@yanzc/claude-pro 0.5.3 → 0.5.5
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 +6 -1
- package/package.json +1 -1
- package/src/agent.js +15 -3
- package/src/peers.js +26 -1
- package/src/ui/repl.js +94 -15
- package/src/updater.js +73 -11
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 全局安装的版本在启动时、以及运行中**每 5 分钟**查一次 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
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yanzc/claude-pro",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.5",
|
|
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/agent.js
CHANGED
|
@@ -7,7 +7,7 @@ import { streamMessage, contextTokens, isAbort } from './api.js';
|
|
|
7
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
|
-
import { CacheStats } from './cache.js';
|
|
10
|
+
import { CacheStats, TTL_MS } from './cache.js';
|
|
11
11
|
import { resolvePath } from './utils/paths.js';
|
|
12
12
|
import { fitImagesIn } from './utils/images.js';
|
|
13
13
|
|
|
@@ -359,6 +359,14 @@ export class Agent {
|
|
|
359
359
|
return out;
|
|
360
360
|
}
|
|
361
361
|
|
|
362
|
+
// Is the conversation's prompt cache known to have run out? From the last request of this process when there was
|
|
363
|
+
// one, else (a resumed or restarted conversation) from when the last message was added, which is after its
|
|
364
|
+
// request started: that guess errs towards warm. Not knowing counts as warm.
|
|
365
|
+
cacheExpired(now = Date.now()) {
|
|
366
|
+
if (this.cache.expected != null) return now >= this.cache.expiresAt;
|
|
367
|
+
return this.lastActivityAt > 0 && now - this.lastActivityAt >= TTL_MS[this.config.cacheTtl('main')];
|
|
368
|
+
}
|
|
369
|
+
|
|
362
370
|
// A new request from the user goes back to the effort the user chose, after the Effort tool changed it for
|
|
363
371
|
// the one before. A request that a restart continues keeps it.
|
|
364
372
|
endEffortBoost() {
|
|
@@ -728,8 +736,12 @@ export class Agent {
|
|
|
728
736
|
// the biggest request there is). Only if that does not give a summary (the model went on calling tools, or
|
|
729
737
|
// the request is too long by the size of the instruction) the plainer request is made: a short system prompt
|
|
730
738
|
// and no effort, which cannot share the cache, but is smaller and cannot be led astray by the main prompt.
|
|
739
|
+
// The messages are marked for the cache only where that can be read: no request after the summary starts with
|
|
740
|
+
// this history, so writing it (25% over the plain input price) pays only for reading what is already there.
|
|
741
|
+
// When the cache is known to have run out, and in the plain request, only the tools and system prompt are marked.
|
|
731
742
|
async requestSummary(messages, { signal, onEvent }) {
|
|
732
|
-
const
|
|
743
|
+
const cacheMessages = !this.cacheExpired();
|
|
744
|
+
const call = (request) => streamMessage(this.config, { model: this.model, tools: this.toolSchemas(), messages, cacheBucket: 'other', cacheMessages, ...(this.effort ? { effort: this.effort } : {}), ...request }, { signal, onEvent });
|
|
733
745
|
try {
|
|
734
746
|
const res = await call({ system: this.currentSystem() });
|
|
735
747
|
this.recordUsage(res, 'summary');
|
|
@@ -737,7 +749,7 @@ export class Agent {
|
|
|
737
749
|
} catch (err) {
|
|
738
750
|
if (isAbort(err) || signal?.aborted || !err.promptTooLong) throw err;
|
|
739
751
|
}
|
|
740
|
-
const res = await call({ system: SUMMARY_SYSTEM, effort: null });
|
|
752
|
+
const res = await call({ system: SUMMARY_SYSTEM, effort: null, cacheMessages: false });
|
|
741
753
|
this.recordUsage({ ...res, cache: { ...res.cache, caching: false } }, 'summary'); // made to miss: not a miss to report
|
|
742
754
|
return res;
|
|
743
755
|
}
|
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/ui/repl.js
CHANGED
|
@@ -31,7 +31,7 @@ import { readClipboardImage, imageFromFile, watchClipboard, writeClipboard, read
|
|
|
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, CHECK_EVERY_MS } 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';
|
|
@@ -400,7 +400,7 @@ export class App {
|
|
|
400
400
|
async startPeer() {
|
|
401
401
|
if (this.driven || !peersEnabled()) return;
|
|
402
402
|
try {
|
|
403
|
-
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();
|
|
404
404
|
} catch (e) {
|
|
405
405
|
this.peers = null;
|
|
406
406
|
this.notice(`Other sessions cannot reach this one: ${e.message}`, 'warn');
|
|
@@ -886,8 +886,15 @@ export class App {
|
|
|
886
886
|
// ---------------------------------------------------------------- restarting (see restart.js)
|
|
887
887
|
|
|
888
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
|
+
|
|
889
895
|
restartBlockers() {
|
|
890
896
|
const found = [];
|
|
897
|
+
for (const t of this.transfers.values()) found.push(`files on their way ${t.direction === 'up' ? 'to' : 'from'} &${t.label}`);
|
|
891
898
|
for (const t of this.runtime.tasks.running()) found.push(`background agent ${t.id} "${t.description}"`);
|
|
892
899
|
for (const s of listBackgroundShells()) if (s.status === 'running') found.push(`background shell ${s.id} (${String(s.command).slice(0, 40)})`);
|
|
893
900
|
return found;
|
|
@@ -985,6 +992,13 @@ export class App {
|
|
|
985
992
|
if (!canRestart() || /^(0|false|no|off)$/i.test(this.config.env.CLAUDE_PRO_AUTO_RESTART || '')) return;
|
|
986
993
|
this.sourceWatch = watchSource(INSTALL_DIR, (file) => {
|
|
987
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
|
+
}
|
|
988
1002
|
if (!this.busy) this.maybeAutoReload();
|
|
989
1003
|
});
|
|
990
1004
|
}
|
|
@@ -996,7 +1010,7 @@ export class App {
|
|
|
996
1010
|
const changed = this.sourceChanged;
|
|
997
1011
|
this.sourceChanged = null;
|
|
998
1012
|
this.reloading = true;
|
|
999
|
-
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…');
|
|
1000
1014
|
let check;
|
|
1001
1015
|
try { check = await runSelfCheck(); } finally { this.reloading = false; }
|
|
1002
1016
|
if (this.exiting) return;
|
|
@@ -1005,27 +1019,34 @@ export class App {
|
|
|
1005
1019
|
return;
|
|
1006
1020
|
}
|
|
1007
1021
|
if (this.busy || this.dialogs.length) { this.sourceChanged = changed; return; } // something started meanwhile: later
|
|
1008
|
-
this.requestRestart({ reason: 'source changed', force: true });
|
|
1022
|
+
this.requestRestart({ reason: this.updateRestart ? `updated to ${APP_NAME} ${this.updateRestart}` : 'source changed', force: true });
|
|
1009
1023
|
}
|
|
1010
1024
|
|
|
1011
|
-
// A newer version on npm
|
|
1012
|
-
//
|
|
1013
|
-
// `quiet`:
|
|
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).
|
|
1014
1029
|
async autoUpdate({ force = false, quiet = false } = {}) {
|
|
1015
|
-
if (this.updating) { if (force) this.notice(
|
|
1030
|
+
if (this.updating) { if (force) this.notice(`${APP_NAME} ${this.updating} is being installed.`); return; }
|
|
1016
1031
|
let r;
|
|
1017
1032
|
try {
|
|
1018
|
-
r = await checkForUpdate({ force, env: { ...process.env, ...this.config.env }
|
|
1033
|
+
r = await checkForUpdate({ force, env: { ...process.env, ...this.config.env } });
|
|
1019
1034
|
} catch (e) {
|
|
1020
1035
|
r = { status: 'failed', error: e.message };
|
|
1021
|
-
} finally {
|
|
1022
|
-
this.updating = false;
|
|
1023
1036
|
}
|
|
1024
1037
|
if (this.exiting) return;
|
|
1025
|
-
if (r.status === '
|
|
1026
|
-
this.
|
|
1027
|
-
|
|
1028
|
-
|
|
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(); }
|
|
1029
1050
|
} else if (r.status === 'failed') {
|
|
1030
1051
|
if (force || (r.version && !quiet)) this.notice(`Could not update ${APP_NAME}${r.version ? ` to ${r.version}` : ''}: ${r.error}`, 'warn');
|
|
1031
1052
|
} else if (force) {
|
|
@@ -1033,6 +1054,58 @@ export class App {
|
|
|
1033
1054
|
}
|
|
1034
1055
|
}
|
|
1035
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
|
+
|
|
1036
1109
|
printWelcome() {
|
|
1037
1110
|
const cfg = this.config;
|
|
1038
1111
|
const w = Math.min(this.width - 1, 76);
|
|
@@ -1501,6 +1574,7 @@ export class App {
|
|
|
1501
1574
|
this.runAgent(this.agent.drainNotifications());
|
|
1502
1575
|
} else {
|
|
1503
1576
|
this.maybeAutoReload();
|
|
1577
|
+
this.maybeInstallUpdate();
|
|
1504
1578
|
}
|
|
1505
1579
|
}
|
|
1506
1580
|
|
|
@@ -2081,6 +2155,11 @@ export class App {
|
|
|
2081
2155
|
left += c.dim(' · ') + c.green(`⇄ ${this.remote.viewers} watching`);
|
|
2082
2156
|
}
|
|
2083
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
|
+
}
|
|
2084
2163
|
const cfg = this.config;
|
|
2085
2164
|
const threshold = cfg.autoCompactThreshold(cfg.mainModel);
|
|
2086
2165
|
const used = this.agent.contextEstimate;
|
package/src/updater.js
CHANGED
|
@@ -1,9 +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
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
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
// development copy) is never touched. Off with
|
|
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.
|
|
7
8
|
|
|
8
9
|
import fs from 'node:fs';
|
|
9
10
|
import path from 'node:path';
|
|
@@ -14,6 +15,8 @@ import { CONFIG_DIR, INSTALL_DIR, VERSION } from './config.js';
|
|
|
14
15
|
// The name on npm (scoped: npm refused plain `claude-pro` as too close to another package's name).
|
|
15
16
|
export const PACKAGE_NAME = JSON.parse(fs.readFileSync(path.join(INSTALL_DIR, 'package.json'), 'utf8')).name;
|
|
16
17
|
const STATE_FILE = path.join(CONFIG_DIR, 'update-check.json');
|
|
18
|
+
const LOCK_FILE = path.join(CONFIG_DIR, 'update.lock');
|
|
19
|
+
const LOCK_STALE_MS = 15 * 60_000;
|
|
17
20
|
export const CHECK_EVERY_MS = 5 * 60_000; // a release reaches the running sessions within minutes
|
|
18
21
|
const RETRY_FAILED_MS = 60_000; // a release's tarball can take minutes to be downloadable after it is published
|
|
19
22
|
|
|
@@ -42,6 +45,40 @@ export function isNewer(latest, current) {
|
|
|
42
45
|
return false;
|
|
43
46
|
}
|
|
44
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
|
+
|
|
45
82
|
function readState() { try { return JSON.parse(fs.readFileSync(STATE_FILE, 'utf8')); } catch { return {}; } }
|
|
46
83
|
function writeState(s) { try { fs.mkdirSync(CONFIG_DIR, { recursive: true }); fs.writeFileSync(STATE_FILE, JSON.stringify(s)); } catch { /* ignore */ } }
|
|
47
84
|
|
|
@@ -75,16 +112,41 @@ function runNpm(args, { timeoutMs = 300_000 } = {}) {
|
|
|
75
112
|
});
|
|
76
113
|
}
|
|
77
114
|
|
|
78
|
-
// Look for a newer version
|
|
79
|
-
//
|
|
80
|
-
|
|
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 } = {}) {
|
|
81
120
|
if (!force && !autoUpdateEnabled(env)) return { status: 'disabled' };
|
|
82
121
|
if (!installedByNpm()) return { status: 'dev' };
|
|
83
122
|
const state = readState();
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
+
}
|
|
87
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) {
|
|
88
150
|
// Only a global install is updated by `npm i -g`: one in a project's node_modules belongs to that project.
|
|
89
151
|
const root = await runNpm(['root', '-g'], { timeoutMs: 30_000 });
|
|
90
152
|
if (!root.ok || path.resolve(root.output.split(/\r?\n/).pop()) !== path.resolve(modulesDir())) {
|
|
@@ -97,6 +159,6 @@ export async function checkForUpdate({ force = false, env = process.env, onInsta
|
|
|
97
159
|
writeState({ ...readState(), checkedAt: Date.now() - CHECK_EVERY_MS + RETRY_FAILED_MS }); // again in a minute
|
|
98
160
|
return { status: 'failed', version: latest, error: r.output.split('\n').slice(-5).join('\n') };
|
|
99
161
|
}
|
|
100
|
-
writeState({
|
|
162
|
+
writeState({ ...readState(), installed: latest });
|
|
101
163
|
return { status: 'updated', version: latest };
|
|
102
164
|
}
|