@yanzc/claude-pro 0.4.5 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -171,11 +171,41 @@ claude-pro config set ANTHROPIC_MODEL deepseek-pro --project
171
171
  - 需要真正退出再启动的只有两种情况:改了 `bin/claude-pro.js` 或 `src/supervisor.js`(运行中的监督进程是旧代码);以及新代码启动不起来而你想强行重启。`-p`、管道、`config` 子命令不经过监督进程;`CLAUDE_PRO_NO_SUPERVISOR=1` 也可以关掉它(环境变量,不是配置项)。
172
172
  - `claude-pro --self-check` 可以单独运行那个启动检查。
173
173
 
174
+ ## 远程控制:在浏览器或手机上看执行过程、下发指令
175
+
176
+ 和 Claude Code 的 `/remote-control` 一样,可以在手机或另一台电脑上看着这个会话干活,并给它发指令。官方版经 Anthropic 中转,这里经**网关**(gw.predix.icu,即 `CLAUDE_PRO_GATEWAY_URL`)中转:claude-pro 只向网关发出站请求,**不监听任何端口,不走局域网**;在网关用户端登录后,左侧菜单"**远程控制**"里能看到并操作自己的会话。模型请求走哪个提供方都可以,远程控制只用网关的登录。
177
+
178
+ - **打开**:会话里输入 `/remote-control`(别名 `/rc`)。这台机器还没登录网关时,会先在浏览器里登录(和 `/login` 选网关一样,但**不切换**模型提供方)。终端打印这个会话在网关上的链接(`https://gw.predix.icu/user#remote/<id>`)和二维码,手机扫码即可打开(没登录时先登录,登录后直接进入这个会话);所有会话的列表在 `/user#remote`。`/rc open` 用本机浏览器打开;再输一次 `/rc` 显示链接和连接状态;`/rc stop` 断开。**登录了网关的电脑上,交互会话启动时默认就在线**(不阻塞启动;设 `CLAUDE_PRO_ONLINE=0` 关闭,之后只有 `/rc` 才上线),所以网页列表里能看到这台电脑上所有开着的会话,别的电脑也能给它们发任务(见下一节)。`claude-pro --rc` 启动时就上线并打印链接。在线时输入框右上角显示 `⇄ &名字`,有网页在看时页脚显示 `⇄ N watching`;和网关断开时自动重连。
179
+ - **网页上能看到**:完整对话(用户消息、流式输出的回答、可展开的思考、工具调用和结果,包括 diff 和正在运行的命令的最新几行输出)、斜杠命令和提示的输出、当前状态(正在想/运行多久/多少 token、限流暂停的倒计时)、待办列表、排队中的消息。
180
+ - **网页上能做**:发消息(和在终端里输入完全一样:忙的时候排队插入,`/命令`、`!shell` 都可以)、**Stop**(等于 Esc)、回答模型的提问(`AskUserQuestion`,含多选和"其他")、批准/拒绝计划、以及 `/model`、`/rewind` 之类命令弹出的选择框。终端和网页可以交替使用,两边同步。
181
+ - **安全**:只有同一个网关账户登录后才能看到和操作这个会话;**能登录这个网关账户的人,就能通过它在这台电脑上以完全权限执行命令**。会话内容经网关中转并暂存在网关上。
182
+ - **省流量**:没人打开网页时只上报状态(列表里的标题、忙/闲),不上传对话;有人打开时先发一份完整快照,再实时推送。
183
+ - **重启不断线**:自动重启(改了源码、自动更新)沿用同一个会话 id,网页显示"重启中"后接着看;网关重启丢了状态时,claude-pro 自动重新注册并补发完整对话。
184
+ - 一个会话对应网关上的一个远程会话;多个会话各自 `/rc`。每个网关账户最多 20 个(超出时淘汰最久没活动的离线会话);已结束的会话在列表里保留 6 小时,断开但没结束的保留 24 小时。不支持从网页上传图片。
185
+
186
+ ## 会话之间互发任务(本机和其他电脑)
187
+
188
+ 每个交互会话都有一个名字(默认是目录名,输入框右上角的 `&名字`),别的会话可以给它发任务,它做完后回答自动发回来。
189
+
190
+ - **本机**:同一台电脑上开着的会话直接互通(经 127.0.0.1,不经过网络)。
191
+ - **其他电脑**:同一个网关账户下在线的会话经网关互通。例如公司电脑上开着 claude-pro,在家里输入 `&公司 跑一下测试`,任务送到公司那台的会话,回答自动显示在家里这边。电脑名默认是主机名,用 `CLAUDE_PRO_MACHINE_NAME=公司` 改成好记的名字(支持中文)。
192
+ - **输入 `&`** 弹出所有能发的会话(本机的和其他电脑在线的),选一个再写任务回车即可:
193
+ - `&名字 任务`:本机的会话;
194
+ - `&电脑名/名字 任务`:其他电脑上的会话;那台电脑只开着一个会话时直接 `&电脑名 任务`。
195
+ - `@` 只用于引用文件。
196
+ - **发送文件**:`/sendfile &公司 C:\Users\yzc\Pictures\a.jpg C:\Users\yzc\Pictures\b.jpg`(别名 `/sf`),文件存进对方电脑的「下载」文件夹(Windows 上是系统设置的下载位置;`CLAUDE_PRO_DOWNLOAD_DIR` 可改),同名文件不覆盖、存为「名字 (1).jpg」,对方存好后把保存位置回给你。不经过模型、不花 token。路径可以带空格(不加引号也行)、可以用 `@` 补全文件名;每次最多 20 个文件、每个最多 5 GB;文件夹要先压缩。
197
+ - **大文件、断点续传**:文件在后台分块发送,输入框下面显示进度(`⇡ 会议录像.mp4 → &公司/proj 42% · 921.6 MB/2.1 GB`),期间可以照常使用会话。网络断了会自动等待重试(最长 1 小时),恢复后从断开的地方接着传;中途关掉了 claude-pro 或 `/sendfile stop` 停掉了,之后再 `/sendfile` 同一个文件,会接着之前传到的位置继续(24 小时内)。接收方同样边下边存,断了用续传接着下,下完校验 sha256 再改成正式文件名;接收方中途退出的话,重新上线后会自动接着下载。也可以直接对 agent 说「把这几张图发到 &公司」,它用 MessagePeer 的 files 参数发送;文件也可以和任务一起发(对方收到任务时知道文件存在哪)。其他电脑之间的文件经网关中转(存 24 小时,对方取走后即删除),同一台电脑上直接复制。文件经网关服务器中转,传输速度受网关服务器带宽限制。
198
+ - **`&` 可以写在句子中间**:输入时在空格后面打 `&` 也会弹出会话列表(比如 `/sendfile a.jpg &`);在发给当前 agent 的消息里提到 `&名字`,agent 会被告知那是哪个会话、怎么联系它。输入法打出全角 `&` 也可以。
199
+ - **`/rename 新名字`**:给当前会话改名(比如 `/rename web-server`),别的会话从此用 `&web-server` 找它;这个名字也成为对话标题(终端标签页、`/resume` 列表、网页),不再自动按话题改;`/resume` 这个对话时名字一起回来。
200
+ - **`/peers`**:列出本机和其他电脑上在线的会话。模型也能用 ListPeers / MessagePeer 工具自己找别的会话分派任务。
201
+ - 回答会在对方这一轮做完后自动发回;对方正忙时,任务排在它当前工作之后。网关替离线的会话暂存回答(24 小时;网关重启会丢失还没送达的消息)。
202
+ - **安全**:在线的会话能被同一网关账户执行任何任务。能登录这个账户、或持有它任一 API key 的人,都能在这些电脑上以完全权限执行命令。不需要时设 `CLAUDE_PRO_ONLINE=0`,或在会话里 `/rc stop`。
203
+
174
204
  ## 完全访问与安全网
175
205
 
176
206
  - **没有权限系统**:没有确认弹窗,没有 allow/deny 规则,也没有 `/permissions`。旧参数 `--dangerously-skip-permissions`、`--permission-mode`、`--allowedTools`、`--disallowedTools` 仍被接受但会被忽略(`--permission-mode plan` 等同于 `--plan`);settings 里残留的 `permissions` 字段也会被忽略。
177
207
  - **计划模式(唯一的开关)**:`shift+tab`、`/plan` 或 `--plan` 进入。此时只能读和调研,改文件、跑会改东西的命令都会被挡住;模型调研完用 `ExitPlanMode` 提交计划,你批准后回到完全访问。
178
- - **检查点与 `/rewind`**:每条提问都会建一个检查点,Write / Edit / NotebookEdit 修改文件前会先保存原样。`/rewind` 选一条提问,再选恢复"代码和对话"、"仅代码"或"仅对话";对话回退后,原来的提问会回填到输入框。**Bash 命令造成的改动不会被记录**,所以模型被要求优先用文件工具改文件。
208
+ - **检查点与 `/rewind`(或在空输入框连按两次 `Esc`)**:每条提问都会建一个检查点,Write / Edit / NotebookEdit 修改文件前会先保存原样。和官方一样,菜单按时间列出这次会话的提问(最新的在最下面、默认选中),选一条后可以:**Restore code and conversation**(代码和对话都回到这条之前)、**Restore conversation**(只回退对话,文件不动)、**Restore code**(只恢复文件,对话不动)、**Summarize from here**(这条及之后的对话压缩成摘要,腾出上下文)、**Summarize up to here**(这条之前的压缩成摘要,之后的原样保留)、**Never mind**(回到列表)。回退对话或"Summarize from here"后,那条提问的原文(完整的,不截断)回填到输入框,可以改了再发,相当于删掉不想要的那段对话重新来。`/clear` 之后,菜单最上面多一项 `/resume <id> (previous session)`,可以回到 `/clear` 之前的对话(本进程内有效)。**Bash 命令造成的改动不会被记录**,所以模型被要求优先用文件工具改文件。
179
209
  - **想要护栏时用 hooks**:`PreToolUse` 钩子仍然可以拦截任何工具调用,由你自己定规则(见下)。
180
210
  - 模型自己的使用规则不受影响,系统提示里的安全条款与官方 Claude Code 现在的措辞一致:允许授权的安全测试、防御、CTF、教学,拒绝恶意用途。这一条是模型的使用规范,不是能力限制,没有去掉。
181
211
 
@@ -210,7 +240,7 @@ claude-pro config set ANTHROPIC_MODEL deepseek-pro --project
210
240
  - 上下文用量接近阈值时自动压缩(`CLAUDE_CODE_AUTO_COMPACT_WINDOW`),也可手动 `/compact`
211
241
  - 会话自动保存到 `~/.claude-pro/projects/`,`claude-pro -c` 继续上次,`claude-pro -r` 选择历史会话
212
242
 
213
- **斜杠命令**:`/help` `/clear` `/compact` `/config` `/model` `/effort` `/status` `/cost` `/context` `/init` `/review` `/memory` `/verify` `/tasks` `/plan` `/rewind` `/skills` `/resume` `/todos` `/agents` `/mcp` `/bashes` `/verbose` `/export` `/doctor` `/exit`,以及 `.claude/commands/*.md`、`.claude-pro/commands/*.md`、`~/.claude-pro/commands/*.md` 中的自定义命令(支持 `$ARGUMENTS`、`$1`、`` !`cmd` ``)。
243
+ **斜杠命令**:`/help` `/rename` `/peers` `/sendfile` `/clear` `/compact` `/config` `/model` `/effort` `/status` `/cost` `/context` `/init` `/review` `/memory` `/verify` `/tasks` `/plan` `/rewind` `/skills` `/resume` `/todos` `/agents` `/mcp` `/bashes` `/verbose` `/export` `/doctor` `/remote-control`(`/rc`) `/exit`,以及 `.claude/commands/*.md`、`.claude-pro/commands/*.md`、`~/.claude-pro/commands/*.md` 中的自定义命令(支持 `$ARGUMENTS`、`$1`、`` !`cmd` ``)。
214
244
 
215
245
  **记忆**:自动读取 `~/.claude-pro/CLAUDE.md`、项目各级目录的 `CLAUDE.md` / `.claude/CLAUDE.md` / `CLAUDE.local.md`;某一级目录没有 `CLAUDE.md` 时读它的 `AGENTS.md` 代替(与 Claude Code 相同,两者都有时通常是同一份说明,不重复发送)。
216
246
 
@@ -260,14 +290,14 @@ claude-pro -p --plan "给出重构方案" # 只调研、只
260
290
  |---|---|
261
291
  | `Enter` | 发送 |
262
292
  | `\` + `Enter` / `ctrl+j` / `alt+enter` | 换行 |
263
- | `Esc` | 中断当前任务;连按两次清空输入 |
293
+ | `Esc` | 中断当前任务;连按两次:有文字时清空输入,输入框为空时打开回退菜单(同 `/rewind`) |
264
294
  | `ctrl+c` | 中断 / 清空输入 / 连按两次退出 |
265
295
  | `shift+tab` | 开关计划模式(只读) |
266
296
  | `ctrl+o` | 切换详细输出(思考过程、完整工具输出) |
267
297
  | `ctrl+t` | 显示/隐藏 todo 列表 |
268
298
  | `ctrl+l` | 清屏重绘 |
269
299
  | `alt+v` | 粘贴剪贴板图片 |
270
- | `↑` / `↓` | 历史记录 |
300
+ | `↑` / `↓` | 在多行输入(包括自动折行的长文字)里上下移动光标;光标在第一行时 `↑` 先移到开头、再按才翻到上一条历史消息,`↓` 在最后一行同理 |
271
301
  | `Tab` | 补全命令 / 文件 |
272
302
 
273
303
  ## 开发
@@ -302,6 +332,8 @@ src/prompts.js 系统提示词、压缩提示词、自动记忆提示
302
332
  src/tools/* 各工具实现(含 Skill、NotebookEdit)
303
333
  src/mcp.js MCP 客户端(stdio / HTTP)
304
334
  src/ui/* 终端 UI:屏幕管理、输入框、对话框、Markdown 渲染
335
+ src/remote.js 远程控制:经网关中转的客户端、对话事件模型(applyEvent,网关页面里有同一份)
336
+ src/qr.js 终端二维码(字节模式、纠错 L、版本 1–6)
305
337
  src/commands.js 斜杠命令
306
338
  ```
307
339
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yanzc/claude-pro",
3
- "version": "0.4.5",
3
+ "version": "0.5.0",
4
4
  "description": "claude-pro: a self-implemented coding agent for Anthropic-compatible APIs or local Claude Code subscription login. Full access with no permission prompts; file edits are checkpointed and can be rewound.",
5
5
  "type": "module",
6
6
  "repository": {
@@ -8,7 +8,13 @@
8
8
  "url": "git+https://github.com/yanzaocheng/copy-claude-code.git"
9
9
  },
10
10
  "homepage": "https://github.com/yanzaocheng/copy-claude-code#readme",
11
- "keywords": ["claude", "coding-agent", "cli", "anthropic", "ai"],
11
+ "keywords": [
12
+ "claude",
13
+ "coding-agent",
14
+ "cli",
15
+ "anthropic",
16
+ "ai"
17
+ ],
12
18
  "bin": {
13
19
  "claude-pro": "bin/claude-pro.js"
14
20
  },
package/src/agent.js CHANGED
@@ -4,11 +4,12 @@
4
4
  import fs from 'node:fs';
5
5
  import path from 'node:path';
6
6
  import { streamMessage, contextTokens, isAbort } from './api.js';
7
- import { compactPrompt, continuationMessage, PLAN_MODE_REMINDER, SUMMARY_SYSTEM } from './prompts.js';
7
+ import { compactPrompt, partialCompactPrompt, partialSummaryMessage, continuationMessage, PLAN_MODE_REMINDER, SUMMARY_SYSTEM } from './prompts.js';
8
8
  import { toolResultsDir } from './session.js';
9
9
  import { rebuildFileState } from './filestate.js';
10
10
  import { CacheStats } from './cache.js';
11
11
  import { resolvePath } from './utils/paths.js';
12
+ import { fitImagesIn } from './utils/images.js';
12
13
 
13
14
  export const INTERRUPT_MESSAGE = '[Request interrupted by user]';
14
15
  export const INTERRUPT_TOOL_MESSAGE = '[Request interrupted by user for tool use]';
@@ -688,6 +689,8 @@ export class Agent {
688
689
  }
689
690
  result = { isError: true, content: `Error: ${err.message}` };
690
691
  }
692
+ // Images from any tool (MCP servers, say) fit what the API takes before they join the conversation.
693
+ if (Array.isArray(result.content)) await fitImagesIn(result.content);
691
694
  handle.done?.(result);
692
695
  let content = typeof result.content === 'string' ? (result.content || '(no output)') : result.content;
693
696
  if (this.hooks?.has('PostToolUse')) {
@@ -739,6 +742,40 @@ export class Agent {
739
742
  return res;
740
743
  }
741
744
 
745
+ // Summarise part of the conversation, as /rewind's "Summarize from here" ('after': the messages from `index` on)
746
+ // and "Summarize up to here" ('before': those before it) do: that part is replaced by its summary, the rest stays
747
+ // word for word. `index` is where a prompt of the user starts a turn (a checkpoint's messageIndex); `label` is
748
+ // that prompt, to tell the model where the part begins or ends. The request is the conversation's own with the
749
+ // instruction at its end, so it reads the conversation from the cache (see requestSummary).
750
+ async summarizePart(index, { part, label = '', instructions = '', signal } = {}) {
751
+ const before = this.messages.slice(0, index);
752
+ const after = this.messages.slice(index);
753
+ if (!(part === 'before' ? before : after).length) throw new Error('There is nothing in that part of the conversation to summarize.');
754
+ this.io.compactStart?.(false, this);
755
+ const messages = normalizeMessages([...this.messages, { role: 'user', content: [{ type: 'text', text: partialCompactPrompt(part, label, instructions) }] }]);
756
+ let res;
757
+ try {
758
+ res = await this.requestSummary(messages, { signal, onEvent: (ev) => this.io.compactProgress?.(ev, this) });
759
+ } catch (err) {
760
+ this.io.compactEnd?.(null, this, err);
761
+ throw err;
762
+ }
763
+ const summary = Agent.summaryOf(res);
764
+ if (!summary) {
765
+ const err = new Error(res.stopReason === 'max_tokens' ? 'The summary was cut off by the output token limit.' : 'The model returned an empty summary.');
766
+ this.io.compactEnd?.(null, this, err);
767
+ throw err;
768
+ }
769
+ const note = { role: 'user', content: [{ type: 'text', text: partialSummaryMessage(part, summary) }] };
770
+ this.replaceMessages(part === 'before' ? [note, ...after] : [...before, note]);
771
+ if (part === 'after') this.todos = []; // what they were about may be summarized away; the model keeps them in the summary
772
+ this.lastContextTokens = estimateTokens(this.messages) + estimateTokens(this.toolSchemas()) + 2000;
773
+ this.tokensSinceUsage = 0;
774
+ this.postCompactTokens = null;
775
+ this.io.compactEnd?.(summary, this, null, { part });
776
+ return summary;
777
+ }
778
+
742
779
  // Summarise the conversation and replace it with the summary.
743
780
  async compact({ signal, auto = false, instructions = '' } = {}) {
744
781
  if (this.messages.length === 0) return null;
package/src/api.js CHANGED
@@ -9,6 +9,7 @@ import { cacheControl, cachePlanOf, fingerprint } from './cache.js';
9
9
  import { readGatewayLogin, fetchGatewayModels, cachedGatewayModel, gatewayEffort, gatewayDefaultEffort } from './gateway.js';
10
10
  import { createCodexFetch, codexModels, codexEffort, nearestEffort } from './codex/client.js';
11
11
  import { SIGNATURE_PREFIX } from './codex/translate-request.js';
12
+ import { fitImagesIn } from './utils/images.js';
12
13
 
13
14
  let cached = null;
14
15
 
@@ -374,6 +375,7 @@ export async function streamMessage(config, request, { signal, onEvent = () => {
374
375
  // session id names the conversation, in the header Claude Code sends it in.
375
376
  const options = { signal, ...(config.usesGateway && config.sessionId ? { headers: { 'X-Claude-Code-Session-Id': config.sessionId } } : {}) };
376
377
  let authReloaded = false;
378
+ let imagesFitted = false;
377
379
  const refused = new Set(); // what the endpoint refused for this request: remembered once a request got through
378
380
  const retryState = { attempt: 0, rateLimitHits: 0, waitedMs: 0, overloaded: 0, overloadedWaitMs: 0 };
379
381
  let paused = null; // the plan of the pause in progress (see planRetry)
@@ -500,6 +502,16 @@ export async function streamMessage(config, request, { signal, onEvent = () => {
500
502
  continue;
501
503
  }
502
504
  }
505
+ // An image in the conversation is larger than the API takes (a screenshot read before images were scaled,
506
+ // or a session that only now has enough images for the stricter limit): scale the images in place, which
507
+ // fixes the conversation itself, and try again. Without this every later request of the session fails.
508
+ if (err.status === 400 && !imagesFitted && /image/i.test(err.message) && /dimension|pixels|exceed|too large|too big/i.test(err.message)) {
509
+ imagesFitted = true;
510
+ if (await fitImagesIn(params.messages)) {
511
+ if (emitted) onEvent({ type: 'reset' });
512
+ continue;
513
+ }
514
+ }
503
515
  // The default max_tokens is generous: if this API allows less, learn its limit and retry at once.
504
516
  const limit = err.status === 400 ? parseMaxTokensLimit(err.message) : null;
505
517
  if (limit && limit < params.max_tokens) {
@@ -132,6 +132,17 @@ export class CheckpointStore {
132
132
  this.writeIndex();
133
133
  }
134
134
 
135
+ // Part of the conversation was summarized at checkpoint `id` (see Agent.summarizePart), which started at message
136
+ // `index`: the turns summarized can no longer be rewound to (their files can still be restored), and with the
137
+ // earlier part summarized the later turns moved up to just after the one summary message.
138
+ summarized(id, part, index) {
139
+ for (const e of this.entries) {
140
+ if (part === 'after' ? e.id >= id : e.id < id) e.conversationValid = false;
141
+ else if (part === 'before') e.messageIndex = e.messageIndex - index + 1;
142
+ }
143
+ this.writeIndex();
144
+ }
145
+
135
146
  // The conversation was compacted or replaced: it can no longer be rewound to older points,
136
147
  // but file snapshots remain valid.
137
148
  markConversationStale() {
@@ -152,6 +163,14 @@ export class CheckpointStore {
152
163
  }
153
164
  }
154
165
 
166
+ // The prompt a checkpoint started with, whole: its label is cut at 200 characters, the message is not.
167
+ export function promptAt(agent, entry) {
168
+ const msg = agent.messages[entry.messageIndex];
169
+ const text = msg?.role !== 'user' ? null : typeof msg.content === 'string' ? msg.content : msg.content?.find?.((b) => b.type === 'text')?.text;
170
+ const head = (s) => String(s || '').replace(/\s+/g, ' ').trim().slice(0, 40);
171
+ return text && head(text) === head(entry.label) ? text : entry.label;
172
+ }
173
+
155
174
  // Apply a rewind to a running agent. mode: 'both' | 'code' | 'conversation'.
156
175
  // Returns {restore, prompt, conversation}: what was restored, the prompt text (so the UI can put it
157
176
  // back in the input box) and whether the conversation was cut.
@@ -161,7 +180,7 @@ export function rewind(agent, store, id, mode = 'both') {
161
180
  if (mode !== 'code' && !entry.conversationValid) {
162
181
  throw new Error('The conversation was compacted after this point, so it can no longer be rewound to it. You can still restore the code.');
163
182
  }
164
- const out = { restore: null, prompt: entry.label, conversation: false };
183
+ const out = { restore: null, prompt: promptAt(agent, entry), conversation: false };
165
184
  if (mode !== 'conversation') {
166
185
  out.restore = store.restoreCode(id);
167
186
  // Files changed behind the agent's back: make it read them again before editing.
package/src/commands.js CHANGED
@@ -6,7 +6,7 @@ import { c, padEnd, truncate } from './ui/ansi.js';
6
6
  import { SelectDialog } from './ui/dialogs.js';
7
7
  import { renderTodos, resultLines, formatTokens, formatDuration } from './ui/render.js';
8
8
  import { loadConfig, settingsFiles, updateSettingsFile, rememberChoice, applyRememberedChoices, KNOWN_KEYS, VERSION, APP_NAME, CONFIG_DIR } from './config.js';
9
- import { loginWithBrowser, readGatewayLogin, clearGatewayLogin, fetchGatewayAccount, fetchGatewayModels, cachedGatewayModel, gatewayEfforts, formatUsd } from './gateway.js';
9
+ import { loginWithBrowser, readGatewayLogin, clearGatewayLogin, fetchGatewayAccount, fetchGatewayModels, cachedGatewayModel, gatewayEfforts, formatUsd, openBrowser } from './gateway.js';
10
10
  import { codexLoginSummary, codexModels, codexEfforts } from './codex/client.js';
11
11
  import { readClaudeCodeLogin } from './claude-code.js';
12
12
  import { SessionStore } from './session.js';
@@ -18,7 +18,8 @@ import { estimateTokens } from './agent.js';
18
18
  import { quickQuery, cacheRefusals, defaultEffort, effortToSend, CLAUDE_EFFORTS } from './api.js';
19
19
  import { hasRipgrep } from './utils/shell.js';
20
20
  import { displayPath } from './utils/paths.js';
21
- import { rewind } from './checkpoints.js';
21
+ import { cleanName, machineName } from './peers.js';
22
+ import { rewind, promptAt } from './checkpoints.js';
22
23
  import { canRestart, runSelfCheck } from './restart.js';
23
24
 
24
25
  const EFFORTS = ['none', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'];
@@ -111,8 +112,11 @@ export function buildCommands(app) {
111
112
  aliases: ['reset', 'new'],
112
113
  description: 'Clear conversation history and free up context',
113
114
  run() {
115
+ // the conversation it ends stays one esc esc away (the rewind menu), until the end of this process
116
+ const previous = app.agent.messages.length ? { file: app.store.file, id: app.store.id } : null;
114
117
  app.agent.resetConversation();
115
118
  app.newSession();
119
+ if (previous) app.previousSession = previous;
116
120
  app.runtime.invalidateSystemPrompt();
117
121
  app.screen.clearScreen();
118
122
  app.printWelcome();
@@ -577,23 +581,87 @@ export function buildCommands(app) {
577
581
  name: 'peers',
578
582
  aliases: ['sessions'],
579
583
  argHint: '[name NEW-NAME]',
580
- description: 'List the other sessions on this machine (send one a task with @name ...), or rename this one',
581
- run(args) {
582
- const node = app.peers;
583
- if (!node) { out(c.dim('Talking to other sessions is off (CLAUDE_PRO_PEERS=0).')); return; }
584
+ description: 'List the other sessions, on this computer and (through the gateway) on your other computers; send one a task with &name ...',
585
+ async run(args) {
584
586
  const [action, ...rest] = args.split(/\s+/).filter(Boolean);
585
- if (action === 'name' && rest.length) { out(`This session is now ${c.bold('@' + node.setName(rest.join('-')))}`); app.screen.schedule(); return; }
586
- const peers = node.peers();
587
+ if (action === 'name' && rest.length) { out(`This session is now ${c.bold('&' + app.renameSession(rest.join('-')))}`); return; }
588
+ const { me, local, remote, online } = await app.listAllPeers();
587
589
  out([
588
- `This session: ${c.bold('@' + node.name)} ${c.dim(displayPath(node.cwd, app.cwd))}`,
589
- ...(peers.length
590
- ? peers.map((p) => `${c.bold('@' + p.name)} ${c.dim(`${p.cwd} · pid ${p.pid}`)}`)
591
- : [c.dim('No other session is running. Start claude-pro in another terminal.')]),
592
- c.dim('Type "@name task" to send a task to a session; its answer is shown here. The agent can use ListPeers / MessagePeer too.'),
590
+ `This session: ${c.bold('&' + me.name)} ${c.dim(displayPath(me.cwd, app.cwd))}${online ? c.dim(` · from other computers: &${me.label}`) : ''}`,
591
+ ...local.map((p) => `${c.bold('&' + p.name)} ${c.dim(`${p.cwd} · pid ${p.pid}`)}`),
592
+ ...(remote.length ? [c.dim('On your other computers, through the gateway:'), ...remote.map((p) => `${c.bold('&' + p.label)} ${c.dim(`${p.cwd}${p.busy ? ' · busy' : ''}`)}`)] : []),
593
+ ...(local.length || remote.length ? [] : [c.dim('No other session is running.')]),
594
+ c.dim(online
595
+ ? 'Type "&" for the list, "&name task" to send a task; its answer is shown here. On another computer: "&computer/name task", or "&computer task" when it runs one session. /sendfile &name file... sends files (saved in its Downloads folder). /rename names this session. The agent can use ListPeers / MessagePeer too.'
596
+ : 'Type "&" for the list, "&name task" to send a task; its answer is shown here. Sessions on your other computers show up here once this one is online at the gateway (/login, then /rc).'),
593
597
  ]);
594
598
  },
595
599
  });
596
600
 
601
+ add({
602
+ name: 'sendfile',
603
+ aliases: ['sf'],
604
+ argHint: '&session <file...>',
605
+ argRequired: true,
606
+ description: 'Send files to another session, on this computer or on another one: it saves them in its Downloads folder',
607
+ async run(args) { await app.sendFiles(args); },
608
+ });
609
+
610
+ add({
611
+ name: 'rename',
612
+ argHint: '<name>',
613
+ argRequired: true,
614
+ description: 'Name this session: the other sessions send it tasks with &name, and it is the title (tab, /resume, the web page)',
615
+ run(args) {
616
+ const name = args.trim();
617
+ if (!name) { out(`This session is ${c.bold('&' + app.peerLabel())}. /rename NAME gives it another name.`); return; }
618
+ const got = app.renameSession(name);
619
+ const note = got !== cleanName(name) ? c.dim(' (another session on this computer has that name)') : '';
620
+ out(`This session is now ${c.bold('&' + got)}${note}${app.remote ? c.dim(` · from your other computers: &${machineName(app.config.env)}/${got}`) : ''}`);
621
+ },
622
+ });
623
+
624
+ add({
625
+ name: 'remote-control',
626
+ aliases: ['rc'],
627
+ argHint: '[stop | open]',
628
+ description: 'Watch and steer this session from a browser or phone, through the gateway (its 远程控制 page): prints the link and a QR code',
629
+ async run(args) {
630
+ const action = args.trim().toLowerCase();
631
+ if (/^(stop|off|disconnect)$/.test(action)) {
632
+ if (!app.remote) { out(c.dim('Remote control is not on.')); return; }
633
+ app.stopRemote();
634
+ out('Remote control is off: the gateway no longer reaches this session.');
635
+ return;
636
+ }
637
+ if (app.remote) log(app.remoteInfo());
638
+ else {
639
+ const url = app.runtime.config.gatewayUrl;
640
+ // It goes through the gateway account: log in to it first (in the browser) when this machine is not; the
641
+ // provider of the model requests stays what it is.
642
+ if (!readGatewayLogin(url)) {
643
+ pendingLogin?.abort();
644
+ const controller = new AbortController();
645
+ pendingLogin = controller;
646
+ try {
647
+ await loginWithBrowser({
648
+ url,
649
+ signal: controller.signal,
650
+ onUrl: (page) => out([`Remote control goes through the gateway ${c.bold(url)}: log in to it in your browser. If it does not open, visit:`, c.blue(page), c.dim('Waiting for the browser (10 minutes at most)…')]),
651
+ });
652
+ } catch (e) {
653
+ if (e.name !== 'AbortError') out(c.red(`Gateway login failed: ${e.message}`));
654
+ return;
655
+ } finally {
656
+ if (pendingLogin === controller) pendingLogin = null;
657
+ }
658
+ }
659
+ try { await app.startRemote(); } catch (e) { out(c.red(`Could not start remote control: ${e.message}`)); return; }
660
+ }
661
+ if (action === 'open' && app.remote.url && !openBrowser(app.remote.url)) out(c.yellow(`Could not open a browser: open ${app.remote.url}`));
662
+ },
663
+ });
664
+
597
665
  add({
598
666
  name: 'plan',
599
667
  description: 'Toggle read-only plan mode (same as shift+tab)',
@@ -608,35 +676,79 @@ export function buildCommands(app) {
608
676
  add({
609
677
  name: 'rewind',
610
678
  aliases: ['checkpoint'],
611
- description: 'Undo: restore files and/or the conversation to before an earlier prompt',
679
+ description: 'Go back to an earlier prompt (or esc esc): restore the code and/or the conversation, or summarize part of it',
612
680
  async run() {
613
681
  const store = app.runtime.checkpoints;
614
- const entries = store.list().slice(0, 30);
615
- if (!entries.length) { out(c.dim('No checkpoints yet: one is created for every prompt you send.')); return; }
616
- const id = await app.showDialog(new SelectDialog({
617
- title: 'Rewind',
618
- question: 'Go back to before which prompt?',
619
- color: c.blue,
620
- options: entries.map((e) => {
682
+ // Like Claude Code: the prompts of the session, the newest at the bottom and highlighted; above them the
683
+ // conversation before a /clear of this process. "Never mind" comes back to this list.
684
+ let entry;
685
+ let mode;
686
+ for (;;) {
687
+ const entries = store.list().slice(0, 30).reverse();
688
+ const previous = app.previousSession;
689
+ if (!entries.length && !previous) { out(c.dim('No checkpoints yet: one is created for every prompt you send.')); return; }
690
+ const options = entries.map((e) => {
621
691
  const n = store.filesSince(e.id).length;
622
692
  return {
623
693
  label: truncate(e.label || '(empty prompt)', 60),
624
694
  value: e.id,
625
- description: `${new Date(e.time).toLocaleTimeString()} · ${n ? `${n} file${n === 1 ? '' : 's'} changed since` : 'no file changes since'}`,
695
+ description: `${new Date(e.time).toLocaleTimeString()} · ${n ? `${n} file${n === 1 ? '' : 's'} changed since` : 'no file changes since'}${e.conversationValid ? '' : ' · conversation compacted since'}`,
626
696
  };
627
- }),
628
- }));
629
- if (!id) return;
630
- const entry = store.get(id);
631
- const files = store.filesSince(id);
632
- const options = [];
633
- if (files.length && entry.conversationValid) options.push({ label: 'Restore code and conversation', value: 'both', description: `${files.length} file(s) go back and the chat continues from before this prompt` });
634
- if (files.length) options.push({ label: 'Restore code only', value: 'code', description: `${files.length} file(s) go back; the conversation stays as it is` });
635
- if (entry.conversationValid) options.push({ label: files.length ? 'Restore conversation only' : 'Rewind the conversation', value: 'conversation', description: 'Files are left as they are' });
636
- if (!options.length) { out(c.dim('Nothing to restore for that prompt (the conversation was compacted and no files changed).')); return; }
637
- options.push({ label: 'Never mind', value: 'cancel' });
638
- const mode = await app.showDialog(new SelectDialog({ title: 'Rewind', question: `To before: ${c.bold(truncate(entry.label || '(empty prompt)', 60))}`, color: c.blue, options, cancelValue: 'cancel' }));
639
- if (!mode || mode === 'cancel') return;
697
+ });
698
+ if (previous) options.unshift({ label: `/resume ${previous.id.slice(0, 8)} (previous session)`, value: 'previous', description: 'the conversation before /clear' });
699
+ const id = await app.showDialog(new SelectDialog({
700
+ title: 'Rewind',
701
+ question: 'Go back to which prompt? (the code and/or the conversation as they were before it)',
702
+ color: c.blue,
703
+ options,
704
+ selected: options.length - 1,
705
+ }));
706
+ if (!id) return;
707
+ if (id === 'previous') {
708
+ app.agent.resetConversation();
709
+ app.loadSession(previous.file, previous.id);
710
+ return;
711
+ }
712
+ entry = store.get(id);
713
+ const files = store.filesSince(id);
714
+ const valid = entry.conversationValid;
715
+ const actions = [];
716
+ if (files.length && valid) actions.push({ label: 'Restore code and conversation', value: 'both', description: `${files.length} file(s) go back and the chat continues from before this prompt` });
717
+ if (valid) actions.push({ label: 'Restore conversation', value: 'conversation', description: files.length ? 'the chat continues from before this prompt; files stay as they are' : 'the chat continues from before this prompt' });
718
+ if (files.length) actions.push({ label: 'Restore code', value: 'code', description: `${files.length} file(s) go back; the conversation stays as it is` });
719
+ if (valid) actions.push({ label: 'Summarize from here', value: 'summarize-after', description: 'this prompt and everything after it become a summary, to free context' });
720
+ if (valid && entry.messageIndex > 0) actions.push({ label: 'Summarize up to here', value: 'summarize-before', description: 'everything before this prompt becomes a summary; the rest stays as it is' });
721
+ if (!actions.length) { out(c.dim('Nothing to restore for that prompt (the conversation was compacted and no files changed).')); return; }
722
+ actions.push({ label: 'Never mind', value: 'cancel', description: 'back to the list' });
723
+ mode = await app.showDialog(new SelectDialog({ title: 'Rewind', question: `At: ${c.bold(truncate(entry.label || '(empty prompt)', 60))}`, color: c.blue, options: actions, cancelValue: 'cancel' }));
724
+ if (mode && mode !== 'cancel') break;
725
+ }
726
+ const id = entry.id;
727
+ if (mode.startsWith('summarize-')) {
728
+ const part = mode === 'summarize-after' ? 'after' : 'before';
729
+ const prompt = promptAt(app.agent, entry);
730
+ const index = entry.messageIndex;
731
+ app.busy = true;
732
+ app.controller = new AbortController();
733
+ app.turnStart = Date.now();
734
+ app.turnTokens = 0;
735
+ try {
736
+ await app.agent.summarizePart(index, { part, label: entry.label, signal: app.controller.signal });
737
+ store.summarized(id, part, index);
738
+ app.remote?.resync();
739
+ if (part === 'after' && prompt) app.editor.set(prompt); // as Claude Code does: the prompt to send again or edit
740
+ } catch (e) {
741
+ if (!app.controller.signal.aborted) out(c.red(`Could not summarize: ${e.message}`));
742
+ else out(c.red('Summarizing interrupted'));
743
+ } finally {
744
+ app.busy = false;
745
+ app.controller = null;
746
+ app.status = null;
747
+ app.rateWait = null;
748
+ app.sendWhatWaited();
749
+ }
750
+ return;
751
+ }
640
752
  let result;
641
753
  try { result = rewind(app.agent, store, id, mode); } catch (e) { out(c.red(e.message)); return; }
642
754
  const lines = [];
@@ -647,6 +759,7 @@ export function buildCommands(app) {
647
759
  lines.push(...r.skipped.slice(0, 5).map((f) => c.red(` skipped: ${f}`)));
648
760
  }
649
761
  if (result.conversation) lines.push(`Conversation rewound to before: ${truncate(result.prompt, 60)}`);
762
+ if (result.conversation) app.remote?.resync();
650
763
  out(lines);
651
764
  if (result.conversation && result.prompt) app.editor.set(result.prompt);
652
765
  },
package/src/config.js CHANGED
@@ -36,6 +36,9 @@ export const KNOWN_KEYS = {
36
36
  CLAUDE_PRO_CLAUDE_CREDENTIALS_FILE: 'Existing Claude Code OAuth credentials file (default: ~/.claude/.credentials.json)',
37
37
  CLAUDE_PRO_CLAUDE_CODE_VERSION: 'Claude Code version in the HTTP User-Agent (default: installed npm version)',
38
38
  CLAUDE_PRO_DISABLE_TOKEN_REFRESH: 'Set to 1 to stop this program renewing the Claude Code login by itself (it then only reads what the official client keeps up to date)',
39
+ CLAUDE_PRO_ONLINE: 'Set to 0 to keep interactive sessions off the gateway until /rc. By default a session goes online there when this computer is logged in to the gateway: its 远程控制 page shows it, and sessions on your other computers can send it tasks (@computer/name)',
40
+ CLAUDE_PRO_DOWNLOAD_DIR: 'Where files that other sessions send this one are saved (default: the Downloads folder)',
41
+ CLAUDE_PRO_MACHINE_NAME: 'This computer\'s name for sessions on your other computers (@公司 ...; default: the host name)',
39
42
  CLAUDE_PRO_AUTO_RESTART: 'Restart by itself when a turn ends and claude-pro\'s own source files changed (default 1; 0 = only on /restart or the Restart tool)',
40
43
  CLAUDE_PRO_CLAUDE_CODE_MODEL: 'Model for Claude Code OAuth (default: sonnet; independent of API model settings)',
41
44
  CLAUDE_CONFIG_DIR: 'Official Claude Code configuration directory, for selecting the locally signed-in account',
package/src/main.js CHANGED
@@ -22,6 +22,7 @@ Options:
22
22
  --system-prompt <text> Replace the system prompt
23
23
  --verbose Show thinking and full tool output
24
24
  --no-mcp Do not start MCP servers
25
+ --remote-control, --rc Online at the gateway from the start, and print the link and QR code (same as /remote-control)
25
26
  -v, --version Print version
26
27
  -h, --help Show help
27
28
 
@@ -67,6 +68,7 @@ function parseArgs(argv) {
67
68
  case '--system-prompt': flags.systemPrompt = next(); break;
68
69
  case '--verbose': flags.verbose = true; break;
69
70
  case '--no-mcp': flags.noMcp = true; break;
71
+ case '--remote-control': case '--rc': flags.remoteControl = true; break;
70
72
  case '-v': case '--version': flags.version = true; break;
71
73
  case '-h': case '--help': flags.help = true; break;
72
74
  default:
package/src/peers.js CHANGED
@@ -1,9 +1,12 @@
1
1
  // Peers: the other interactive claude-pro sessions on this machine. Each session registers itself in
2
2
  // CONFIG_DIR/peers/<id>.json (name, directory, pid, and a port and token of a small HTTP server on 127.0.0.1)
3
- // so that sessions can send each other tasks: the user with `@name task` in the input box, the model with the
3
+ // so that sessions can send each other tasks: the user with `&name task` in the input box, the model with the
4
4
  // MessagePeer tool. A task is answered automatically with the final text of the turn that handled it.
5
+ // Sessions on the user's other computers are reached through the gateway instead (see remote.js): they are
6
+ // listed as {id, name, machine, cwd, remote: true, label: 'machine/name'} and addressed by that label.
5
7
 
6
8
  import fs from 'node:fs';
9
+ import os from 'node:os';
7
10
  import path from 'node:path';
8
11
  import http from 'node:http';
9
12
  import crypto from 'node:crypto';
@@ -20,7 +23,7 @@ function alive(pid) {
20
23
  try { process.kill(pid, 0); return true; } catch (e) { return e.code === 'EPERM'; }
21
24
  }
22
25
 
23
- export const cleanName = (s) => String(s || '').replace(/[^\w.-]+/g, '-').replace(/^[-.]+|[-.]+$/g, '').slice(0, 40) || 'agent';
26
+ export const cleanName = (s) => String(s || '').replace(/[^\p{L}\p{M}\p{N}_.-]+/gu, '-').replace(/^[-.]+|[-.]+$/g, '').slice(0, 40) || 'agent';
24
27
 
25
28
  // The sessions that are running now (stale entries of sessions that died are removed).
26
29
  export function listPeers({ excludeId } = {}) {
@@ -37,8 +40,34 @@ export function listPeers({ excludeId } = {}) {
37
40
  return peers.sort((a, b) => a.startedAt - b.startedAt);
38
41
  }
39
42
 
43
+ // The name of this computer for sessions on the other computers: CLAUDE_PRO_MACHINE_NAME (公司, home...), else
44
+ // the host name. Letters of any script, digits, _ . and -.
45
+ export function machineName(env = process.env) {
46
+ const raw = env.CLAUDE_PRO_MACHINE_NAME || os.hostname() || 'computer';
47
+ return String(raw).replace(/[^\p{L}\p{M}\p{N}_.-]+/gu, '-').replace(/^[-.]+|[-.]+$/g, '').slice(0, 40) || 'computer';
48
+ }
49
+
50
+ // Find the session `ref` (with or without @) names among the local ones and those on other computers: a local
51
+ // name (or id prefix); else machine/name, a name only one remote session has, a computer with only one session
52
+ // (`&公司`), or an id prefix. `ids: false` leaves the id prefixes out (for what the user types).
53
+ // Returns {peer}, {error, ambiguous: true} when several match, or {error: null} when none does.
54
+ export function resolvePeer(local, remote, ref, { ids = true } = {}) {
55
+ const q = String(ref || '').replace(/^[@&&]/, '').toLowerCase();
56
+ if (!q) return { error: null };
57
+ const near = ids ? findPeer(local, q) : local.find((p) => p.name.toLowerCase() === q);
58
+ if (near) return { peer: near };
59
+ const same = (v) => String(v || '').toLowerCase() === q;
60
+ let hits = remote.filter((p) => same(p.label));
61
+ if (!hits.length) hits = remote.filter((p) => same(p.name));
62
+ if (!hits.length) hits = remote.filter((p) => same(p.machine));
63
+ if (!hits.length && ids && q.length >= 4) hits = remote.filter((p) => p.id.toLowerCase().startsWith(q));
64
+ if (hits.length === 1) return { peer: hits[0] };
65
+ if (hits.length > 1) return { error: `"&${String(ref).replace(/^[@&&]/, '')}" could be ${hits.map((p) => '&' + p.label).join(', ')}: say which.`, ambiguous: true };
66
+ return { error: null };
67
+ }
68
+
40
69
  export function findPeer(peers, ref) {
41
- const q = String(ref || '').replace(/^@/, '').toLowerCase();
70
+ const q = String(ref || '').replace(/^[@&&]/, '').toLowerCase();
42
71
  if (!q) return null;
43
72
  return peers.find((p) => p.name.toLowerCase() === q) || peers.find((p) => p.id.startsWith(q)) || null;
44
73
  }
@@ -116,15 +145,18 @@ export class PeerNode {
116
145
  if (typeof msg?.text !== 'string' || !msg.from?.name) return reply(400, { error: 'text and from are required' });
117
146
  const id = crypto.randomBytes(4).toString('hex');
118
147
  reply(200, { ok: true, id, name: this.name });
119
- try { this.onMessage?.({ id, kind: msg.kind === 'reply' ? 'reply' : 'task', text: msg.text, inReplyTo: msg.inReplyTo || null, from: msg.from }); } catch { /* the receiver's problem */ }
148
+ // files on this computer: [{path, name, size}] (the receiver copies them where it keeps what it is sent)
149
+ const files = (Array.isArray(msg.files) ? msg.files : []).filter((f) => f && typeof f.path === 'string' && path.isAbsolute(f.path))
150
+ .map((f) => ({ path: f.path, name: String(f.name || path.basename(f.path)), size: Number(f.size) || 0 }));
151
+ try { this.onMessage?.({ id, kind: msg.kind === 'reply' ? 'reply' : 'task', text: msg.text, inReplyTo: msg.inReplyTo || null, from: msg.from, ...(files.length ? { files } : {}) }); } catch { /* the receiver's problem */ }
120
152
  });
121
153
  }
122
154
 
123
155
  // Send to a peer (an entry of peers(), or its name). Resolves with {id} (the id the receiver gave the message).
124
- async send(target, { text, kind = 'task', inReplyTo = null }) {
156
+ async send(target, { text, kind = 'task', inReplyTo = null, files = [] }) {
125
157
  const peer = typeof target === 'string' ? findPeer(this.peers(), target) : target;
126
158
  if (!peer) throw new Error(`No running session called "${target}". /peers lists them.`);
127
- const body = JSON.stringify({ kind, text, inReplyTo, from: { id: this.id, name: this.name, cwd: this.cwd } });
159
+ const body = JSON.stringify({ kind, text, inReplyTo, from: { id: this.id, name: this.name, cwd: this.cwd }, ...(files.length ? { files } : {}) });
128
160
  return await new Promise((resolve, reject) => {
129
161
  const req = http.request({
130
162
  host: '127.0.0.1', port: peer.port, path: '/message', method: 'POST', timeout: 10_000,
@@ -149,7 +181,8 @@ export class PeerNode {
149
181
 
150
182
  // What the model is given for a message from another session.
151
183
  export function peerMessagePrompt(msg) {
152
- const from = `${msg.from.name}${msg.from.cwd ? ` (working in ${msg.from.cwd})` : ''}`;
184
+ const where = msg.from.machine ? ` on the computer "${msg.from.machine}"` : '';
185
+ const from = `${msg.from.name}${msg.from.cwd ? ` (working in ${msg.from.cwd}${where})` : where}`;
153
186
  if (msg.kind === 'reply') {
154
187
  return `<peer-message from="${msg.from.name}" kind="reply">\n${msg.text}\n</peer-message>\n<system-reminder>This is the answer of another ${'claude-pro'} session, ${from}, to a message you sent it with MessagePeer. Carry on with your task using it.</system-reminder>`;
155
188
  }