CloudCLI 搭建手册

在浏览器里用 Claude Code。基于开源的 CloudCLI,加了一组本地补丁:免登录、只看 Claude 会话、标题和客户端一致、额度显示、一键打开文件。下面是复刻方法和全部改动代码。

@cloudcli-ai/cloudcli 1.37.3 macOS Node 20+ Claude 订阅账号 更新于 2026-10-09

1快速安装

GitHub 仓库 下载 zip 包含安装 / 卸载脚本、打好补丁的文件、diff、导入工具
  1. 准备环境。装好 Node 20+(推荐 nvm)和 Claude Code CLI,在终端里运行一次 claude 并用订阅账号登录。
  2. 下载并安装。
    git clone https://github.com/Amewiki777/cloudcli-kit.git && cd cloudcli-kit
    ./install.sh
    如果你的 Claude 要走本地代理(比如 Clash),把端口告诉脚本,它会写进常驻服务的环境变量:
    PROXY=http://127.0.0.1:7897 ./install.sh
  3. 打开 http://localhost:3001。脚本跑完会自动打开。开机自启,不用再手动启动。

脚本依次做的事:装固定版本 1.37.3 → 把 files/ 覆盖进包目录(原文件备份成 *.orig)→ 按你本机的 node / claude 路径生成 launchd 配置并启动 → 注册一个占位用户、跳过首次引导。可以反复运行。

2改了什么、为什么

#定制为什么改动位置
1免登录,只监听 127.0.0.1原版强制注册账号、默认监听 0.0.0.0。自己本机用,登录是多余的.env、dist/index.html
2只显示 Claude 会话原版会把 Codex 等其它工具的会话也混进列表两个 service 文件
3标题和 Claude 客户端一致,隐藏脚本会话原版取第一条输入当标题,会出现 /model 这种;claude -p 脚本产生的大量一次性会话会刷屏claude-session-synchronizer.provider.js
4额度胶囊(右下角)随时看 5 小时 / 本周 / 本周 Fable 用量和重置时间,每 60 秒刷新新增 claude-usage.patch.js
5模型标签带版本号原版只写 Opus / Sonnet,分不清是哪一代claude-models.provider.js
6文件名旁的 「打开 ▾」菜单Claude 改了哪个文件,点一下用默认程序 / 指定编辑器打开、在 Finder 中显示,或预览 Markdown新增 local-file.patch.js
7粘贴任意文件原版粘贴只收图片,PDF、表格只能拖拽dist/index.html
8公式用 LaTeX网页端能渲染 KaTeX,但 Claude 默认按终端习惯写纯文本公式claude-runtime.provider.js

每一项的具体代码见 第 8 节。

3安全须知

不要对局域网开放 免登录模式下,服务端把任何请求都当成同一个用户;「打开文件」和「额度」接口也没有鉴权。只要别人能连上这个端口,就等于能在你的电脑上让 Claude 执行命令。.env 里的 HOST=127.0.0.1 一定不要改成 0.0.0.0。

如果确实要从手机或别的电脑访问,先恢复登录:删掉 .env 里的 VITE_IS_PLATFORM=true,并把 dist/index.html 恢复成 index.html.orig(会丢掉额度胶囊等前端补丁,需要的话只去掉 <title> 后面那行写 auth-token 的脚本)。

4日常运维

# 看日志
tail -f ~/.cloudcli/server.log

# 重启(改了补丁之后)
launchctl kickstart -k gui/$(id -u)/com.cloudcli.server

# 看 3001 端口被谁占着
lsof -nP -iTCP:3001 -sTCP:LISTEN

# 卸载(加 --purge 连 ~/.cloudcli 一起删)
./uninstall.sh
不要 npm update -g 升级会把补丁全部冲掉,而且前端注入脚本依赖 1.37.3 的页面结构。要升级,先对照 patches.diff 逐个确认能打上,再把数据库里的 scan_state.last_scanned_at 置空触发重扫、重启服务。

数据都在哪

位置内容
~/.claude/projects/真正的对话记录(JSONL),终端 claude、CloudCLI、桌面客户端共用
~/.cloudcli/auth.dbCloudCLI 自己的索引:用户、会话列表、设置
~/.cloudcli/assets/聊天里粘贴 / 上传的图片
~/.cloudcli/server.log服务日志
~/Library/LaunchAgents/com.cloudcli.server.plist开机自启配置

5踩过的坑

6导入 Claude 桌面客户端

CloudCLI 和桌面客户端读的是同一批对话记录,但客户端左侧列表只显示它自己建过的会话。tools/import_to_claude_desktop.py 会为其余对话补一个列表项(只新建小 JSON 文件,不改对话记录),之后在客户端里也能接着聊。

python3 tools/import_to_claude_desktop.py          # 试运行:统计 + 预览标题
python3 tools/import_to_claude_desktop.py --apply  # 写入

写入后按 ⌘Q 完全退出 Claude 客户端再打开。标题优先用原有标题,没有就取第一句话的前 50 个字;claude -p 脚本产生的会话、工作目录已删除的会话会自动跳过。撤销:删掉文件里带 "importedBy": "cloudcli-kit" 的 local_*.json。

别两边同时聊同一个会话 CloudCLI 和客户端会往同一个记录文件里写,同时发消息会让记录错乱。

7手动搭建(不用脚本)

想自己一步步来,或者不是 macOS:

  1. npm install -g @cloudcli-ai/cloudcli@1.37.3,然后 PKG="$(npm root -g)/@cloudcli-ai/cloudcli"。
  2. 把压缩包里 files/ 下的文件按相同相对路径覆盖进 $PKG(或在 $PKG 里 patch -p1 < patches.diff)。其中 .env 内容:
    VITE_IS_PLATFORM=true
    HOST=127.0.0.1
    
  3. 前台试跑:node "$PKG/dist-server/server/modules/cli/cli.js",打开 localhost:3001。
  4. 注册占位用户并跳过引导(密码随机,之后用不到):
    curl -X POST localhost:3001/api/auth/register -H 'Content-Type: application/json' \
      -d "{\"username\":\"local\",\"password\":\"$(openssl rand -hex 16)\"}"
    curl -X POST localhost:3001/api/user/complete-onboarding -H 'Content-Type: application/json' -d '{}'
  5. 常驻:macOS 用 launchd(见下面 install.sh 里生成的 plist),Linux 可写一个 systemd --user 服务。文件菜单里的「打开」依赖 macOS 的 open 命令,其余功能跨平台。
install.sh一键安装脚本全文
#!/bin/bash
# CloudCLI 本机定制版一键安装(macOS)
#   用法:  ./install.sh                         # 不走代理
#          PROXY=http://127.0.0.1:7897 ./install.sh   # Claude 需要走本地代理时
# 做的事: 装 @cloudcli-ai/cloudcli@1.37.3 → 覆盖补丁文件 → 写 launchd 常驻 → 注册占位用户并跳过引导
# 前提:   已装 Node 20+ 和 Claude Code CLI,并且终端里 `claude` 已登录(订阅账号)
set -euo pipefail

VERSION=1.37.3
PORT=3001
LABEL=com.cloudcli.server
KIT="$(cd "$(dirname "$0")" && pwd)"
PLIST="$HOME/Library/LaunchAgents/$LABEL.plist"

say() { printf '\033[1;34m==>\033[0m %s\n' "$*"; }
die() { printf '\033[1;31m错误:\033[0m %s\n' "$*" >&2; exit 1; }

[[ "$(uname)" == Darwin ]] || die "这个脚本只支持 macOS(launchd、open 命令都依赖它)"
NODE="$(command -v node)" || die "找不到 node,请先装 Node 20+(推荐 nvm)"
command -v npm >/dev/null || die "找不到 npm"
CLAUDE="$(command -v claude)" || die "找不到 claude,请先装 Claude Code CLI 并登录"
NODE_MAJOR="$("$NODE" -p 'process.versions.node.split(".")[0]')"
(( NODE_MAJOR >= 20 )) || die "Node 版本太旧($("$NODE" -v)),需要 20 以上"

# 1. 安装固定版本:补丁依赖 1.37.3 的文件结构,别装最新版
say "安装 @cloudcli-ai/cloudcli@$VERSION"
npm install -g "@cloudcli-ai/cloudcli@$VERSION"
PKG="$(npm root -g)/@cloudcli-ai/cloudcli"
[[ "$("$NODE" -p "require('$PKG/package.json').version")" == "$VERSION" ]] || die "装上的版本不是 $VERSION"

# 2. 覆盖补丁文件,原文件第一次覆盖前备份成 *.orig
say "应用补丁到 $PKG"
cd "$KIT/files"
find . -type f | while read -r f; do
  dst="$PKG/${f#./}"
  mkdir -p "$(dirname "$dst")"
  [[ -f "$dst" && ! -f "$dst.orig" ]] && cp "$dst" "$dst.orig"
  cp "$f" "$dst"
done
cd "$KIT"

# 3. launchd 常驻。launchd 不读 shell 配置,所以 node / claude 的目录都写成绝对路径
say "写入 $PLIST"
mkdir -p "$HOME/.cloudcli" "$HOME/Library/LaunchAgents"
PATH_VALUE="$(dirname "$NODE"):$(dirname "$CLAUDE"):/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin"
PROXY_XML=""
if [[ -n "${PROXY:-}" ]]; then
  for k in http_proxy https_proxy HTTP_PROXY HTTPS_PROXY; do
    PROXY_XML+="    <key>$k</key><string>$PROXY</string>"$'\n'
  done
  for k in no_proxy NO_PROXY; do
    PROXY_XML+="    <key>$k</key><string>localhost,127.0.0.1,::1</string>"$'\n'
  done
fi
cat > "$PLIST" <<EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
  <key>Label</key><string>$LABEL</string>
  <key>ProgramArguments</key>
  <array>
    <string>$NODE</string>
    <string>$PKG/dist-server/server/modules/cli/cli.js</string>
  </array>
  <key>WorkingDirectory</key><string>$HOME</string>
  <key>EnvironmentVariables</key>
  <dict>
    <key>PATH</key><string>$PATH_VALUE</string>
    <key>HOME</key><string>$HOME</string>
$PROXY_XML  </dict>
  <key>RunAtLoad</key><true/>
  <key>KeepAlive</key><true/>
  <key>ThrottleInterval</key><integer>5</integer>
  <key>StandardOutPath</key><string>$HOME/.cloudcli/server.log</string>
  <key>StandardErrorPath</key><string>$HOME/.cloudcli/server.log</string>
</dict>
</plist>
EOF
plutil -lint "$PLIST" >/dev/null

# 端口被旧实例占着时 launchd 会无限重启,先清掉
OLD_PID="$(lsof -nP -t -iTCP:$PORT -sTCP:LISTEN 2>/dev/null || true)"
launchctl bootout "gui/$(id -u)/$LABEL" 2>/dev/null || true
[[ -n "$OLD_PID" ]] && { say "结束占用 $PORT 端口的旧进程 $OLD_PID"; kill $OLD_PID 2>/dev/null || true; sleep 1; }
launchctl bootstrap "gui/$(id -u)" "$PLIST"

say "等待服务启动"
for _ in $(seq 1 30); do
  curl -fsS "http://127.0.0.1:$PORT/" >/dev/null 2>&1 && break
  sleep 1
done
curl -fsS "http://127.0.0.1:$PORT/" >/dev/null 2>&1 || die "服务没起来,看日志: tail -50 ~/.cloudcli/server.log"

# 4. 注册占位用户(免登录模式下所有请求都映射到第一个用户,密码随机生成后不再使用)
say "注册占位用户 local 并跳过首次引导"
PW="$(openssl rand -hex 16)"
curl -fsS -X POST "http://127.0.0.1:$PORT/api/auth/register" \
  -H 'Content-Type: application/json' -d "{\"username\":\"local\",\"password\":\"$PW\"}" >/dev/null 2>&1 \
  || echo "   (已有用户,跳过注册)"
curl -fsS -X POST "http://127.0.0.1:$PORT/api/user/complete-onboarding" \
  -H 'Authorization: Bearer local' -H 'Content-Type: application/json' -d '{}' >/dev/null 2>&1 || true

say "完成 → 打开 http://localhost:$PORT"
echo "   日志:   tail -f ~/.cloudcli/server.log"
echo "   重启:   launchctl kickstart -k gui/\$(id -u)/$LABEL"
echo "   卸载:   ./uninstall.sh"
open "http://localhost:$PORT" || true

8全部代码

相对原版 1.37.3 的完整 diff:7 个改动文件 + 2 个新增文件,点开查看。压缩包里 patches.diff 是同一份内容,files/ 是打完补丁的完整文件。

dist-server/server/index.js+28 −0挂载 /api/claude-usage 和 /api/local-file/* 路由
--- a/dist-server/server/index.js
+++ b/dist-server/server/index.js
@@ -33,6 +33,8 @@
 import { browserUseService } from './modules/browser-use/browser-use.service.js';
 import { initializeDatabase, sessionsDb } from './modules/database/index.js';
 import { configureWebPush } from './modules/notifications/index.js';
+import { getClaudeUsage } from './claude-usage.patch.js'; // PATCHED (local)
+import { listApps, openTarget, renderPreview } from './local-file.patch.js'; // PATCHED (local)
 const __dirname = getModuleDirectory(import.meta.url);
 // The server source runs from /server, while the compiled output runs from /dist-server/server.
 // Resolving the app root once keeps every repo-level lookup below aligned across both layouts.
\ No newline at end of file
@@ -152,6 +154,32 @@
 // Agent API Routes (uses API key authentication)
 app.use('/api/agent', agentRoutes);
 app.use('/api/voice', authenticateToken, voiceRoutes);
+// PATCHED (local): Claude plan usage meter for the web UI widget.
+app.get('/api/claude-usage', async (req, res) => {
+    try {
+        const u = await getClaudeUsage(req.query.force === '1');
+        const rl = u.rate_limits || {};
+        res.json({
+            fetchedAt: u.fetchedAt, cached: Boolean(u.cached), subscription_type: u.subscription_type,
+            available: Boolean(u.rate_limits_available),
+            limits: Array.isArray(rl.limits) ? rl.limits : null,
+            five_hour: rl.five_hour ?? null, seven_day: rl.seven_day ?? null,
+            extra_usage: rl.extra_usage ? { is_enabled: rl.extra_usage.is_enabled, utilization: rl.extra_usage.utilization } : null,
+        });
+    } catch (error) {
+        res.status(502).json({ error: error instanceof Error ? error.message : String(error) });
+    }
+});
+// PATCHED (local): open local files from the chat UI.
+app.get('/api/local-file/apps', (_req, res) => { res.json({ apps: listApps() }); });
+app.post('/api/local-file/open', express.json(), async (req, res) => {
+    try { res.json(await openTarget({ path: req.body?.path, mode: req.body?.mode, app: req.body?.app, dry: req.body?.dry === true })); }
+    catch (e) { res.status(e.status || 500).json({ error: e.message }); }
+});
+app.get('/api/local-file/preview', (req, res) => {
+    try { res.type('html').send(renderPreview(String(req.query.path || ''))); }
+    catch (e) { res.status(e.status || 500).type('text').send(e.message); }
+});
 // Serve public files (like api-docs.html)
 app.use(express.static(path.join(APP_ROOT, 'public')));
 // Static files served after API routes
\ No newline at end of file
dist-server/server/modules/providers/list/claude/claude-models.provider.js+9 −8模型标签带版本号
--- a/dist-server/server/modules/providers/list/claude/claude-models.provider.js
+++ b/dist-server/server/modules/providers/list/claude/claude-models.provider.js
@@ -1,3 +1,4 @@
+// PATCHED (local): model labels carry version numbers (aliases unchanged).
 import { readFile } from 'node:fs/promises';
 import { sessionsDb } from '../../../../modules/database/index.js';
 import { buildDefaultProviderCurrentActiveModel } from '../../../../shared/utils.js';
\ No newline at end of file
@@ -31,7 +32,7 @@
         {
             value: 'best',
             label: 'Best available',
-            description: 'Use Fable 5 when available, otherwise the latest Opus model.',
+            description: 'Use Fable 5.1 when available, otherwise Opus 5.5.',
             effort: {
                 default: 'high',
                 values: [
\ No newline at end of file
@@ -46,7 +47,7 @@
         },
         {
             value: 'fable',
-            label: 'Fable 5',
+            label: 'Fable 5.1',
             description: 'Most capable Claude model for the hardest, longest-running tasks.',
             effort: {
                 default: 'high',
\ No newline at end of file
@@ -62,7 +63,7 @@
         },
         {
             value: 'sonnet',
-            label: 'Sonnet',
+            label: 'Sonnet 5',
             description: 'Latest Sonnet model for everyday coding tasks.',
             effort: {
                 default: 'high',
\ No newline at end of file
@@ -78,7 +79,7 @@
         },
         {
             value: 'sonnet[1m]',
-            label: 'Sonnet (1M context)',
+            label: 'Sonnet 5 (1M context)',
             description: 'Latest Sonnet model with a 1M context window.',
             effort: {
                 default: 'high',
\ No newline at end of file
@@ -94,7 +95,7 @@
         },
         {
             value: 'opus',
-            label: 'Opus',
+            label: 'Opus 5.5',
             description: 'Latest Opus model for complex reasoning and coding tasks.',
             effort: {
                 default: 'high',
\ No newline at end of file
@@ -110,7 +111,7 @@
         },
         {
             value: 'opus[1m]',
-            label: 'Opus (1M context)',
+            label: 'Opus 5.5 (1M context)',
             description: 'Latest Opus model with a 1M context window.',
             effort: {
                 default: 'high',
\ No newline at end of file
@@ -126,12 +127,12 @@
         },
         {
             value: 'haiku',
-            label: 'Haiku',
+            label: 'Haiku 4.5',
             description: 'Fast and efficient Claude model for simple tasks.',
         },
         {
             value: 'opusplan',
-            label: 'Opus Plan',
+            label: 'Opus 5.5 Plan',
             description: 'Use Opus while planning, then switch to Sonnet for execution.',
             effort: {
                 default: 'high',
\ No newline at end of file
dist-server/server/modules/providers/list/claude/claude-runtime.provider.js+9 −1系统提示追加:公式用 LaTeX
--- a/dist-server/server/modules/providers/list/claude/claude-runtime.provider.js
+++ b/dist-server/server/modules/providers/list/claude/claude-runtime.provider.js
@@ -219,9 +219,17 @@
     sdkOptions.disallowedTools = settings.disallowedTools || [];
     sdkOptions.model = options.model || CLAUDE_PREDEFINED_MODELS.DEFAULT;
     applyClaudeEffort(sdkOptions, resolveClaudeEffort(sdkOptions.model, effort, options.effortModels || CLAUDE_PREDEFINED_MODELS));
+    // PATCHED (local): the web UI renders Markdown + KaTeX, so ask for LaTeX math instead of plain-text formulas.
+    const CLOUDCLI_MATH_NOTE = [
+        'Rendering note: your replies are displayed in a web chat UI (CloudCLI) that renders GitHub-flavored Markdown with KaTeX, not in a terminal.',
+        'Write ALL mathematics as LaTeX: inline math as $...$ and display equations as $$...$$ on their own lines.',
+        'Never put formulas in plain-text or ```text code blocks, and do not substitute Unicode pseudo-math (Σ, ∫, ², ≈, ·) for LaTeX.',
+        'Code blocks are only for code, commands and file contents. Tables, headings, lists and Mermaid diagrams render normally.',
+    ].join(' ');
     sdkOptions.systemPrompt = {
         type: 'preset',
-        preset: 'claude_code'
+        preset: 'claude_code',
+        append: CLOUDCLI_MATH_NOTE
     };
     sdkOptions.settingSources = ['project', 'user', 'local'];
     // The SDK resumes with the provider-native session id, never the app id.
\ No newline at end of file
dist-server/server/modules/providers/list/claude/claude-session-synchronizer.provider.js+84 −96标题规则与客户端一致,隐藏 claude -p 脚本会话
--- a/dist-server/server/modules/providers/list/claude/claude-session-synchronizer.provider.js
+++ b/dist-server/server/modules/providers/list/claude/claude-session-synchronizer.provider.js
@@ -1,141 +1,129 @@
+// PATCHED (local): title priority = Claude custom-title > ai-title > first real prompt.
+// Original kept as *.orig. Only Claude sessions are indexed.
 import os from 'node:os';
 import path from 'node:path';
+import fs from 'node:fs';
+import readline from 'node:readline';
 import { readFile } from 'node:fs/promises';
 import { sessionsDb } from '../../../../modules/database/index.js';
-import { buildLookupMap, extractFirstValidJsonlData, findFilesRecursivelyCreatedAfter, normalizeSessionName, readFileTimestamps, } from '../../../../shared/utils.js';
-/**
- * Session indexer for Claude transcript artifacts.
- */
+import { extractFirstValidJsonlData, findFilesRecursivelyCreatedAfter, normalizeSessionName, readFileTimestamps, } from '../../../../shared/utils.js';
+const isMeaningfulPrompt = (text) => {
+    const t = typeof text === 'string' ? text.trim() : '';
+    return t.length > 0 && !t.startsWith('/') && !t.startsWith('<');
+};
+const textOfUserMessage = (message) => {
+    if (!message || message.role !== 'user') return undefined;
+    const c = message.content;
+    if (typeof c === 'string') return c;
+    if (Array.isArray(c)) {
+        for (const block of c) {
+            if (block && block.type === 'text' && typeof block.text === 'string') return block.text;
+        }
+    }
+    return undefined;
+};
+async function buildFirstPromptMap(filePath) {
+    const lookup = new Map();
+    lookup.interactiveIds = new Set();
+    try {
+        const rl = readline.createInterface({ input: fs.createReadStream(filePath), crlfDelay: Infinity });
+        for await (const line of rl) {
+            const trimmed = line.trim();
+            if (!trimmed) continue;
+            let parsed;
+            try { parsed = JSON.parse(trimmed); } catch { continue; }
+            const key = parsed.sessionId;
+            const value = parsed.display;
+            if (typeof key === 'string') lookup.interactiveIds.add(key);
+            if (typeof key === 'string' && isMeaningfulPrompt(value) && !lookup.has(key)) {
+                lookup.set(key, value.trim());
+            }
+        }
+    } catch { /* history.jsonl missing */ }
+    return lookup;
+}
 export class ClaudeSessionSynchronizer {
     provider = 'claude';
     claudeHome = path.join(os.homedir(), '.claude');
-    /**
-     * Returns true when a JSONL file is a subagent transcript or tool result
-     * rather than a top-level session.
-     *
-     * Claude stores subagent transcripts under a `subagents/` directory and
-     * tool results under a `tool-results/` directory, e.g.
-     * `~/.claude/projects/<encoded-cwd>/<session-id>/subagents/agent-<id>.jsonl`.
-     * Those files repeat the parent session's `sessionId`, so indexing them as
-     * standalone sessions overwrites the parent row's `jsonl_path` and corrupts
-     * the main session record. The recursive scan in `synchronize()` reaches
-     * them, so both entry points must skip them.
-     */
     isSubagentTranscript(filePath) {
         const pathParts = path.normalize(filePath).split(path.sep);
         return pathParts.includes('subagents') || pathParts.includes('tool-results');
     }
-    /**
-     * Scans ~/.claude/projects and upserts discovered sessions into DB.
-     */
     async synchronize(since) {
-        const nameMap = await buildLookupMap(path.join(this.claudeHome, 'history.jsonl'), 'sessionId', 'display');
+        const nameMap = await buildFirstPromptMap(path.join(this.claudeHome, 'history.jsonl'));
         const files = await findFilesRecursivelyCreatedAfter(path.join(this.claudeHome, 'projects'), '.jsonl', since ?? null);
         let processed = 0;
         for (const filePath of files) {
-            if (this.isSubagentTranscript(filePath)) {
-                continue;
-            }
+            if (this.isSubagentTranscript(filePath)) continue;
             const parsed = await this.processSessionFile(filePath, nameMap);
-            if (!parsed) {
-                continue;
-            }
+            if (!parsed) continue;
             const timestamps = await readFileTimestamps(filePath);
             sessionsDb.createSession(parsed.sessionId, this.provider, parsed.projectPath, parsed.sessionName, timestamps.createdAt, timestamps.updatedAt, filePath);
             processed += 1;
         }
         return processed;
     }
-    /**
-     * Parses and upserts one Claude session JSONL file.
-     */
     async synchronizeFile(filePath) {
-        if (!filePath.endsWith('.jsonl')) {
-            return null;
-        }
-        if (this.isSubagentTranscript(filePath)) {
-            return null;
-        }
-        const nameMap = await buildLookupMap(path.join(this.claudeHome, 'history.jsonl'), 'sessionId', 'display');
+        if (!filePath.endsWith('.jsonl')) return null;
+        if (this.isSubagentTranscript(filePath)) return null;
+        const nameMap = await buildFirstPromptMap(path.join(this.claudeHome, 'history.jsonl'));
         const parsed = await this.processSessionFile(filePath, nameMap);
-        if (!parsed) {
-            return null;
-        }
+        if (!parsed) return null;
         const timestamps = await readFileTimestamps(filePath);
         return sessionsDb.createSession(parsed.sessionId, this.provider, parsed.projectPath, parsed.sessionName, timestamps.createdAt, timestamps.updatedAt, filePath);
     }
-    /**
-     * Extracts session metadata from one Claude JSONL session file.
-     */
     async processSessionFile(filePath, nameMap) {
         const parsed = await extractFirstValidJsonlData(filePath, (rawData) => {
             const data = rawData;
             const sessionId = typeof data.sessionId === 'string' ? data.sessionId : undefined;
             const projectPath = typeof data.cwd === 'string' ? data.cwd : undefined;
-            if (!sessionId || !projectPath) {
-                return null;
-            }
-            return {
-                sessionId,
-                projectPath,
-            };
+            if (!sessionId || !projectPath) return null;
+            return { sessionId, projectPath };
         });
-        if (!parsed) {
-            return null;
-        }
-        // App-created sessions are keyed by an app id, so disk-discovered provider
-        // ids must be resolved through the provider-id mapping first.
-        const existingSession = sessionsDb.getSessionByProviderSessionId(parsed.sessionId)
-            ?? sessionsDb.getSessionById(parsed.sessionId);
-        const existingSessionName = existingSession?.custom_name;
-        if (existingSessionName && existingSessionName !== 'Untitled Claude Session') {
-            return {
-                ...parsed,
-                sessionName: normalizeSessionName(existingSessionName, 'Untitled Claude Session'),
-            };
-        }
-        let sessionName = nameMap.get(parsed.sessionId);
-        if (!sessionName) {
-            sessionName = await this.extractSessionAiTitleFromEnd(filePath, parsed.sessionId);
-        }
-        return {
-            ...parsed,
-            sessionName: normalizeSessionName(sessionName, 'Untitled Claude Session'),
-        };
+        if (!parsed) return null;
+        const titles = await this.extractTitlesFromTranscript(filePath, parsed.sessionId);
+        // PATCHED (local): skip headless script sessions (claude -p / SDK one-shots).
+        // A real conversation has a desktop custom-title, an ai-title, an interactive
+        // "mode" record, an entry in history.jsonl, or was created by this app.
+        const existingRow = sessionsDb.getSessionByProviderSessionId(parsed.sessionId) ?? sessionsDb.getSessionById(parsed.sessionId);
+        const createdByApp = Boolean(existingRow && existingRow.session_id !== existingRow.provider_session_id);
+        const interactive = Boolean(titles.customTitle || titles.aiTitle || titles.hasMode || nameMap.interactiveIds?.has(parsed.sessionId) || createdByApp);
+        if (!interactive) return null;
+        const sessionName = titles.customTitle || titles.aiTitle || nameMap.get(parsed.sessionId) || titles.firstPrompt;
+        return { ...parsed, sessionName: normalizeSessionName(sessionName, 'Untitled Claude Session') };
     }
-    async extractSessionAiTitleFromEnd(filePath, sessionId) {
+    async extractTitlesFromTranscript(filePath, sessionId) {
+        const result = { customTitle: undefined, aiTitle: undefined, firstPrompt: undefined, hasMode: false };
         try {
             const content = await readFile(filePath, 'utf8');
             const lines = content.split(/\r?\n/);
             for (let index = lines.length - 1; index >= 0; index -= 1) {
+                if (result.customTitle && result.aiTitle && result.hasMode) break;
                 const line = lines[index]?.trim();
-                if (!line) {
-                    continue;
+                if (!line) continue;
+                let data;
+                try { data = JSON.parse(line); } catch { continue; }
+                if (typeof data.sessionId === 'string' && data.sessionId !== sessionId) continue;
+                if (data.type === 'mode' || data.type === 'permission-mode') result.hasMode = true;
+                if (!result.customTitle && data.type === 'custom-title' && typeof data.customTitle === 'string' && data.customTitle.trim()) {
+                    result.customTitle = data.customTitle.trim();
                 }
-                let parsed;
-                try {
-                    parsed = JSON.parse(line);
+                if (!result.aiTitle && data.type === 'ai-title' && typeof data.aiTitle === 'string' && data.aiTitle.trim()) {
+                    result.aiTitle = data.aiTitle.trim();
                 }
-                catch {
-                    continue;
+            }
+            if (!result.customTitle && !result.aiTitle) {
+                for (const raw of lines) {
+                    const line = raw.trim();
+                    if (!line) continue;
+                    let data;
+                    try { data = JSON.parse(line); } catch { continue; }
+                    if (data.type !== 'user' || data.isSidechain) continue;
+                    const text = textOfUserMessage(data.message);
+                    if (isMeaningfulPrompt(text)) { result.firstPrompt = text.trim().slice(0, 200); break; }
                 }
-                const data = parsed;
-                const eventType = typeof data.type === 'string' ? data.type : undefined;
-                const eventSessionId = typeof data.sessionId === 'string' ? data.sessionId : undefined;
-                const aiTitle = typeof data.aiTitle === 'string' ? data.aiTitle : undefined;
-                const lastPrompt = typeof data.lastPrompt === 'string' ? data.lastPrompt : undefined;
-                const claudeRenamedTitle = typeof data.customTitle === 'string' ? data.customTitle : undefined;
-                if ((eventType === 'ai-title' && eventSessionId === sessionId && aiTitle?.trim()) ||
-                    (eventType === 'last-prompt' && eventSessionId === sessionId && lastPrompt?.trim()) ||
-                    (eventType === "custom-title" && eventSessionId === sessionId && claudeRenamedTitle?.trim())) {
-                    return aiTitle || lastPrompt || claudeRenamedTitle;
-                }
             }
-        }
-        catch {
-            // Ignore missing/unreadable files so sync can continue.
-        }
-        return undefined;
+        } catch { /* unreadable file: fall through */ }
+        return result;
     }
 }
-//# sourceMappingURL=claude-session-synchronizer.provider.js.map
\ No newline at end of file
dist-server/server/modules/providers/services/session-synchronizer.service.js+1 −1同步时只处理 claude provider
--- a/dist-server/server/modules/providers/services/session-synchronizer.service.js
+++ b/dist-server/server/modules/providers/services/session-synchronizer.service.js
@@ -69,7 +69,7 @@
         opencode: 0,
     };
     const failures = [];
-    const results = await Promise.allSettled(providerRegistry.listProviders().map(async (provider) => ({
+    const results = await Promise.allSettled(providerRegistry.listProviders().filter((provider) => provider.id === "claude") /* PATCHED (local) */ .map(async (provider) => ({
         provider: provider.id,
         processed: await provider.sessionSynchronizer.synchronize(lastScanAt ?? undefined),
     })));
\ No newline at end of file
dist-server/server/modules/providers/services/sessions-watcher.service.js+3 −1文件监听只看 ~/.claude/projects
--- a/dist-server/server/modules/providers/services/sessions-watcher.service.js
+++ b/dist-server/server/modules/providers/services/sessions-watcher.service.js
@@ -4,7 +4,7 @@
 import chokidar from 'chokidar';
 import { sessionSynchronizerService } from '../../../modules/providers/services/session-synchronizer.service.js';
 import { broadcastSessionUpsertedBatch } from '../../../modules/websocket/index.js';
-const PROVIDER_WATCH_PATHS = [
+const PROVIDER_WATCH_PATHS_ALL = [
     {
         provider: 'claude',
         rootPath: path.join(os.homedir(), '.claude', 'projects'),
\ No newline at end of file
@@ -22,6 +22,8 @@
         rootPath: path.join(os.homedir(), '.local', 'share', 'opencode'),
     },
 ];
+// PATCHED (local): only watch Claude transcripts.
+const PROVIDER_WATCH_PATHS = PROVIDER_WATCH_PATHS_ALL.filter((entry) => entry.provider === "claude");
 const WATCHER_IGNORED_PATTERNS = [
     '**/node_modules/**',
     '**/.git/**',
\ No newline at end of file
dist/index.html+135 −0注入占位 token(免登录)+ 额度胶囊 + 文件「打开 ▾」菜单 + 粘贴非图片文件
--- a/dist/index.html
+++ b/dist/index.html
@@ -6,6 +6,7 @@
     <link rel="icon" type="image/png" href="/favicon.png" />
     <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no, viewport-fit=cover, interactive-widget=resizes-content" />
     <title>CloudCLI UI</title>
+    <script>try{localStorage.getItem("auth-token")||localStorage.setItem("auth-token","eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJ1c2VySWQiOjEsInVzZXJuYW1lIjoibG9jYWwiLCJpYXQiOjE3MDAwMDAwMDAsImV4cCI6NDEwMjQ0NDgwMH0.local")}catch(e){}</script>
 
     <!-- Fonts: Encode Sans (UI) + Merriweather (chat) -->
     <link rel="preconnect" href="https://fonts.googleapis.com" />
@@ -38,6 +39,140 @@
     <link rel="modulepreload" crossorigin href="/assets/vendor-codemirror-CYrSt7-U.js">
     <link rel="modulepreload" crossorigin href="/assets/vendor-xterm-CS4rQ5Mr.js">
     <link rel="stylesheet" crossorigin href="/assets/index-BTDHQ6Id.css">
+  
+    <!-- PATCH:claude-usage-widget -->
+    <style>
+      #cu-widget{position:fixed;right:14px;bottom:14px;z-index:9999;font:12px/1.4 -apple-system,BlinkMacSystemFont,"Encode Sans",sans-serif;color:#1f2937}
+      #cu-widget .cu-pill{display:flex;gap:10px;align-items:center;padding:6px 12px;border-radius:999px;background:rgba(255,255,255,.92);border:1px solid rgba(0,0,0,.12);box-shadow:0 2px 10px rgba(0,0,0,.12);cursor:pointer;backdrop-filter:blur(8px);white-space:nowrap}
+      #cu-widget .cu-pill span b{font-weight:600}
+      #cu-widget .cu-panel{display:none;position:absolute;right:0;bottom:36px;width:280px;padding:12px 14px;border-radius:12px;background:rgba(255,255,255,.97);border:1px solid rgba(0,0,0,.12);box-shadow:0 6px 24px rgba(0,0,0,.16)}
+      #cu-widget.open .cu-panel{display:block}
+      #cu-widget .cu-row{margin:8px 0}
+      #cu-widget .cu-row .cu-l{display:flex;justify-content:space-between;margin-bottom:3px}
+      #cu-widget .cu-bar{height:6px;border-radius:3px;background:rgba(0,0,0,.08);overflow:hidden}
+      #cu-widget .cu-bar i{display:block;height:100%;border-radius:3px;background:#3b82f6}
+      #cu-widget .warning i,#cu-widget .warning .cu-dot{background:#f59e0b}
+      #cu-widget .critical i,#cu-widget .critical .cu-dot{background:#ef4444}
+      #cu-widget .cu-dot{display:inline-block;width:7px;height:7px;border-radius:50%;background:#3b82f6;margin-right:5px;vertical-align:1px}
+      #cu-widget .cu-meta{margin-top:8px;font-size:11px;color:#6b7280;display:flex;justify-content:space-between}
+      #cu-widget .cu-meta a{color:#6b7280;cursor:pointer;text-decoration:underline}
+      @media (prefers-color-scheme:dark){#cu-widget{color:#e5e7eb}#cu-widget .cu-pill,#cu-widget .cu-panel{background:rgba(24,24,27,.94);border-color:rgba(255,255,255,.14)}#cu-widget .cu-bar{background:rgba(255,255,255,.12)}#cu-widget .cu-meta,#cu-widget .cu-meta a{color:#9ca3af}}
+      .dark #cu-widget{color:#e5e7eb}.dark #cu-widget .cu-pill,.dark #cu-widget .cu-panel{background:rgba(24,24,27,.94);border-color:rgba(255,255,255,.14)}.dark #cu-widget .cu-bar{background:rgba(255,255,255,.12)}.dark #cu-widget .cu-meta,.dark #cu-widget .cu-meta a{color:#9ca3af}
+    </style>
+    <script>
+    (function(){
+      var POLL=60000, el, pill, panel, timer;
+      function label(r){ if(r.kind==='session')return '5 小时'; if(r.kind==='weekly_all')return '本周 · 全部模型'; if(r.kind==='weekly_scoped'){var s=r.scope||{};var n=(s.model&&s.model.display_name)||(s.surface&&s.surface.display_name)||'';return '本周 · '+n;} return r.kind; }
+      function short(r){ if(r.kind==='session')return '5h'; if(r.kind==='weekly_all')return '周'; if(r.kind==='weekly_scoped'){var s=r.scope||{};return (s.model&&s.model.display_name)||(s.surface&&s.surface.display_name)||'周*';} return r.kind; }
+      function resetText(iso){ if(!iso)return ''; var d=new Date(iso), ms=d-Date.now(); if(ms<=0)return '即将重置'; var h=Math.floor(ms/3600000), m=Math.floor(ms%3600000/60000); if(h>=48)return Math.floor(h/24)+' 天后重置'; return (h?h+' 小时 ':'')+m+' 分后重置'; }
+      function render(d){
+        var rows=(d.limits||[]).slice();
+        if(!rows.length){ pill.innerHTML='<span>额度:不可用</span>'; panel.innerHTML='<div>'+(d.available===false?'当前登录方式不适用套餐额度':'暂无数据')+'</div>'; return; }
+        pill.innerHTML=rows.map(function(r){return '<span class="'+r.severity+'"><i class="cu-dot"></i>'+short(r)+' <b>'+r.percent+'%</b></span>';}).join('');
+        panel.innerHTML=rows.map(function(r){return '<div class="cu-row '+r.severity+'"><div class="cu-l"><span>'+label(r)+'</span><span><b>'+r.percent+'%</b></span></div><div class="cu-bar"><i style="width:'+Math.min(100,r.percent)+'%"></i></div><div style="font-size:11px;color:#6b7280;margin-top:2px">'+resetText(r.resets_at)+'</div></div>';}).join('')
+          +'<div class="cu-meta"><span>'+(d.subscription_type?('套餐 '+d.subscription_type.toUpperCase()):'')+' · '+new Date(d.fetchedAt).toLocaleTimeString()+'</span><a data-refresh>刷新</a></div>';
+      }
+      function load(force){
+        fetch('/api/claude-usage'+(force?'?force=1':'')).then(function(r){return r.json();}).then(render).catch(function(){ pill.innerHTML='<span>额度:读取失败</span>'; });
+      }
+      function init(){
+        el=document.createElement('div'); el.id='cu-widget';
+        el.innerHTML='<div class="cu-panel"></div><div class="cu-pill"><span>额度加载中…</span></div>';
+        document.body.appendChild(el); pill=el.querySelector('.cu-pill'); panel=el.querySelector('.cu-panel');
+        pill.addEventListener('click',function(){ el.classList.toggle('open'); });
+        panel.addEventListener('click',function(e){ if(e.target&&e.target.hasAttribute('data-refresh')){ load(true); } });
+        load(false); timer=setInterval(function(){ if(!document.hidden) load(false); },POLL);
+        document.addEventListener('visibilitychange',function(){ if(!document.hidden) load(false); });
+      }
+      if(document.readyState==='loading') document.addEventListener('DOMContentLoaded',init); else init();
+    })();
+    </script>
+    <!-- /PATCH:claude-usage-widget -->
+  
+    <!-- PATCH:file-actions -->
+    <style>
+      .cu-fa-btn{display:inline-flex;align-items:center;gap:3px;margin-left:6px;padding:0 6px;height:18px;border-radius:9px;border:1px solid rgba(0,0,0,.15);background:rgba(0,0,0,.04);font-size:10px;line-height:1;color:#374151;cursor:pointer;vertical-align:middle;white-space:nowrap}
+      .cu-fa-btn:hover{background:rgba(59,130,246,.12);border-color:rgba(59,130,246,.4);color:#1d4ed8}
+      .dark .cu-fa-btn{border-color:rgba(255,255,255,.18);background:rgba(255,255,255,.06);color:#d1d5db}.dark .cu-fa-btn:hover{color:#93c5fd}
+      .cu-fa-menu{position:fixed;z-index:10000;min-width:200px;padding:6px;border-radius:10px;background:#fff;border:1px solid rgba(0,0,0,.12);box-shadow:0 8px 28px rgba(0,0,0,.18);font:13px/1.4 -apple-system,BlinkMacSystemFont,"Encode Sans",sans-serif;color:#1f2937}
+      .dark .cu-fa-menu{background:#1c1c20;border-color:rgba(255,255,255,.14);color:#e5e7eb}
+      .cu-fa-menu .cu-fa-path{padding:4px 8px 6px;font-size:11px;color:#6b7280;word-break:break-all;border-bottom:1px solid rgba(128,128,128,.2);margin-bottom:4px}
+      .cu-fa-menu .cu-fa-item{display:block;width:100%;text-align:left;padding:6px 8px;border-radius:6px;background:none;border:0;color:inherit;font:inherit;cursor:pointer}
+      .cu-fa-menu .cu-fa-item:hover{background:rgba(59,130,246,.12)}
+      .cu-fa-menu .cu-fa-item[disabled]{opacity:.5;cursor:default}
+    </style>
+    <script>
+    (function(){
+      var apps=null, menu=null, FIBER_PREFIX='__reactFiber'+String.fromCharCode(36);
+      function getApps(cb){ if(apps){cb(apps);return;} fetch('/api/local-file/apps').then(function(r){return r.json();}).then(function(d){apps=d.apps||[];cb(apps);}).catch(function(){apps=[];cb(apps);}); }
+      function fiberOf(el){ for(var k in el){ if(k.indexOf(FIBER_PREFIX)===0) return el[k]; } return null; }
+      function findTool(el){ var f=fiberOf(el), n=0; while(f&&n<30){ var p=f.memoizedProps; if(p&&p.toolInput&&typeof p.toolInput==='object'){ var ti=p.toolInput; var fp=ti.file_path||ti.notebook_path||ti.path; if(typeof fp==='string'&&fp) return {path:fp,toolName:p.toolName,onFileOpen:typeof p.onFileOpen==='function'?p.onFileOpen:null}; return null; } f=f.return; n++; } return null; }
+      function post(body){ return fetch('/api/local-file/open',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)}).then(function(r){return r.json();}).then(function(d){ if(d.error) alert('打开失败:'+d.error); }); }
+      function closeMenu(){ if(menu){menu.remove();menu=null;} }
+      document.addEventListener('click',function(e){ if(menu&&!menu.contains(e.target)) closeMenu(); },true);
+      document.addEventListener('keydown',function(e){ if(e.key==='Escape') closeMenu(); });
+      function openMenu(btn,info){
+        closeMenu(); var r=btn.getBoundingClientRect(); menu=document.createElement('div'); menu.className='cu-fa-menu';
+        var isMd=/\.(md|markdown|mdx|txt)$/i.test(info.path);
+        function item(label,fn,dis){ var b=document.createElement('button'); b.className='cu-fa-item'; b.textContent=label; if(dis) b.disabled=true; b.addEventListener('click',function(ev){ev.stopPropagation(); closeMenu(); fn();}); menu.appendChild(b); }
+        var pathEl=document.createElement('div'); pathEl.className='cu-fa-path'; pathEl.textContent=info.path; menu.appendChild(pathEl);
+        item('直接打开(默认程序)',function(){post({path:info.path,mode:'default'});});
+        getApps(function(list){ list.forEach(function(a){ item('用 '+a+' 打开',function(){post({path:info.path,mode:'app',app:a});}); }); });
+        item('在 CloudCLI 编辑器中打开',function(){ info.onFileOpen(info.path); },!info.onFileOpen);
+        if(isMd) item('预览 Markdown(新标签页)',function(){ window.open('/api/local-file/preview?path='+encodeURIComponent(info.path),'_blank'); });
+        item('在访达中显示',function(){post({path:info.path,mode:'reveal'});});
+        item('复制路径',function(){ try{navigator.clipboard.writeText(info.path);}catch(e){} });
+        document.body.appendChild(menu);
+        var mw=menu.offsetWidth, mh=menu.offsetHeight, x=Math.min(r.left, window.innerWidth-mw-8), y=r.bottom+4; if(y+mh>window.innerHeight-8) y=r.top-mh-4;
+        menu.style.left=Math.max(8,x)+'px'; menu.style.top=Math.max(8,y)+'px';
+      }
+      function placeButton(root,info){
+        var base=info.path.split('/').pop(), anchor=null, els=root.querySelectorAll('span,button,code,div,a');
+        for(var i=0;i<els.length;i++){ var t=(els[i].textContent||'').trim(); if(els[i].children.length===0&&(t===base||t===info.path)){ anchor=els[i]; break; } }
+        var btn=document.createElement('button'); btn.className='cu-fa-btn'; btn.type='button'; btn.title=info.path; btn.textContent='打开 ▾';
+        btn.addEventListener('click',function(e){ e.preventDefault(); e.stopPropagation(); openMenu(btn,info); });
+        if(anchor){ anchor.insertAdjacentElement('afterend',btn); } else { (root.firstElementChild||root).appendChild(btn); }
+      }
+      function scan(){
+        var nodes=document.querySelectorAll('div[class*="border-l-2"]:not([data-cu-fa]),div.group:not([data-cu-fa])');
+        for(var i=0;i<nodes.length;i++){ var el=nodes[i]; var info=findTool(el); if(!info){ el.setAttribute('data-cu-fa','none'); continue; } if(!/^(Write|Edit|MultiEdit|Read|NotebookEdit|ApplyPatch)$/.test(info.toolName||'')) { el.setAttribute('data-cu-fa','skip'); continue; } el.setAttribute('data-cu-fa','1'); placeButton(el,info); }
+      }
+      var timer=null; function schedule(){ if(timer) return; timer=setTimeout(function(){timer=null; try{scan();}catch(e){}},250); }
+      function init(){ new MutationObserver(schedule).observe(document.body,{childList:true,subtree:true}); schedule(); }
+      if(document.readyState==='loading') document.addEventListener('DOMContentLoaded',init); else init();
+    })();
+    </script>
+    <!-- /PATCH:file-actions -->
+  
+    <!-- PATCH:paste-files -->
+    <script>
+    (function(){
+      // Route non-image files pasted from Finder into the chat composer's dropzone (the app's own paste handler only accepts images).
+      function composerInput(){
+        var ta=document.querySelector('textarea');
+        var scope=ta?(ta.closest('form')||ta.parentElement&&ta.parentElement.parentElement&&ta.parentElement.parentElement.parentElement||document):document;
+        var inputs=Array.prototype.slice.call((scope||document).querySelectorAll('input[type="file"]'));
+        if(!inputs.length) inputs=Array.prototype.slice.call(document.querySelectorAll('input[type="file"]'));
+        // react-dropzone input: tabindex=-1 and clip style, not the file-tree "hidden" upload input
+        var dz=inputs.filter(function(i){return i.getAttribute('tabindex')==='-1'&&!i.classList.contains('hidden');});
+        return dz[0]||inputs[0]||null;
+      }
+      document.addEventListener('paste',function(e){
+        var cd=e.clipboardData; if(!cd||!cd.files||!cd.files.length) return;
+        var files=Array.prototype.slice.call(cd.files);
+        var nonImage=files.filter(function(f){return !(f.type||'').startsWith('image/');});
+        if(!nonImage.length) return; // images: let the app handle
+        var input=composerInput(); if(!input) return;
+        e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation();
+        try{
+          var dt=new DataTransfer(); files.forEach(function(f){dt.items.add(f);});
+          input.files=dt.files;
+          input.dispatchEvent(new Event('change',{bubbles:true}));
+        }catch(err){ console.warn('[paste-files] failed',err); }
+      },true);
+    })();
+    </script>
+    <!-- /PATCH:paste-files -->
   </head>
   <body>
     <div id="root"></div>
dist-server/server/claude-usage.patch.js+31 −0新增:通过 Agent SDK 读订阅额度,缓存 60 秒
--- /dev/null
+++ b/dist-server/server/claude-usage.patch.js
@@ -0,0 +1,31 @@
+// PATCHED (local): Claude plan usage meter for the web UI.
+// Uses the Agent SDK's /usage control request, so the Claude CLI reads its own
+// login credentials; this module never touches them.
+import { query } from '@anthropic-ai/claude-agent-sdk';
+import os from 'node:os';
+const CACHE_MS = 60_000;
+let cache = { at: 0, data: null };
+let inFlight = null;
+async function* never() { await new Promise(() => {}); }
+async function fetchUsage() {
+    const q = query({ prompt: never(), options: { cwd: os.homedir(), permissionMode: 'default', allowedTools: [], maxTurns: 1 } });
+    try {
+        const u = await q.usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET({ skipBehaviors: true });
+        return { fetchedAt: new Date().toISOString(), subscription_type: u.subscription_type ?? null, rate_limits_available: u.rate_limits_available, rate_limits: u.rate_limits ?? null };
+    } finally {
+        // Only close. interrupt() after close rejects asynchronously and would
+        // take the whole server down as an unhandled rejection.
+        try { const r = q.close?.(); if (r && typeof r.catch === 'function') r.catch(() => {}); } catch { /* ignore */ }
+    }
+}
+export async function getClaudeUsage(force = false) {
+    if (!force && cache.data && Date.now() - cache.at < CACHE_MS) return { ...cache.data, cached: true };
+    if (!inFlight) {
+        inFlight = fetchUsage().then((data) => { cache = { at: Date.now(), data }; return data; }).finally(() => { inFlight = null; });
+    }
+    return inFlight;
+}
+if (process.argv[1] && process.argv[1].endsWith('claude-usage.patch.js')) {
+    const t0 = Date.now();
+    getClaudeUsage(true).then((d) => { console.log(JSON.stringify({ ms: Date.now() - t0, ...d }, null, 1)); process.exit(0); }, (e) => { console.error('ERR', e?.message); process.exit(1); });
+}
dist-server/server/local-file.patch.js+62 −0新增:检测编辑器、用 open 打开文件、Markdown 预览
--- /dev/null
+++ b/dist-server/server/local-file.patch.js
@@ -0,0 +1,62 @@
+// PATCHED (local): open local files from the chat UI (default app / chosen app / Finder / markdown preview).
+import fs from 'node:fs';
+import path from 'node:path';
+import os from 'node:os';
+import { execFile } from 'node:child_process';
+import { marked } from 'marked';
+const CANDIDATE_APPS = ['Typora', 'TextEdit', 'Visual Studio Code', 'Cursor', 'Sublime Text', 'Obsidian', 'Zed', 'Xcode', 'BBEdit', 'MacDown', 'iA Writer', 'Nova', 'TextMate'];
+const APP_DIRS = ['/Applications', '/System/Applications', path.join(os.homedir(), 'Applications')];
+export function listApps() {
+    const found = [];
+    for (const name of CANDIDATE_APPS) {
+        for (const dir of APP_DIRS) {
+            if (fs.existsSync(path.join(dir, `${name}.app`))) { found.push(name); break; }
+        }
+    }
+    return found;
+}
+function resolveTarget(raw) {
+    if (typeof raw !== 'string' || !raw.trim()) throw Object.assign(new Error('path required'), { status: 400 });
+    let p = raw.trim();
+    if (p.startsWith('~/')) p = path.join(os.homedir(), p.slice(2));
+    if (!path.isAbsolute(p)) throw Object.assign(new Error('path must be absolute'), { status: 400 });
+    p = path.normalize(p);
+    if (!fs.existsSync(p)) throw Object.assign(new Error('file not found: ' + p), { status: 404 });
+    return p;
+}
+function run(args) {
+    return new Promise((resolve, reject) => {
+        execFile('/usr/bin/open', args, { timeout: 15000 }, (err, stdout, stderr) => err ? reject(new Error((stderr || err.message).trim())) : resolve((stdout || '').trim()));
+    });
+}
+export async function openTarget({ path: raw, mode = 'default', app, dry }) {
+    const target = resolveTarget(raw);
+    let args;
+    if (mode === 'reveal') args = ['-R', target];
+    else if (mode === 'app') {
+        if (!listApps().includes(app)) throw Object.assign(new Error('unknown app: ' + app), { status: 400 });
+        args = ['-a', app, target];
+    } else args = [target];
+    if (dry) return { ok: true, dry: true, command: ['open', ...args] };
+    await run(args);
+    return { ok: true, command: ['open', ...args] };
+}
+const esc = (s) => s.replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
+export function renderPreview(raw) {
+    const target = resolveTarget(raw);
+    const stat = fs.statSync(target);
+    if (!stat.isFile()) throw Object.assign(new Error('not a file'), { status: 400 });
+    if (stat.size > 5 * 1024 * 1024) throw Object.assign(new Error('file too large for preview'), { status: 413 });
+    const text = fs.readFileSync(target, 'utf8');
+    const ext = path.extname(target).toLowerCase();
+    const isMd = ext === '.md' || ext === '.markdown' || ext === '.mdx';
+    const body = isMd ? marked.parse(text) : `<pre>${esc(text)}</pre>`;
+    const name = path.basename(target);
+    return `<!doctype html><html lang="zh"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${esc(name)}</title>
+<style>:root{color-scheme:light dark}body{max-width:860px;margin:32px auto;padding:0 20px 60px;font:15px/1.7 -apple-system,BlinkMacSystemFont,"PingFang SC","Helvetica Neue",sans-serif;color:#1f2937;background:#fff}
+@media(prefers-color-scheme:dark){body{color:#e5e7eb;background:#111318}pre,code{background:#1f2329!important}a{color:#93c5fd}th,td{border-color:#374151!important}blockquote{border-color:#4b5563;color:#9ca3af}}
+h1,h2,h3{line-height:1.3}pre{background:#f3f4f6;padding:12px 14px;border-radius:8px;overflow:auto;font-size:13px}code{background:#f3f4f6;padding:1px 5px;border-radius:4px;font-size:.92em}pre code{padding:0;background:none}
+table{border-collapse:collapse;margin:12px 0}th,td{border:1px solid #d1d5db;padding:6px 10px}blockquote{margin:12px 0;padding:2px 14px;border-left:3px solid #d1d5db;color:#6b7280}img{max-width:100%}
+.cu-top{font-size:12px;color:#6b7280;margin-bottom:18px;word-break:break-all}.cu-top a{color:inherit}</style></head>
+<body><div class="cu-top">${esc(target)} · <a href="#" onclick="fetch('/api/local-file/open',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({path:${JSON.stringify(target)},mode:'reveal'})});return false">在访达中显示</a></div>${body}</body></html>`;
+}
tools/import_to_claude_desktop.py导入桌面客户端的工具
#!/usr/bin/env python3
"""把 ~/.claude/projects 里的 Claude Code 对话挂到 Claude 桌面客户端(Code 标签页)左侧列表。

CloudCLI 和终端里的 claude 都把对话写在 ~/.claude/projects/<编码后的目录>/<id>.jsonl,
桌面客户端左侧列表的每一项则是
  ~/Library/Application Support/Claude/claude-code-sessions/<账号>/<组织>/local_<uuid>.json,
里面的 cliSessionId 指向那个 jsonl。本脚本只新建这些小 JSON,不改任何对话记录。

用法:
  python3 import_to_claude_desktop.py            # 试运行:只统计、预览标题
  python3 import_to_claude_desktop.py --apply    # 真正写入,之后 ⌘Q 退出客户端再打开
撤销: 删掉本脚本写入的 local_*.json(文件里带 "importedBy": "cloudcli-kit")。
"""
import glob
import json
import os
import re
import sys
import uuid
from datetime import datetime

APPLY = "--apply" in sys.argv
PROJECTS = os.path.expanduser("~/.claude/projects")
SESSIONS_ROOT = os.path.expanduser("~/Library/Application Support/Claude/claude-code-sessions")


def find_meta_dir():
    # <账号>/<组织> 目录里放着现有会话的 local_*.json;取最近用过的那个
    dirs = [d for d in glob.glob(os.path.join(SESSIONS_ROOT, "*", "*")) if os.path.isdir(d)]
    if not dirs:
        sys.exit("找不到桌面客户端的会话目录:先在 Claude 桌面客户端的 Code 标签页里随便开一个会话")
    return max(dirs, key=os.path.getmtime)


def encode(path):
    return re.sub(r"[^A-Za-z0-9]", "-", path)


def to_ms(ts):
    return int(datetime.fromisoformat(ts.replace("Z", "+00:00")).timestamp() * 1000)


def short_title(text):
    text = re.sub(r"<command-name>(.*?)</command-name>", r"\1 ", text)
    text = re.sub(r"<[^>]+>[^<]*</[^>]+>", "", text)
    return re.sub(r"\s+", " ", text).strip()[:50]


def scan(path):
    info = dict(ts=[], cwds=[], model=None, custom=None, ai=None, first=None, mode=None, entry=set())
    for line in open(path, errors="ignore"):
        try:
            d = json.loads(line)
        except ValueError:
            continue
        kind = d.get("type")
        if d.get("timestamp"):
            info["ts"].append(d["timestamp"])
        if d.get("cwd") and d["cwd"] not in info["cwds"]:
            info["cwds"].append(d["cwd"])
        if d.get("entrypoint"):
            info["entry"].add(d["entrypoint"])
        if kind == "custom-title":
            info["custom"] = d.get("customTitle")
        elif kind == "ai-title":
            info["ai"] = d.get("aiTitle")
        elif kind == "permission-mode":
            info["mode"] = d.get("permissionMode")
        msg = d.get("message") or {}
        if kind == "assistant" and msg.get("model") and not msg["model"].startswith("<"):
            info["model"] = msg["model"]
        if info["first"] is None and kind == "user" and not d.get("isMeta"):
            content = msg.get("content")
            if isinstance(content, list):
                content = " ".join(x.get("text", "") for x in content if isinstance(x, dict) and x.get("type") == "text")
            if content and content.strip():
                info["first"] = content
    return info


def main():
    meta_dir = find_meta_dir()
    linked = set()
    for m in glob.glob(os.path.join(meta_dir, "local_*.json")):
        try:
            linked.add(json.load(open(m)).get("cliSessionId"))
        except ValueError:
            pass

    new, skipped = [], {}
    for proj in sorted(os.listdir(PROJECTS)):
        for path in glob.glob(os.path.join(PROJECTS, proj, "*.jsonl")):
            cli_id = os.path.basename(path)[:-6]
            if cli_id in linked:
                continue
            s = scan(path)
            reason = None
            if not s["first"] or not s["ts"]:
                reason = "没有用户消息"
            elif s["entry"] and s["entry"] <= {"sdk-cli"}:
                reason = "脚本里 claude -p 的一次性调用"
            cwd = next((c for c in s["cwds"] if encode(c) == proj), s["cwds"][0] if s["cwds"] else None)
            if not reason and not (cwd and os.path.isdir(cwd)):
                reason = "工作目录已不存在"
            if reason:
                skipped[reason] = skipped.get(reason, 0) + 1
                continue
            new.append(dict(cli=cli_id, cwd=cwd, created=to_ms(min(s["ts"])), last=to_ms(max(s["ts"])),
                            model=s["model"], mode=s["mode"] or "default",
                            title=s["custom"] or s["ai"] or short_title(s["first"])))

    print(f"客户端会话目录: {meta_dir}")
    print(f"已在列表: {len(linked)}   待导入: {len(new)}   跳过: {skipped or 0}")
    for o in sorted(new, key=lambda o: -o["last"])[:20]:
        print("  ", datetime.fromtimestamp(o["last"] / 1000).strftime("%m-%d %H:%M"), o["title"])
    if not APPLY:
        print("\n这是试运行。确认无误后加 --apply 写入,然后 ⌘Q 退出 Claude 客户端再打开。")
        return
    for o in new:
        sid = "local_" + str(uuid.uuid4())
        meta = {
            "sessionId": sid, "cliSessionId": o["cli"], "cwd": o["cwd"], "originCwd": o["cwd"],
            "createdAt": o["created"], "lastActivityAt": o["last"], "lastFocusedAt": o["last"],
            "isArchived": False, "title": o["title"], "titleSource": "user",
            "permissionMode": o["mode"], "importedBy": "cloudcli-kit",
        }
        if o["model"]:
            meta["model"] = o["model"]
        with open(os.path.join(meta_dir, sid + ".json"), "w") as f:
            json.dump(meta, f, ensure_ascii=False)
    print(f"\n已写入 {len(new)} 条。⌘Q 完全退出 Claude 客户端再打开即可看到。")


if __name__ == "__main__":
    main()