@yanzc/claude-pro 0.5.2 → 0.5.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -2
- package/package.json +1 -1
- package/src/peers.js +26 -1
- package/src/selfcheck.js +4 -1
- package/src/ui/repl.js +130 -24
- package/src/updater.js +85 -14
- package/src/utils/clipboard.js +29 -0
package/README.md
CHANGED
|
@@ -28,7 +28,12 @@ npm install -g @yanzc/claude-pro
|
|
|
28
28
|
claude-pro # 在任意项目目录里运行
|
|
29
29
|
```
|
|
30
30
|
|
|
31
|
-
自动更新(与 Claude Code 一样):用 npm
|
|
31
|
+
自动更新(与 Claude Code 一样):用 npm 全局安装的版本在启动时、以及运行中**每 5 分钟**查一次 npm 上的最新版(同一台电脑上的所有会话共用这个间隔,查到的新版本号也共享;查询绕过 npm 的 CDN 缓存)。
|
|
32
|
+
- **不打断工作**:发现新版后先记下,底部显示 `update x.y.z ready · installs when idle`;**等这个会话和这台电脑上其他开着的会话都空闲**(没有在执行任务、没有等待回答的问题、没有后台 agent/命令/文件传输)才安装,有会话在忙就每 30 秒再看一次,本会话一轮结束时也会再试。
|
|
33
|
+
- **安装中有提示**:底部显示 `⇣ updating claude-pro to x.y.z…`(`npm install -g --prefer-online`),装好后提示并自动重启加载,对话保留;重启后显示 `↻ Restarted: updated to claude-pro x.y.z`。
|
|
34
|
+
- **多个终端**:同一时间只有一个会话去安装(加锁),其他会话看到程序文件变了,会提示"x.y.z 已由另一个窗口安装",各自在空闲时重启;正在工作的会话等它的任务结束才重启。
|
|
35
|
+
- npm 刚发布时安装包要几分钟才能下载,期间安装失败会在 1 分钟后重试。所以发布后一般 5–10 分钟内各个客户端就用上新版。
|
|
36
|
+
- `/update` 立即检查;`CLAUDE_PRO_AUTO_UPDATE=0` 或 `DISABLE_AUTOUPDATER=1` 关闭。从源码运行(git 检出)的副本从不自动更新。
|
|
32
37
|
|
|
33
38
|
从源码运行:
|
|
34
39
|
|
|
@@ -234,7 +239,7 @@ claude-pro config set ANTHROPIC_MODEL deepseek-pro --project
|
|
|
234
239
|
- 流式 Markdown 渲染(代码高亮、表格、中文宽度对齐),思考过程折叠显示(`ctrl+o` 展开)
|
|
235
240
|
- `Esc` 中断;忙碌时输入的消息会排队并插入当前任务
|
|
236
241
|
- `!` Bash 模式、`#` 写入记忆(CLAUDE.md)、`\`+回车 或 `ctrl+j` 换行、粘贴大段文本折叠为 `[Pasted text #1 +42 lines]`
|
|
237
|
-
- **全屏模式(默认,和 Claude Code 的 `/tui fullscreen` 一样)**:程序画在终端的备用屏幕上(像 vim),输入框固定在窗口最底下,不随输出或终端滚动条移动;对话在程序里滚动:鼠标滚轮(每格 `CLAUDE_PRO_SCROLL_SPEED` 行,默认 3)、`PgUp`/`PgDn`(半屏)、`Ctrl+Home`(对话开头)、`Ctrl+End`(回到最新)。往上翻时新输出不会把画面拉回底部,底部浮着 `Jump to bottom (ctrl+End) ↓ · N new lines`,**点它**或按 `Ctrl+End` 回到底部;发消息时自动回到底部。**选中文字直接用鼠标拖**(不用按 Shift),松开就复制到剪贴板;双击选一个词,三击选一整行;拖到对话区上下边缘会跟着滚动。选中的部分反色显示,点一下别处、按 `Esc` 或任意键就消失;有选中时 `Ctrl+C`
|
|
242
|
+
- **全屏模式(默认,和 Claude Code 的 `/tui fullscreen` 一样)**:程序画在终端的备用屏幕上(像 vim),输入框固定在窗口最底下,不随输出或终端滚动条移动;对话在程序里滚动:鼠标滚轮(每格 `CLAUDE_PRO_SCROLL_SPEED` 行,默认 3)、`PgUp`/`PgDn`(半屏)、`Ctrl+Home`(对话开头)、`Ctrl+End`(回到最新)。往上翻时新输出不会把画面拉回底部,底部浮着 `Jump to bottom (ctrl+End) ↓ · N new lines`,**点它**或按 `Ctrl+End` 回到底部;发消息时自动回到底部。**选中文字直接用鼠标拖**(不用按 Shift),松开就复制到剪贴板;双击选一个词,三击选一整行;拖到对话区上下边缘会跟着滚动。选中的部分反色显示,点一下别处、按 `Esc` 或任意键就消失;有选中时 `Ctrl+C` 是复制(不会中断任务)。**右键**和在终端里一样:有选中时复制,没有时把剪贴板的文字粘贴进输入框(和 `Ctrl+V` 相同)。仍可按住 Shift 拖动用终端自己的选中。退出后终端回到启动前的样子(对话不留在终端滚动记录里)。`/tui default` 切回经典模式(对话写进终端自己的滚动记录,输入框在窗口底部、靠空行顶到底;`CLAUDE_PRO_PIN_INPUT=0` 让它紧跟在输出下面),`/tui fullscreen` 切回全屏,选择会记住(`CLAUDE_PRO_TUI`)
|
|
238
243
|
- `ctrl+c` 或连按两次 `Esc` 清掉的草稿不会丢:在空输入框按 `↑` 找回(连同粘贴的文本和图片),中间发过别的消息也可以
|
|
239
244
|
- `@文件` 补全并附加内容:走的就是 `Read` 工具,等于模型自己读了这个文件。最多带前 2000 行(被截断时会明确告诉模型,并把该文件记为"只读了一部分",之后 `Write` 不会去覆盖没给它看过的行);图片作为图片附加;二进制文件和超过 512KB 的文本会被跳过并提示原因;`@目录` 列出目录内容
|
|
240
245
|
- `alt+v` 粘贴剪贴板里的图片(截图用 `Win+Shift+S`),输入框里显示为 `[Image #1]`;超过 1568px 的图会自动缩小。把图片文件拖进终端(或粘贴图片路径)也会作为图片附加
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yanzc/claude-pro",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.4",
|
|
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": {
|
package/src/peers.js
CHANGED
|
@@ -75,10 +75,12 @@ export function findPeer(peers, ref) {
|
|
|
75
75
|
export class PeerNode {
|
|
76
76
|
// onMessage(msg): msg = {id, kind: 'task'|'reply', text, inReplyTo, from: {id, name, cwd}}
|
|
77
77
|
// `id`: the id this session had before a restart, so that answers on their way still find it.
|
|
78
|
-
|
|
78
|
+
// getStatus(): {busy}, what GET /status answers (an update waits until no session of the machine is busy).
|
|
79
|
+
constructor({ cwd, name, id, onMessage, getStatus }) {
|
|
79
80
|
this.cwd = cwd;
|
|
80
81
|
this.wantedName = name || null;
|
|
81
82
|
this.onMessage = onMessage;
|
|
83
|
+
this.getStatus = getStatus;
|
|
82
84
|
this.id = /^[0-9a-f]{12}$/.test(id || '') ? id : crypto.randomBytes(6).toString('hex');
|
|
83
85
|
this.token = crypto.randomBytes(16).toString('hex');
|
|
84
86
|
this.name = null;
|
|
@@ -134,6 +136,12 @@ export class PeerNode {
|
|
|
134
136
|
|
|
135
137
|
handle(req, res) {
|
|
136
138
|
const reply = (code, body) => { res.writeHead(code, { 'content-type': 'application/json' }); res.end(JSON.stringify(body)); };
|
|
139
|
+
if (req.method === 'GET' && req.url === '/status') {
|
|
140
|
+
if (req.headers.authorization !== `Bearer ${this.token}`) return reply(401, { error: 'bad token' });
|
|
141
|
+
let status = {};
|
|
142
|
+
try { status = this.getStatus?.() || {}; } catch { /* nothing to say */ }
|
|
143
|
+
return reply(200, { busy: Boolean(status.busy) });
|
|
144
|
+
}
|
|
137
145
|
if (req.method !== 'POST' || req.url !== '/message') return reply(404, { error: 'not found' });
|
|
138
146
|
if (req.headers.authorization !== `Bearer ${this.token}`) return reply(401, { error: 'bad token' });
|
|
139
147
|
let body = '';
|
|
@@ -152,6 +160,23 @@ export class PeerNode {
|
|
|
152
160
|
});
|
|
153
161
|
}
|
|
154
162
|
|
|
163
|
+
// Whether a session of this machine is working now. A session that does not answer (gone, or a version without
|
|
164
|
+
// /status) counts as idle: it cannot hold an update back forever.
|
|
165
|
+
status(peer) {
|
|
166
|
+
return new Promise((resolve) => {
|
|
167
|
+
const req = http.get({ host: '127.0.0.1', port: peer.port, path: '/status', timeout: 2000, headers: { authorization: `Bearer ${peer.token}` } }, (res) => {
|
|
168
|
+
let out = '';
|
|
169
|
+
res.setEncoding('utf8');
|
|
170
|
+
res.on('data', (d) => { out += d; });
|
|
171
|
+
res.on('end', () => {
|
|
172
|
+
try { resolve(res.statusCode === 200 ? { busy: Boolean(JSON.parse(out).busy) } : { busy: false }); } catch { resolve({ busy: false }); }
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
req.on('timeout', () => req.destroy());
|
|
176
|
+
req.on('error', () => resolve({ busy: false }));
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
155
180
|
// Send to a peer (an entry of peers(), or its name). Resolves with {id} (the id the receiver gave the message).
|
|
156
181
|
async send(target, { text, kind = 'task', inReplyTo = null, files = [] }) {
|
|
157
182
|
const peer = typeof target === 'string' ? findPeer(this.peers(), target) : target;
|
package/src/selfcheck.js
CHANGED
|
@@ -10,6 +10,9 @@ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
|
10
10
|
async function check() {
|
|
11
11
|
const problems = [];
|
|
12
12
|
const fail = (text) => problems.push(text);
|
|
13
|
+
// The App starts as a session does (the fullscreen renderer, the update timer, ...), so that code only a real
|
|
14
|
+
// start runs is checked too, but this process must not show up as a session, go online or install an update.
|
|
15
|
+
Object.assign(process.env, { CLAUDE_PRO_PEERS: '0', CLAUDE_PRO_ONLINE: '0', CLAUDE_PRO_AUTO_UPDATE: '0' });
|
|
13
16
|
|
|
14
17
|
let App;
|
|
15
18
|
try {
|
|
@@ -25,7 +28,7 @@ async function check() {
|
|
|
25
28
|
const stdin = new PassThrough();
|
|
26
29
|
Object.assign(stdin, { isTTY: true, setRawMode() {} });
|
|
27
30
|
|
|
28
|
-
const app = new App({ flags: { noMcp: true }, cwd: process.cwd(), stdin, stdout,
|
|
31
|
+
const app = new App({ flags: { noMcp: true }, cwd: process.cwd(), stdin, stdout, selfCheck: true });
|
|
29
32
|
let exited;
|
|
30
33
|
try {
|
|
31
34
|
exited = app.start('');
|
package/src/ui/repl.js
CHANGED
|
@@ -27,11 +27,11 @@ import { ripgrepPath } from '../utils/shell.js';
|
|
|
27
27
|
import { describePause, formatClock, humanDuration, resumeText, shouldAnnounce } from '../ratelimit.js';
|
|
28
28
|
import { shutdownBackgroundShells, listBackgroundShells } from '../tools/bash.js';
|
|
29
29
|
import { estimateTokens } from '../agent.js';
|
|
30
|
-
import { readClipboardImage, imageFromFile, watchClipboard, writeClipboard } from '../utils/clipboard.js';
|
|
30
|
+
import { readClipboardImage, imageFromFile, watchClipboard, writeClipboard, readClipboardText } from '../utils/clipboard.js';
|
|
31
31
|
import { fitImagesIn } from '../utils/images.js';
|
|
32
32
|
import { downloadsDir, filesToSend, formatBytes } from '../utils/downloads.js';
|
|
33
33
|
import { copyUnique } from '../transfer.js';
|
|
34
|
-
import { checkForUpdate } from '../updater.js';
|
|
34
|
+
import { checkForUpdate, installUpdate, installedVersion, isNewer, CHECK_EVERY_MS } from '../updater.js';
|
|
35
35
|
import { PeerNode, peersEnabled, findPeer, resolvePeer, machineName, cleanName, peerMessagePrompt } from '../peers.js';
|
|
36
36
|
import { RemoteControl, transcriptItems, onlineEnabled, REMOTE_WIDTH } from '../remote.js';
|
|
37
37
|
import { qrLines } from '../qr.js';
|
|
@@ -143,12 +143,16 @@ class FileIndex {
|
|
|
143
143
|
}
|
|
144
144
|
|
|
145
145
|
export class App {
|
|
146
|
-
|
|
146
|
+
// `noProcessExit`: the tests, which drive the App themselves; it never ends the process, and it has no peers, no
|
|
147
|
+
// gateway connection, no update checks and the default renderer. `selfCheck` (see selfcheck.js): it never ends the
|
|
148
|
+
// process either, but otherwise starts as a session does, so that code only a real start runs is checked too.
|
|
149
|
+
constructor({ flags, cwd, stdin = process.stdin, stdout = process.stdout, noProcessExit = false, selfCheck = false }) {
|
|
147
150
|
this.flags = flags;
|
|
148
151
|
this.cwd = cwd;
|
|
149
152
|
this.stdin = stdin;
|
|
150
153
|
this.stdout = stdout;
|
|
151
|
-
this.noProcessExit = noProcessExit;
|
|
154
|
+
this.noProcessExit = noProcessExit || selfCheck;
|
|
155
|
+
this.driven = noProcessExit;
|
|
152
156
|
this.screen = new Screen(stdout);
|
|
153
157
|
this.busy = false;
|
|
154
158
|
this.controller = null;
|
|
@@ -249,6 +253,7 @@ export class App {
|
|
|
249
253
|
// Keys typed while starting up stay buffered in stdin until we listen. Mouse reports (fullscreen: the wheel)
|
|
250
254
|
// are taken out before the keys are read.
|
|
251
255
|
const keys = mouseFilter(stdin, (m) => this.onMouse(m));
|
|
256
|
+
this.keys = keys;
|
|
252
257
|
readline.emitKeypressEvents(keys);
|
|
253
258
|
keys.on('keypress', (str, key) => {
|
|
254
259
|
if (/^\x1b\[\d+;\d+R$/.test(key?.sequence || '')) return; // a late answer to the cursor position query, not a key
|
|
@@ -279,7 +284,12 @@ export class App {
|
|
|
279
284
|
}
|
|
280
285
|
this.fileIndex.refresh();
|
|
281
286
|
this.startSourceWatch();
|
|
282
|
-
if (!this.
|
|
287
|
+
if (!this.driven) {
|
|
288
|
+
if (!this.flags.restart) this.autoUpdate();
|
|
289
|
+
// and every few minutes while it runs, so that a release reaches a session that stays open for days
|
|
290
|
+
this.updateTimer = setInterval(() => { if (!this.exiting) this.autoUpdate({ quiet: true }); }, CHECK_EVERY_MS);
|
|
291
|
+
this.updateTimer.unref?.();
|
|
292
|
+
}
|
|
283
293
|
return new Promise((resolve) => { this.resolveExit = resolve; });
|
|
284
294
|
}
|
|
285
295
|
|
|
@@ -388,9 +398,9 @@ export class App {
|
|
|
388
398
|
// Register this session so that the other sessions on the machine can send it tasks. Not in the tests
|
|
389
399
|
// (which drive the App themselves) and not with CLAUDE_PRO_PEERS=0.
|
|
390
400
|
async startPeer() {
|
|
391
|
-
if (this.
|
|
401
|
+
if (this.driven || !peersEnabled()) return;
|
|
392
402
|
try {
|
|
393
|
-
this.peers = await new PeerNode({ cwd: this.cwd, name: this.flags.restart?.peerName, id: this.flags.restart?.peerId, onMessage: (msg) => this.onPeerMessage(msg) }).start();
|
|
403
|
+
this.peers = await new PeerNode({ cwd: this.cwd, name: this.flags.restart?.peerName, id: this.flags.restart?.peerId, onMessage: (msg) => this.onPeerMessage(msg), getStatus: () => ({ busy: this.working() }) }).start();
|
|
394
404
|
} catch (e) {
|
|
395
405
|
this.peers = null;
|
|
396
406
|
this.notice(`Other sessions cannot reach this one: ${e.message}`, 'warn');
|
|
@@ -706,12 +716,12 @@ export class App {
|
|
|
706
716
|
// Online at the gateway (see remote.js) from the start: by itself when this computer is logged in to the gateway
|
|
707
717
|
// (CLAUDE_PRO_ONLINE=0 turns that off), with --rc (which prints the link too), and after a restart as before (with
|
|
708
718
|
// the same id, so that open pages and messages on their way find it again). It does not hold up the start. Never
|
|
709
|
-
// in the tests
|
|
719
|
+
// in the tests, which drive the App themselves, nor in the self-check, which sets CLAUDE_PRO_ONLINE=0.
|
|
710
720
|
autoStartRemote(restart) {
|
|
711
721
|
const carried = restart?.remote;
|
|
712
722
|
if (carried?.off) { this.remoteStopped = true; return; }
|
|
713
723
|
const explicit = Boolean(this.flags.remoteControl);
|
|
714
|
-
if (this.
|
|
724
|
+
if (this.driven && !explicit) return;
|
|
715
725
|
if (!explicit && !carried?.id && !(onlineEnabled(this.config.env) && readGatewayLogin(this.config.gatewayUrl))) return;
|
|
716
726
|
this.startRemote({ id: carried?.id, quiet: !explicit || Boolean(carried?.id), persistent: !explicit }).catch((e) => {
|
|
717
727
|
this.notice(`${explicit ? 'Remote control could not start' : 'This session could not go online at the gateway'}: ${e.message}${e.code === 'NO_GATEWAY_LOGIN' ? '' : ' (/rc tries again)'}`, 'warn');
|
|
@@ -876,8 +886,15 @@ export class App {
|
|
|
876
886
|
// ---------------------------------------------------------------- restarting (see restart.js)
|
|
877
887
|
|
|
878
888
|
// What a restart would kill: agents and shells that are still running in the background.
|
|
889
|
+
// Something is going on that an update must not cut into: a turn, a question waiting for an answer, messages
|
|
890
|
+
// waiting to be sent, background agents and shells, files on their way.
|
|
891
|
+
working() {
|
|
892
|
+
return Boolean(this.busy || this.dialogs.length || this.queue.length || this.restartBlockers().length);
|
|
893
|
+
}
|
|
894
|
+
|
|
879
895
|
restartBlockers() {
|
|
880
896
|
const found = [];
|
|
897
|
+
for (const t of this.transfers.values()) found.push(`files on their way ${t.direction === 'up' ? 'to' : 'from'} &${t.label}`);
|
|
881
898
|
for (const t of this.runtime.tasks.running()) found.push(`background agent ${t.id} "${t.description}"`);
|
|
882
899
|
for (const s of listBackgroundShells()) if (s.status === 'running') found.push(`background shell ${s.id} (${String(s.command).slice(0, 40)})`);
|
|
883
900
|
return found;
|
|
@@ -975,6 +992,13 @@ export class App {
|
|
|
975
992
|
if (!canRestart() || /^(0|false|no|off)$/i.test(this.config.env.CLAUDE_PRO_AUTO_RESTART || '')) return;
|
|
976
993
|
this.sourceWatch = watchSource(INSTALL_DIR, (file) => {
|
|
977
994
|
this.sourceChanged = file || true;
|
|
995
|
+
// another window (or npm) installed a new version: say so, and load it once this session is idle
|
|
996
|
+
const disk = installedVersion();
|
|
997
|
+
if (disk !== VERSION && isNewer(disk, VERSION) && this.updateRestart !== disk) {
|
|
998
|
+
this.updateRestart = disk;
|
|
999
|
+
this.pendingUpdate = null;
|
|
1000
|
+
this.notice(`${APP_NAME} ${disk} was installed (by another window or npm); this one restarts to load it ${this.working() ? 'once its work is done' : 'now'}.`);
|
|
1001
|
+
}
|
|
978
1002
|
if (!this.busy) this.maybeAutoReload();
|
|
979
1003
|
});
|
|
980
1004
|
}
|
|
@@ -986,7 +1010,7 @@ export class App {
|
|
|
986
1010
|
const changed = this.sourceChanged;
|
|
987
1011
|
this.sourceChanged = null;
|
|
988
1012
|
this.reloading = true;
|
|
989
|
-
this.flash('Source changed: checking the new code…');
|
|
1013
|
+
this.flash(this.updateRestart ? `Updated to ${this.updateRestart}: checking it starts, then restarting…` : 'Source changed: checking the new code…');
|
|
990
1014
|
let check;
|
|
991
1015
|
try { check = await runSelfCheck(); } finally { this.reloading = false; }
|
|
992
1016
|
if (this.exiting) return;
|
|
@@ -995,33 +1019,93 @@ export class App {
|
|
|
995
1019
|
return;
|
|
996
1020
|
}
|
|
997
1021
|
if (this.busy || this.dialogs.length) { this.sourceChanged = changed; return; } // something started meanwhile: later
|
|
998
|
-
this.requestRestart({ reason: 'source changed', force: true });
|
|
1022
|
+
this.requestRestart({ reason: this.updateRestart ? `updated to ${APP_NAME} ${this.updateRestart}` : 'source changed', force: true });
|
|
999
1023
|
}
|
|
1000
1024
|
|
|
1001
|
-
// A newer version on npm
|
|
1002
|
-
//
|
|
1003
|
-
|
|
1004
|
-
|
|
1025
|
+
// A newer version on npm (see updater.js): it is installed once no session of this machine is working, and then
|
|
1026
|
+
// loaded by a restart (this session's; the others restart when they see the files change). `force`: the /update
|
|
1027
|
+
// command, which also says when there is nothing to do. `quiet`: the periodic check, which says nothing when an
|
|
1028
|
+
// install fails (it is tried again in a minute).
|
|
1029
|
+
async autoUpdate({ force = false, quiet = false } = {}) {
|
|
1030
|
+
if (this.updating) { if (force) this.notice(`${APP_NAME} ${this.updating} is being installed.`); return; }
|
|
1005
1031
|
let r;
|
|
1006
1032
|
try {
|
|
1007
|
-
r = await checkForUpdate({ force, env: { ...process.env, ...this.config.env }
|
|
1033
|
+
r = await checkForUpdate({ force, env: { ...process.env, ...this.config.env } });
|
|
1008
1034
|
} catch (e) {
|
|
1009
1035
|
r = { status: 'failed', error: e.message };
|
|
1010
|
-
} finally {
|
|
1011
|
-
this.updating = false;
|
|
1012
1036
|
}
|
|
1013
1037
|
if (this.exiting) return;
|
|
1014
|
-
if (r.status === '
|
|
1015
|
-
this.
|
|
1016
|
-
|
|
1017
|
-
|
|
1038
|
+
if (r.status === 'available') {
|
|
1039
|
+
if (this.pendingUpdate !== r.version) {
|
|
1040
|
+
this.pendingUpdate = r.version;
|
|
1041
|
+
this.updateQuiet = quiet;
|
|
1042
|
+
this.screen.schedule();
|
|
1043
|
+
}
|
|
1044
|
+
if (force && this.working()) this.notice(`${APP_NAME} ${r.version} is out: it is installed as soon as this session's work is done.`);
|
|
1045
|
+
await this.maybeInstallUpdate({ force });
|
|
1046
|
+
} else if (r.status === 'installed') {
|
|
1047
|
+
if (force || this.updateRestart !== r.version) this.notice(`${APP_NAME} ${r.version} is installed already; this session restarts to load it ${this.working() ? 'once its work is done' : 'now'}.`);
|
|
1048
|
+
this.updateRestart = r.version;
|
|
1049
|
+
if (canRestart()) { this.sourceChanged ||= true; if (!this.busy) this.maybeAutoReload(); }
|
|
1018
1050
|
} else if (r.status === 'failed') {
|
|
1019
|
-
if (force || r.version) this.notice(`Could not update ${APP_NAME}${r.version ? ` to ${r.version}` : ''}: ${r.error}`, 'warn');
|
|
1051
|
+
if (force || (r.version && !quiet)) this.notice(`Could not update ${APP_NAME}${r.version ? ` to ${r.version}` : ''}: ${r.error}`, 'warn');
|
|
1020
1052
|
} else if (force) {
|
|
1021
1053
|
this.notice(r.status === 'dev' ? `This copy runs from a source checkout (${INSTALL_DIR}): update it with git.` : `${APP_NAME} ${VERSION} is the latest version.`);
|
|
1022
1054
|
}
|
|
1023
1055
|
}
|
|
1024
1056
|
|
|
1057
|
+
// Install the version found (pendingUpdate) if nothing is going on: in this session, and in the other sessions
|
|
1058
|
+
// of this machine, whose files it replaces under them. Called again when this session's turn ends, and every
|
|
1059
|
+
// half minute while another session works.
|
|
1060
|
+
async maybeInstallUpdate({ force = false } = {}) {
|
|
1061
|
+
const version = this.pendingUpdate;
|
|
1062
|
+
if (!version || this.updating || this.exiting || this.restartRequest) return;
|
|
1063
|
+
if (this.working()) return; // the end of the turn calls this again
|
|
1064
|
+
const local = this.peers?.peers() || [];
|
|
1065
|
+
const busy = [];
|
|
1066
|
+
for (const p of local) if ((await this.peers.status(p)).busy) busy.push(p.name);
|
|
1067
|
+
if (this.pendingUpdate !== version || this.updating || this.exiting) return;
|
|
1068
|
+
if (busy.length) {
|
|
1069
|
+
if (this.updateWaitingFor !== busy.join(',')) {
|
|
1070
|
+
this.updateWaitingFor = busy.join(',');
|
|
1071
|
+
this.screen.schedule();
|
|
1072
|
+
if (force) this.notice(`${APP_NAME} ${version} is installed once &${busy.join(', &')} ${busy.length === 1 ? 'is' : 'are'} done working.`);
|
|
1073
|
+
}
|
|
1074
|
+
clearTimeout(this.updateRetry);
|
|
1075
|
+
this.updateRetry = setTimeout(() => this.maybeInstallUpdate(), 30_000);
|
|
1076
|
+
this.updateRetry.unref?.();
|
|
1077
|
+
return;
|
|
1078
|
+
}
|
|
1079
|
+
if (this.working()) return;
|
|
1080
|
+
this.updateWaitingFor = null;
|
|
1081
|
+
this.updating = version;
|
|
1082
|
+
this.screen.schedule();
|
|
1083
|
+
let r;
|
|
1084
|
+
try {
|
|
1085
|
+
r = await installUpdate(version);
|
|
1086
|
+
} catch (e) {
|
|
1087
|
+
r = { status: 'failed', version, error: e.message };
|
|
1088
|
+
} finally {
|
|
1089
|
+
this.updating = null;
|
|
1090
|
+
this.screen.schedule();
|
|
1091
|
+
}
|
|
1092
|
+
if (this.exiting) return;
|
|
1093
|
+
if (r.status === 'locked') return; // another window is installing it: its files changing tell this one
|
|
1094
|
+
if (r.status === 'updated' || r.status === 'installed') {
|
|
1095
|
+
this.pendingUpdate = null;
|
|
1096
|
+
this.updateRestart = r.version;
|
|
1097
|
+
if (canRestart()) {
|
|
1098
|
+
this.notice(`${APP_NAME} was updated to ${r.version} (from ${VERSION}); restarting to load it. The conversation carries over.`);
|
|
1099
|
+
this.sourceChanged = true;
|
|
1100
|
+
this.maybeAutoReload();
|
|
1101
|
+
} else {
|
|
1102
|
+
this.notice(`${APP_NAME} was updated to ${r.version}: restart the program to use it.`);
|
|
1103
|
+
}
|
|
1104
|
+
return;
|
|
1105
|
+
}
|
|
1106
|
+
if (force || !this.updateQuiet) this.notice(`Could not update ${APP_NAME} to ${version}: ${r.error}`, 'warn');
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1025
1109
|
printWelcome() {
|
|
1026
1110
|
const cfg = this.config;
|
|
1027
1111
|
const w = Math.min(this.width - 1, 76);
|
|
@@ -1073,7 +1157,7 @@ export class App {
|
|
|
1073
1157
|
// Fullscreen (see screen.js) unless CLAUDE_PRO_TUI=default, as Claude Code's /tui: the input box fixed at the
|
|
1074
1158
|
// bottom of the window, the conversation scrolled inside the program. Not in the tests that drive the App.
|
|
1075
1159
|
fullscreenWanted() {
|
|
1076
|
-
const mode = String(this.config.env.CLAUDE_PRO_TUI || (this.
|
|
1160
|
+
const mode = String(this.config.env.CLAUDE_PRO_TUI || (this.driven ? 'default' : 'fullscreen')).toLowerCase();
|
|
1077
1161
|
return mode === 'fullscreen' && this.stdout.isTTY !== false;
|
|
1078
1162
|
}
|
|
1079
1163
|
|
|
@@ -1087,6 +1171,22 @@ export class App {
|
|
|
1087
1171
|
this.screen.scrollBy(m.button & 1 ? -speed : speed);
|
|
1088
1172
|
return;
|
|
1089
1173
|
}
|
|
1174
|
+
// A right click, which the terminal no longer gets: what it did there. With a selection it copies it;
|
|
1175
|
+
// without one it pastes the clipboard's text, as the terminal's own paste (ctrl+v) arrives, so that it is
|
|
1176
|
+
// handled the same way (long text folded, an image path attached, into a dialog's answer box).
|
|
1177
|
+
if ((m.button & 3) === 2 && !(m.button & 32) && !m.release) {
|
|
1178
|
+
if (this.screen.selection) {
|
|
1179
|
+
this.copyText(this.screen.selectedText());
|
|
1180
|
+
this.screen.clearSelection();
|
|
1181
|
+
return;
|
|
1182
|
+
}
|
|
1183
|
+
readClipboardText().then((text) => {
|
|
1184
|
+
if (!text || this.exiting) return;
|
|
1185
|
+
const clean = text.replace(/\x1b\[20[01]~/g, '').replace(/\r\n|\n/g, '\r');
|
|
1186
|
+
this.keys.write(`\x1b[200~${clean}\x1b[201~`);
|
|
1187
|
+
});
|
|
1188
|
+
return;
|
|
1189
|
+
}
|
|
1090
1190
|
const r = this.screen.mouse(m);
|
|
1091
1191
|
if (r?.copy) this.copyText(r.copy);
|
|
1092
1192
|
}
|
|
@@ -1474,6 +1574,7 @@ export class App {
|
|
|
1474
1574
|
this.runAgent(this.agent.drainNotifications());
|
|
1475
1575
|
} else {
|
|
1476
1576
|
this.maybeAutoReload();
|
|
1577
|
+
this.maybeInstallUpdate();
|
|
1477
1578
|
}
|
|
1478
1579
|
}
|
|
1479
1580
|
|
|
@@ -2054,6 +2155,11 @@ export class App {
|
|
|
2054
2155
|
left += c.dim(' · ') + c.green(`⇄ ${this.remote.viewers} watching`);
|
|
2055
2156
|
}
|
|
2056
2157
|
if (this.transfers.size && !flash) left += c.dim(' · ') + this.transferStatus();
|
|
2158
|
+
if (!flash) {
|
|
2159
|
+
if (this.updating) left += c.dim(' · ') + c.blue(`⇣ updating ${APP_NAME} to ${this.updating}…`);
|
|
2160
|
+
else if (this.updateRestart && this.sourceChanged && !this.restartRequest) left += c.dim(' · ') + c.blue(`↻ ${this.updateRestart} installed: restarts ${this.working() ? 'when the work is done' : 'now'}`);
|
|
2161
|
+
else if (this.pendingUpdate) left += c.dim(' · ') + c.dim(`update ${this.pendingUpdate} ready · ${this.updateWaitingFor ? `installs when &${this.updateWaitingFor.split(',')[0]} is idle` : 'installs when idle'}`);
|
|
2162
|
+
}
|
|
2057
2163
|
const cfg = this.config;
|
|
2058
2164
|
const threshold = cfg.autoCompactThreshold(cfg.mainModel);
|
|
2059
2165
|
const used = this.agent.contextEstimate;
|
package/src/updater.js
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
// Auto-update, as in Claude Code: an interactive session installed with `npm i -g claude-pro` looks for a newer
|
|
2
|
-
// version on the npm registry (
|
|
3
|
-
//
|
|
4
|
-
//
|
|
2
|
+
// version on the npm registry (when it starts and every few minutes while it runs; the sessions of a machine share
|
|
3
|
+
// the interval, and what one finds the others see in the state file). It is installed only once no session of the
|
|
4
|
+
// machine is working (App.maybeInstallUpdate asks them), by one session at a time (a lock file), and the session
|
|
5
|
+
// then loads it with a restart (the conversation carries over); the others see the files change and restart too,
|
|
6
|
+
// each once it is idle (App.startSourceWatch). A git checkout (the development copy) is never touched. Off with
|
|
7
|
+
// CLAUDE_PRO_AUTO_UPDATE=0 or DISABLE_AUTOUPDATER=1.
|
|
5
8
|
|
|
6
9
|
import fs from 'node:fs';
|
|
7
10
|
import path from 'node:path';
|
|
@@ -12,7 +15,10 @@ import { CONFIG_DIR, INSTALL_DIR, VERSION } from './config.js';
|
|
|
12
15
|
// The name on npm (scoped: npm refused plain `claude-pro` as too close to another package's name).
|
|
13
16
|
export const PACKAGE_NAME = JSON.parse(fs.readFileSync(path.join(INSTALL_DIR, 'package.json'), 'utf8')).name;
|
|
14
17
|
const STATE_FILE = path.join(CONFIG_DIR, 'update-check.json');
|
|
15
|
-
const
|
|
18
|
+
const LOCK_FILE = path.join(CONFIG_DIR, 'update.lock');
|
|
19
|
+
const LOCK_STALE_MS = 15 * 60_000;
|
|
20
|
+
export const CHECK_EVERY_MS = 5 * 60_000; // a release reaches the running sessions within minutes
|
|
21
|
+
const RETRY_FAILED_MS = 60_000; // a release's tarball can take minutes to be downloadable after it is published
|
|
16
22
|
|
|
17
23
|
export function autoUpdateEnabled(env = process.env) {
|
|
18
24
|
if (/^(1|true|yes)$/i.test(env.DISABLE_AUTOUPDATER || '')) return false;
|
|
@@ -39,12 +45,48 @@ export function isNewer(latest, current) {
|
|
|
39
45
|
return false;
|
|
40
46
|
}
|
|
41
47
|
|
|
48
|
+
// The version installed on disk now (another session may have updated it since this one started).
|
|
49
|
+
export function installedVersion(dir = INSTALL_DIR) {
|
|
50
|
+
try { return JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8')).version; } catch { return VERSION; }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function alive(pid) {
|
|
54
|
+
try { process.kill(pid, 0); return true; } catch (e) { return e.code === 'EPERM'; }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// One session of the machine installs at a time. A lock left by a session that died, or one that is very old, is taken over.
|
|
58
|
+
function takeLock() {
|
|
59
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
60
|
+
try {
|
|
61
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
62
|
+
fs.writeFileSync(LOCK_FILE, JSON.stringify({ pid: process.pid, at: Date.now() }), { flag: 'wx' });
|
|
63
|
+
return true;
|
|
64
|
+
} catch (e) {
|
|
65
|
+
if (e.code !== 'EEXIST') return false;
|
|
66
|
+
let held = {};
|
|
67
|
+
try { held = JSON.parse(fs.readFileSync(LOCK_FILE, 'utf8')); } catch { /* unreadable: stale */ }
|
|
68
|
+
if (alive(held.pid) && Date.now() - (held.at || 0) < LOCK_STALE_MS) return false;
|
|
69
|
+
try { fs.unlinkSync(LOCK_FILE); } catch { /* someone else took it over */ }
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function releaseLock() {
|
|
76
|
+
try {
|
|
77
|
+
const held = JSON.parse(fs.readFileSync(LOCK_FILE, 'utf8'));
|
|
78
|
+
if (held.pid === process.pid) fs.unlinkSync(LOCK_FILE);
|
|
79
|
+
} catch { /* gone already */ }
|
|
80
|
+
}
|
|
81
|
+
|
|
42
82
|
function readState() { try { return JSON.parse(fs.readFileSync(STATE_FILE, 'utf8')); } catch { return {}; } }
|
|
43
83
|
function writeState(s) { try { fs.mkdirSync(CONFIG_DIR, { recursive: true }); fs.writeFileSync(STATE_FILE, JSON.stringify(s)); } catch { /* ignore */ } }
|
|
44
84
|
|
|
45
85
|
export function latestVersion({ timeoutMs = 10_000 } = {}) {
|
|
46
86
|
return new Promise((resolve, reject) => {
|
|
47
|
-
|
|
87
|
+
// past the registry's CDN cache, which can answer with the version before the one just published
|
|
88
|
+
const url = `https://registry.npmjs.org/${PACKAGE_NAME.replace('/', '%2f')}/latest?t=${Date.now()}`;
|
|
89
|
+
const req = https.get(url, { headers: { accept: 'application/json', 'cache-control': 'no-cache' }, timeout: timeoutMs }, (res) => {
|
|
48
90
|
let body = '';
|
|
49
91
|
res.setEncoding('utf8');
|
|
50
92
|
res.on('data', (d) => { body += d; });
|
|
@@ -70,24 +112,53 @@ function runNpm(args, { timeoutMs = 300_000 } = {}) {
|
|
|
70
112
|
});
|
|
71
113
|
}
|
|
72
114
|
|
|
73
|
-
// Look for a newer version
|
|
74
|
-
//
|
|
75
|
-
|
|
115
|
+
// Look for a newer version. Resolves with {status: 'disabled'|'dev'|'skipped'|'current'|'available'|'installed', version}:
|
|
116
|
+
// 'available' to be installed (installUpdate), 'installed' when it is on disk already (another session installed
|
|
117
|
+
// it: a restart loads it). Within the interval the registry is not asked again, but what another session found is
|
|
118
|
+
// still 'available'. `force`: ask now, whatever the last check was (the /update command).
|
|
119
|
+
export async function checkForUpdate({ force = false, env = process.env } = {}) {
|
|
76
120
|
if (!force && !autoUpdateEnabled(env)) return { status: 'disabled' };
|
|
77
121
|
if (!installedByNpm()) return { status: 'dev' };
|
|
78
122
|
const state = readState();
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
123
|
+
let latest;
|
|
124
|
+
if (!force && state.checkedAt && Date.now() - state.checkedAt < CHECK_EVERY_MS) {
|
|
125
|
+
if (!state.available) return { status: 'skipped' };
|
|
126
|
+
latest = state.available;
|
|
127
|
+
} else {
|
|
128
|
+
writeState({ ...state, checkedAt: Date.now() });
|
|
129
|
+
latest = await latestVersion();
|
|
130
|
+
writeState({ ...readState(), available: latest });
|
|
131
|
+
}
|
|
82
132
|
if (!isNewer(latest, VERSION)) return { status: 'current', version: latest };
|
|
133
|
+
if (!isNewer(latest, installedVersion())) return { status: 'installed', version: installedVersion() };
|
|
134
|
+
return { status: 'available', version: latest };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Install `version` with npm. Resolves with {status: 'updated'|'installed'|'locked'|'failed', version, error}:
|
|
138
|
+
// 'locked' while another session of the machine is installing.
|
|
139
|
+
export async function installUpdate(version, { onInstalling } = {}) {
|
|
140
|
+
if (!takeLock()) return { status: 'locked', version };
|
|
141
|
+
try {
|
|
142
|
+
if (!isNewer(version, installedVersion())) return { status: 'installed', version: installedVersion() };
|
|
143
|
+
return await install(version, onInstalling);
|
|
144
|
+
} finally {
|
|
145
|
+
releaseLock();
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function install(latest, onInstalling) {
|
|
83
150
|
// Only a global install is updated by `npm i -g`: one in a project's node_modules belongs to that project.
|
|
84
151
|
const root = await runNpm(['root', '-g'], { timeoutMs: 30_000 });
|
|
85
152
|
if (!root.ok || path.resolve(root.output.split(/\r?\n/).pop()) !== path.resolve(modulesDir())) {
|
|
86
153
|
return { status: 'failed', version: latest, error: `this copy (${INSTALL_DIR}) is not the global npm install; update it with npm yourself` };
|
|
87
154
|
}
|
|
88
155
|
onInstalling?.(latest);
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
156
|
+
// --prefer-online: not npm's own cache of the package's versions, which may not know this one yet
|
|
157
|
+
const r = await runNpm(['install', '-g', '--prefer-online', `${PACKAGE_NAME}@${latest}`]);
|
|
158
|
+
if (!r.ok) {
|
|
159
|
+
writeState({ ...readState(), checkedAt: Date.now() - CHECK_EVERY_MS + RETRY_FAILED_MS }); // again in a minute
|
|
160
|
+
return { status: 'failed', version: latest, error: r.output.split('\n').slice(-5).join('\n') };
|
|
161
|
+
}
|
|
162
|
+
writeState({ ...readState(), installed: latest });
|
|
92
163
|
return { status: 'updated', version: latest };
|
|
93
164
|
}
|
package/src/utils/clipboard.js
CHANGED
|
@@ -164,6 +164,35 @@ export async function writeClipboard(text) {
|
|
|
164
164
|
return false;
|
|
165
165
|
}
|
|
166
166
|
|
|
167
|
+
// The text on the system clipboard ('' when there is none), for a right click in the fullscreen renderer, which
|
|
168
|
+
// gets the mouse instead of the terminal: Windows PowerShell (written out as UTF-8), pbpaste on macOS,
|
|
169
|
+
// wl-paste / xclip / xsel elsewhere.
|
|
170
|
+
export async function readClipboardText() {
|
|
171
|
+
const tries = process.platform === 'win32'
|
|
172
|
+
? [['powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', '$t = Get-Clipboard -Raw; if ($t) { $b = [Text.Encoding]::UTF8.GetBytes($t); $o = [Console]::OpenStandardOutput(); $o.Write($b, 0, $b.Length) }']]]
|
|
173
|
+
: process.platform === 'darwin'
|
|
174
|
+
? [['pbpaste', []]]
|
|
175
|
+
: [['wl-paste', ['--no-newline']], ['xclip', ['-selection', 'clipboard', '-o']], ['xsel', ['--clipboard', '--output']]];
|
|
176
|
+
for (const [cmd, args] of tries) {
|
|
177
|
+
const text = await new Promise((resolve) => {
|
|
178
|
+
const chunks = [];
|
|
179
|
+
let child;
|
|
180
|
+
try {
|
|
181
|
+
child = spawn(cmd, args, { windowsHide: true, stdio: ['ignore', 'pipe', 'ignore'] });
|
|
182
|
+
} catch {
|
|
183
|
+
resolve(null);
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
const timer = setTimeout(() => child.kill(), 10_000);
|
|
187
|
+
child.stdout.on('data', (d) => chunks.push(d));
|
|
188
|
+
child.on('error', () => { clearTimeout(timer); resolve(null); });
|
|
189
|
+
child.on('close', (code) => { clearTimeout(timer); resolve(code === 0 ? Buffer.concat(chunks).toString('utf8') : null); });
|
|
190
|
+
});
|
|
191
|
+
if (text !== null) return text;
|
|
192
|
+
}
|
|
193
|
+
return '';
|
|
194
|
+
}
|
|
195
|
+
|
|
167
196
|
// Returns {image} on success, {error: string} on failure, or {error: null} when the clipboard has no image.
|
|
168
197
|
export async function readClipboardImage({ source } = {}) {
|
|
169
198
|
if (process.platform === 'win32') return readWindows(source);
|