@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 CHANGED
@@ -28,7 +28,12 @@ npm install -g @yanzc/claude-pro
28
28
  claude-pro # 在任意项目目录里运行
29
29
  ```
30
30
 
31
- 自动更新(与 Claude Code 一样):用 npm 全局安装的版本每 6 小时最多查一次 npm 上的最新版,有新版就在后台 `npm install -g @yanzc/claude-pro@新版本`,装好后在会话空闲时自动重启加载,对话保留。`/update` 立即检查;`CLAUDE_PRO_AUTO_UPDATE=0` 或 `DISABLE_AUTOUPDATER=1` 关闭。从源码运行(git 检出)的副本从不自动更新。
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` 是复制(不会中断任务)。仍可按住 Shift 拖动用终端自己的选中。退出后终端回到启动前的样子(对话不留在终端滚动记录里)。`/tui default` 切回经典模式(对话写进终端自己的滚动记录,输入框在窗口底部、靠空行顶到底;`CLAUDE_PRO_PIN_INPUT=0` 让它紧跟在输出下面),`/tui fullscreen` 切回全屏,选择会记住(`CLAUDE_PRO_TUI`)
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.2",
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
- constructor({ cwd, name, id, onMessage }) {
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, noProcessExit: true });
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
- constructor({ flags, cwd, stdin = process.stdin, stdout = process.stdout, noProcessExit = false }) {
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.noProcessExit && !this.flags.restart) this.autoUpdate();
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.noProcessExit || !peersEnabled()) return;
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 and the self-check, which drive the App themselves.
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.noProcessExit && !explicit) return;
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 is installed in the background (see updater.js) and loaded by a restart once the
1002
- // session is idle. `force`: the /update command, which also says when there is nothing to do.
1003
- async autoUpdate({ force = false } = {}) {
1004
- if (this.updating) { if (force) this.notice('An update is already being installed.'); return; }
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 }, onInstalling: (v) => { this.updating = true; this.flash(`Installing ${APP_NAME} ${v}…`); } });
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 === 'updated') {
1015
- this.notice(`${APP_NAME} was updated to ${r.version} (from ${VERSION}); it is loaded by a restart as soon as the session is idle.`);
1016
- if (canRestart()) { this.sourceChanged = true; if (!this.busy) this.maybeAutoReload(); }
1017
- else this.notice('Restart the program to use it.');
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.noProcessExit ? 'default' : 'fullscreen')).toLowerCase();
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 (at most once every few hours), installs it in the background, and the session
3
- // then loads it with a restart once it is idle (the conversation carries over). A git checkout (the
4
- // development copy) is never touched. Off with CLAUDE_PRO_AUTO_UPDATE=0 or DISABLE_AUTOUPDATER=1.
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 CHECK_EVERY_MS = 6 * 3600_000;
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
- const req = https.get(`https://registry.npmjs.org/${PACKAGE_NAME.replace('/', '%2f')}/latest`, { headers: { accept: 'application/json' }, timeout: timeoutMs }, (res) => {
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 and install it. Resolves with {status: 'disabled'|'dev'|'skipped'|'current'|'updated'|'failed', version, error}.
74
- // `force`: check now, whatever the last check was (the /update command).
75
- export async function checkForUpdate({ force = false, env = process.env, onInstalling } = {}) {
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
- if (!force && state.checkedAt && Date.now() - state.checkedAt < CHECK_EVERY_MS) return { status: 'skipped' };
80
- writeState({ ...state, checkedAt: Date.now() });
81
- const latest = await latestVersion();
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
- const r = await runNpm(['install', '-g', `${PACKAGE_NAME}@${latest}`]);
90
- if (!r.ok) return { status: 'failed', version: latest, error: r.output.split('\n').slice(-5).join('\n') };
91
- writeState({ checkedAt: Date.now(), installed: latest });
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
  }
@@ -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);