@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 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 上的最新版(同一台电脑上的所有会话共用这个间隔;查询绕过 npm 的 CDN 缓存),有新版就在后台 `npm install -g --prefer-online @yanzc/claude-pro@新版本`,装好后在会话空闲时自动重启加载,对话保留;同一台电脑上其他开着的会话看到文件变了,也会在空闲时自动重启。所以发布后几分钟内各个客户端就会用上新版(npm 刚发布时安装包要几分钟才能下载,期间安装失败会在 1 分钟后重试)。`/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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yanzc/claude-pro",
3
- "version": "0.5.3",
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 call = (request) => streamMessage(this.config, { model: this.model, tools: this.toolSchemas(), messages, cacheBucket: 'other', ...(this.effort ? { effort: this.effort } : {}), ...request }, { signal, onEvent });
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
- 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/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 is installed in the background (see updater.js) and loaded by a restart once the
1012
- // session is idle. `force`: the /update command, which also says when there is nothing to do.
1013
- // `quiet`: a periodic check, which says nothing when the install fails (it is tried again in a minute).
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('An update is already being installed.'); return; }
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 }, onInstalling: (v) => { this.updating = true; this.flash(`Installing ${APP_NAME} ${v}…`); } });
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 === 'updated') {
1026
- this.notice(`${APP_NAME} was updated to ${r.version} (from ${VERSION}); it is loaded by a restart as soon as the session is idle.`);
1027
- if (canRestart()) { this.sourceChanged = true; if (!this.busy) this.maybeAutoReload(); }
1028
- 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(); }
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), installs it in the background, and the session then loads it with a restart once it is idle (the
4
- // conversation carries over). The other sessions of the machine see the files change and restart too (see
5
- // App.startSourceWatch). A git checkout (the
6
- // development copy) is never touched. Off with CLAUDE_PRO_AUTO_UPDATE=0 or DISABLE_AUTOUPDATER=1.
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 and install it. Resolves with {status: 'disabled'|'dev'|'skipped'|'current'|'updated'|'failed', version, error}.
79
- // `force`: check now, whatever the last check was (the /update command).
80
- 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 } = {}) {
81
120
  if (!force && !autoUpdateEnabled(env)) return { status: 'disabled' };
82
121
  if (!installedByNpm()) return { status: 'dev' };
83
122
  const state = readState();
84
- if (!force && state.checkedAt && Date.now() - state.checkedAt < CHECK_EVERY_MS) return { status: 'skipped' };
85
- writeState({ ...state, checkedAt: Date.now() });
86
- 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
+ }
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({ checkedAt: Date.now(), installed: latest });
162
+ writeState({ ...readState(), installed: latest });
101
163
  return { status: 'updated', version: latest };
102
164
  }