@foxden-app/foxclaw 0.3.13 → 0.3.15

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
@@ -84,7 +84,7 @@ DEFAULT_SANDBOX_MODE=workspace-write
84
84
 
85
85
  配置文件默认在 `~/.foxclaw/.env`。想放别处的话设 `FOXCLAW_ENV=/path/to/.env`。
86
86
 
87
- `foxclaw start` 会自动检查环境并安装/重启后台服务,幂等操作,升级后再跑一次就行。
87
+ `foxclaw start` 会自动检查环境并安装/重启后台服务。后续升级直接运行 `foxclaw update`,它会沿用当前的 npm/pnpm 全局安装方式,完成安装、自检和服务重启。
88
88
 
89
89
  FoxClaw 只响应 `TG_ALLOWED_USER_ID` 的消息——把机器人拉进群不代表群里所有人都能用。
90
90
 
@@ -150,6 +150,7 @@ journalctl --user -u foxclaw.service -f
150
150
  ```bash
151
151
  foxclaw status
152
152
  foxclaw restart
153
+ foxclaw update
153
154
  foxclaw stop
154
155
  ```
155
156
 
@@ -238,7 +239,7 @@ FoxClaw 会把 `codex app-server` 作为 detached 子进程启动,记录其 pi
238
239
  - `/setup` — 统一设置面板
239
240
  - `/fast <on|off|toggle>`
240
241
  - `/active <steer|queue>`
241
- - `/status`、`/account`、`/quota`
242
+ - `/status`、`/account`、`/quota`、`/update`
242
243
  - `/quota_nudge <credits|usage_limit> confirm`
243
244
  - `/login_device`、`/login_cancel [id]`、`/logout confirm`
244
245
  - `/auth [list|use <n>|enable <n>|disable <n>|reload|add <name>]`
@@ -290,6 +291,7 @@ foxclaw doctor
290
291
  foxclaw status
291
292
  foxclaw start
292
293
  foxclaw restart
294
+ foxclaw update
293
295
  foxclaw stop
294
296
  foxclaw uninstall-systemd
295
297
  ```
package/README_EN.md CHANGED
@@ -84,7 +84,7 @@ DEFAULT_SANDBOX_MODE=workspace-write
84
84
 
85
85
  The default config file is `~/.foxclaw/.env`. Set `FOXCLAW_ENV=/path/to/.env` if you want to keep it somewhere else.
86
86
 
87
- `foxclaw start` runs checks and installs or restarts the background service. It is idempotent run it again after upgrading.
87
+ `foxclaw start` runs checks and installs or restarts the background service. For later upgrades, run `foxclaw update`; it preserves the current npm/pnpm global-install method, runs checks, and restarts the service.
88
88
 
89
89
  FoxClaw accepts messages only from `TG_ALLOWED_USER_ID`. Putting the bot in a group does not make it available to every group member.
90
90
 
@@ -150,6 +150,7 @@ You can also use the wrapper commands:
150
150
  ```bash
151
151
  foxclaw status
152
152
  foxclaw restart
153
+ foxclaw update
153
154
  foxclaw stop
154
155
  ```
155
156
 
@@ -238,7 +239,7 @@ No static Codex app-server port is required in normal installs.
238
239
  - `/setup` — unified preference panel
239
240
  - `/fast <on|off|toggle>`
240
241
  - `/active <steer|queue>`
241
- - `/status`, `/account`, `/quota`
242
+ - `/status`, `/account`, `/quota`, `/update`
242
243
  - `/quota_nudge <credits|usage_limit> confirm`
243
244
  - `/login_device`, `/login_cancel [id]`, `/logout confirm`
244
245
  - `/auth [list|use <n>|enable <n>|disable <n>|reload|add <name>]`
@@ -290,6 +291,7 @@ foxclaw doctor
290
291
  foxclaw status
291
292
  foxclaw start
292
293
  foxclaw restart
294
+ foxclaw update
293
295
  foxclaw stop
294
296
  foxclaw uninstall-systemd
295
297
  ```
@@ -5,12 +5,14 @@ import type { RuntimeStatus } from '../types.js';
5
5
  import type { TelegramGateway, TelegramTextEvent } from '../telegram/gateway.js';
6
6
  import { BridgeMessagingRouter } from '../channels/bridge_messaging_router.js';
7
7
  import type { CodexAppClient } from '../codex_app/client.js';
8
+ import type { SelfUpdateRuntime } from '../update.js';
8
9
  export declare class BridgeSessionCore {
9
10
  private readonly config;
10
11
  private readonly store;
11
12
  private readonly logger;
12
13
  private readonly bot;
13
14
  private readonly app;
15
+ private readonly selfUpdater;
14
16
  private activeTurns;
15
17
  private activeTurnsByTurnId;
16
18
  private observedThreadWatchers;
@@ -38,13 +40,14 @@ export declare class BridgeSessionCore {
38
40
  private locks;
39
41
  private approvalTimers;
40
42
  private submittedUserInputTimers;
43
+ private selfUpdatePollTimer;
41
44
  private attachedThreads;
42
45
  private botUsername;
43
46
  private lastError;
44
47
  /** Last threads-panel pagination state per scope (Telegram inline nav + /open index alignment). */
45
48
  private threadListPresentationState;
46
49
  private readonly messaging;
47
- constructor(config: AppConfig, store: BridgeStore, logger: Logger, bot: TelegramGateway, app: CodexAppClient, outbound: BridgeMessagingRouter);
50
+ constructor(config: AppConfig, store: BridgeStore, logger: Logger, bot: TelegramGateway, app: CodexAppClient, outbound: BridgeMessagingRouter, selfUpdater?: SelfUpdateRuntime | null);
48
51
  /** Wire Telegram inbound events. Call before {@link startCodexApp}. */
49
52
  registerTelegramInboundHandlers(): void;
50
53
  /**
@@ -203,6 +206,11 @@ export declare class BridgeSessionCore {
203
206
  private handleFastCommand;
204
207
  private setCollaborationMode;
205
208
  private handleAuthReloadCommand;
209
+ private handleSelfUpdateCommand;
210
+ private scheduleSelfUpdateStatusPoll;
211
+ private clearSelfUpdateStatusPoll;
212
+ private pollSelfUpdateStatus;
213
+ private formatSelfUpdateResult;
206
214
  private handleAuthCommand;
207
215
  private handleAuthUseCommand;
208
216
  private handleAuthToggleCommand;
@@ -25,6 +25,7 @@ const OBSERVED_CLI_USER_LABEL = 'codex-cli-user';
25
25
  const DEFAULT_COLLABORATION_MODE = 'default';
26
26
  const CODEX_LOCAL_USAGE_CACHE_MS = 30_000;
27
27
  const USER_INPUT_SUBMITTED_NOTICE_MS = 90_000;
28
+ const SELF_UPDATE_STATUS_POLL_MS = 1000;
28
29
  const PLAN_IMPLEMENTATION_CODING_MESSAGE = 'Implement the plan.';
29
30
  const PLAN_IMPLEMENTATION_CLEAR_CONTEXT_PREFIX = 'A previous agent produced the plan below to accomplish the user\'s task. Implement the plan in a fresh context. Treat the plan as the source of user intent, re-read files as needed, and carry the work through implementation and verification.';
30
31
  const PINNED_HELP_COMMANDS = [
@@ -39,6 +40,7 @@ const DYNAMIC_HELP_COMMANDS = [
39
40
  { key: 'active', line: '/active <steer|queue>' },
40
41
  { key: 'account', line: '/account' },
41
42
  { key: 'quota', line: '/quota' },
43
+ { key: 'update', line: '/update' },
42
44
  { key: 'login_device', line: '/login_device' },
43
45
  { key: 'threads_archived', line: '/threads archived [query]' },
44
46
  { key: 'open', line: '/open <n>' },
@@ -88,6 +90,7 @@ export class BridgeSessionCore {
88
90
  logger;
89
91
  bot;
90
92
  app;
93
+ selfUpdater;
91
94
  activeTurns = new Map();
92
95
  activeTurnsByTurnId = new Map();
93
96
  observedThreadWatchers = new Map();
@@ -115,18 +118,20 @@ export class BridgeSessionCore {
115
118
  locks = new Map();
116
119
  approvalTimers = new Map();
117
120
  submittedUserInputTimers = new Map();
121
+ selfUpdatePollTimer = null;
118
122
  attachedThreads = new Set();
119
123
  botUsername = null;
120
124
  lastError = null;
121
125
  /** Last threads-panel pagination state per scope (Telegram inline nav + /open index alignment). */
122
126
  threadListPresentationState = new Map();
123
127
  messaging;
124
- constructor(config, store, logger, bot, app, outbound) {
128
+ constructor(config, store, logger, bot, app, outbound, selfUpdater = null) {
125
129
  this.config = config;
126
130
  this.store = store;
127
131
  this.logger = logger;
128
132
  this.bot = bot;
129
133
  this.app = app;
134
+ this.selfUpdater = selfUpdater;
130
135
  this.messaging = outbound;
131
136
  }
132
137
  /** Wire Telegram inbound events. Call before {@link startCodexApp}. */
@@ -188,6 +193,7 @@ export class BridgeSessionCore {
188
193
  await this.bot.start();
189
194
  this.botUsername = this.bot.username;
190
195
  this.updateStatus();
196
+ this.scheduleSelfUpdateStatusPoll(0);
191
197
  }
192
198
  /** Telegram-only default startup (single channel). */
193
199
  async start() {
@@ -223,6 +229,7 @@ export class BridgeSessionCore {
223
229
  clearTimeout(timer);
224
230
  }
225
231
  this.submittedUserInputTimers.clear();
232
+ this.clearSelfUpdateStatusPoll();
226
233
  await this.app.stop({ terminateServer: false });
227
234
  this.updateStatus();
228
235
  }
@@ -382,6 +389,10 @@ export class BridgeSessionCore {
382
389
  await this.handleQuotaCommand(scopeId, locale);
383
390
  return;
384
391
  }
392
+ case 'update': {
393
+ await this.handleSelfUpdateCommand(scopeId, locale);
394
+ return;
395
+ }
385
396
  case 'quota_nudge': {
386
397
  await this.handleQuotaNudgeCommand(scopeId, locale, args);
387
398
  return;
@@ -3641,6 +3652,74 @@ export class BridgeSessionCore {
3641
3652
  lines.push(...await this.buildCodexUsageStatusLines(locale));
3642
3653
  await this.sendMessage(scopeId, lines.join('\n'));
3643
3654
  }
3655
+ async handleSelfUpdateCommand(scopeId, locale) {
3656
+ if (!this.selfUpdater) {
3657
+ await this.sendMessage(scopeId, t(locale, 'update_unavailable'));
3658
+ return;
3659
+ }
3660
+ if (this.activeTurns.size > 0 || this.store.countPendingApprovals() > 0 || this.pendingUserInputs.size > 0 || this.pendingMcpElicitations.size > 0) {
3661
+ await this.sendMessage(scopeId, t(locale, 'update_blocked_active'));
3662
+ return;
3663
+ }
3664
+ const status = await this.selfUpdater.readStatus();
3665
+ if (status?.state === 'pending') {
3666
+ await this.sendMessage(scopeId, t(locale, 'update_already_running'));
3667
+ this.scheduleSelfUpdateStatusPoll();
3668
+ return;
3669
+ }
3670
+ if (status) {
3671
+ await this.selfUpdater.clearStatus();
3672
+ }
3673
+ await this.sendMessage(scopeId, t(locale, 'update_started'));
3674
+ try {
3675
+ await this.selfUpdater.launch(scopeId, locale);
3676
+ this.scheduleSelfUpdateStatusPoll();
3677
+ }
3678
+ catch (error) {
3679
+ await this.selfUpdater.clearStatus().catch(() => undefined);
3680
+ await this.sendMessage(scopeId, t(locale, 'update_failed', { error: formatUserError(error) }));
3681
+ }
3682
+ }
3683
+ scheduleSelfUpdateStatusPoll(delay = SELF_UPDATE_STATUS_POLL_MS) {
3684
+ if (!this.selfUpdater || this.selfUpdatePollTimer) {
3685
+ return;
3686
+ }
3687
+ this.selfUpdatePollTimer = setTimeout(() => {
3688
+ this.selfUpdatePollTimer = null;
3689
+ void this.pollSelfUpdateStatus().catch((error) => {
3690
+ this.logger.error('self_update.poll_failed', { error: toErrorMeta(error) });
3691
+ this.scheduleSelfUpdateStatusPoll();
3692
+ });
3693
+ }, delay);
3694
+ }
3695
+ clearSelfUpdateStatusPoll() {
3696
+ if (!this.selfUpdatePollTimer) {
3697
+ return;
3698
+ }
3699
+ clearTimeout(this.selfUpdatePollTimer);
3700
+ this.selfUpdatePollTimer = null;
3701
+ }
3702
+ async pollSelfUpdateStatus() {
3703
+ const status = await this.selfUpdater?.readStatus();
3704
+ if (!status) {
3705
+ return;
3706
+ }
3707
+ if (status.state === 'pending') {
3708
+ this.scheduleSelfUpdateStatusPoll();
3709
+ return;
3710
+ }
3711
+ await this.sendMessage(status.scopeId, this.formatSelfUpdateResult(status));
3712
+ await this.selfUpdater?.clearStatus();
3713
+ }
3714
+ formatSelfUpdateResult(status) {
3715
+ if (status.state === 'succeeded') {
3716
+ return t(status.locale, 'update_succeeded', {
3717
+ from: status.fromVersion,
3718
+ to: status.toVersion ?? t(status.locale, 'unknown'),
3719
+ });
3720
+ }
3721
+ return t(status.locale, 'update_failed', { error: status.error ?? t(status.locale, 'unknown') });
3722
+ }
3644
3723
  async handleAuthCommand(scopeId, locale, args) {
3645
3724
  const action = args[0]?.toLowerCase() ?? 'list';
3646
3725
  if (action === 'reload' || action === 'restart') {
package/dist/i18n.d.ts CHANGED
@@ -46,6 +46,7 @@ declare const MESSAGES: {
46
46
  readonly cmd_desc_fast: "Toggle Fast mode";
47
47
  readonly cmd_desc_active: "Active-turn message behavior";
48
48
  readonly cmd_desc_status: "Bridge status";
49
+ readonly cmd_desc_update: "Update and restart FoxClaw";
49
50
  readonly cmd_desc_account: "Codex account";
50
51
  readonly cmd_desc_quota: "Codex quota";
51
52
  readonly cmd_desc_login_device: "ChatGPT device login";
@@ -115,6 +116,12 @@ declare const MESSAGES: {
115
116
  readonly status_codex_local_usage_unavailable: "Codex local history: unavailable ({error})";
116
117
  readonly status_codex_credits: "Codex credits: {value}";
117
118
  readonly status_codex_limit_reached: "Codex limit: {value}";
119
+ readonly update_started: "FoxClaw update started. I will report here after installation, checks, and service restart complete.";
120
+ readonly update_succeeded: "FoxClaw updated and restarted: {from} -> {to}.";
121
+ readonly update_failed: "FoxClaw update failed: {error}\nRun foxclaw update in a terminal for details.";
122
+ readonly update_unavailable: "Self-update is unavailable in this runtime. Run foxclaw update in a terminal.";
123
+ readonly update_already_running: "A FoxClaw update is already running. I will report here when it finishes.";
124
+ readonly update_blocked_active: "Cannot update FoxClaw while a turn, approval, or question is active. Wait or use /interrupt first.";
118
125
  readonly auth_reload_restarting: "Restarting Codex app-server to reload auth...";
119
126
  readonly auth_reload_done: "Codex app-server restarted. Current auth has been reloaded.";
120
127
  readonly auth_reload_blocked_active: "Cannot reload Codex auth while a turn, approval, or question is active. Wait or use /interrupt first.";
@@ -600,6 +607,7 @@ declare const MESSAGES: {
600
607
  readonly cmd_desc_fast: "切换 Fast 模式";
601
608
  readonly cmd_desc_active: "运行中新消息处理方式";
602
609
  readonly cmd_desc_status: "查看桥接状态";
610
+ readonly cmd_desc_update: "升级并重启 FoxClaw";
603
611
  readonly cmd_desc_account: "Codex 账号";
604
612
  readonly cmd_desc_quota: "Codex 用量";
605
613
  readonly cmd_desc_login_device: "ChatGPT 设备登录";
@@ -669,6 +677,12 @@ declare const MESSAGES: {
669
677
  readonly status_codex_local_usage_unavailable: "Codex 本地历史:无法获取({error})";
670
678
  readonly status_codex_credits: "Codex 额度:{value}";
671
679
  readonly status_codex_limit_reached: "Codex 限制:{value}";
680
+ readonly update_started: "已开始升级 FoxClaw。安装、自检和服务重启完成后,我会在这里回报结果。";
681
+ readonly update_succeeded: "FoxClaw 已升级并重启:{from} -> {to}。";
682
+ readonly update_failed: "FoxClaw 升级失败:{error}\n请在终端运行 foxclaw update 查看详情。";
683
+ readonly update_unavailable: "当前运行方式不支持自升级,请在终端运行 foxclaw update。";
684
+ readonly update_already_running: "FoxClaw 升级已经在进行中,结束后我会在这里回报结果。";
685
+ readonly update_blocked_active: "当前有回复、审批或问题在进行中,不能升级 FoxClaw。请先等待,或使用 /interrupt。";
672
686
  readonly auth_reload_restarting: "正在重启 Codex app-server 以重新读取 auth...";
673
687
  readonly auth_reload_done: "Codex app-server 已重启,当前 auth 已重新读取。";
674
688
  readonly auth_reload_blocked_active: "当前有回复、审批或问题在进行中,不能重载 Codex auth。请先等待,或使用 /interrupt。";
package/dist/i18n.js CHANGED
@@ -44,6 +44,7 @@ const MESSAGES = {
44
44
  cmd_desc_fast: 'Toggle Fast mode',
45
45
  cmd_desc_active: 'Active-turn message behavior',
46
46
  cmd_desc_status: 'Bridge status',
47
+ cmd_desc_update: 'Update and restart FoxClaw',
47
48
  cmd_desc_account: 'Codex account',
48
49
  cmd_desc_quota: 'Codex quota',
49
50
  cmd_desc_login_device: 'ChatGPT device login',
@@ -113,6 +114,12 @@ const MESSAGES = {
113
114
  status_codex_local_usage_unavailable: 'Codex local history: unavailable ({error})',
114
115
  status_codex_credits: 'Codex credits: {value}',
115
116
  status_codex_limit_reached: 'Codex limit: {value}',
117
+ update_started: 'FoxClaw update started. I will report here after installation, checks, and service restart complete.',
118
+ update_succeeded: 'FoxClaw updated and restarted: {from} -> {to}.',
119
+ update_failed: 'FoxClaw update failed: {error}\nRun foxclaw update in a terminal for details.',
120
+ update_unavailable: 'Self-update is unavailable in this runtime. Run foxclaw update in a terminal.',
121
+ update_already_running: 'A FoxClaw update is already running. I will report here when it finishes.',
122
+ update_blocked_active: 'Cannot update FoxClaw while a turn, approval, or question is active. Wait or use /interrupt first.',
116
123
  auth_reload_restarting: 'Restarting Codex app-server to reload auth...',
117
124
  auth_reload_done: 'Codex app-server restarted. Current auth has been reloaded.',
118
125
  auth_reload_blocked_active: 'Cannot reload Codex auth while a turn, approval, or question is active. Wait or use /interrupt first.',
@@ -598,6 +605,7 @@ const MESSAGES = {
598
605
  cmd_desc_fast: '切换 Fast 模式',
599
606
  cmd_desc_active: '运行中新消息处理方式',
600
607
  cmd_desc_status: '查看桥接状态',
608
+ cmd_desc_update: '升级并重启 FoxClaw',
601
609
  cmd_desc_account: 'Codex 账号',
602
610
  cmd_desc_quota: 'Codex 用量',
603
611
  cmd_desc_login_device: 'ChatGPT 设备登录',
@@ -667,6 +675,12 @@ const MESSAGES = {
667
675
  status_codex_local_usage_unavailable: 'Codex 本地历史:无法获取({error})',
668
676
  status_codex_credits: 'Codex 额度:{value}',
669
677
  status_codex_limit_reached: 'Codex 限制:{value}',
678
+ update_started: '已开始升级 FoxClaw。安装、自检和服务重启完成后,我会在这里回报结果。',
679
+ update_succeeded: 'FoxClaw 已升级并重启:{from} -> {to}。',
680
+ update_failed: 'FoxClaw 升级失败:{error}\n请在终端运行 foxclaw update 查看详情。',
681
+ update_unavailable: '当前运行方式不支持自升级,请在终端运行 foxclaw update。',
682
+ update_already_running: 'FoxClaw 升级已经在进行中,结束后我会在这里回报结果。',
683
+ update_blocked_active: '当前有回复、审批或问题在进行中,不能升级 FoxClaw。请先等待,或使用 /interrupt。',
670
684
  auth_reload_restarting: '正在重启 Codex app-server 以重新读取 auth...',
671
685
  auth_reload_done: 'Codex app-server 已重启,当前 auth 已重新读取。',
672
686
  auth_reload_blocked_active: '当前有回复、审批或问题在进行中,不能重载 Codex auth。请先等待,或使用 /interrupt。',
@@ -1121,6 +1135,7 @@ export function getTelegramCommands(locale) {
1121
1135
  { command: 'help', description: t(locale, 'cmd_desc_help') },
1122
1136
  { command: 'setup', description: t(locale, 'cmd_desc_setup') },
1123
1137
  { command: 'status', description: t(locale, 'cmd_desc_status') },
1138
+ { command: 'update', description: t(locale, 'cmd_desc_update') },
1124
1139
  { command: 'threads', description: t(locale, 'cmd_desc_threads') },
1125
1140
  { command: 'auth', description: t(locale, 'cmd_desc_auth') },
1126
1141
  { command: 'fast', description: t(locale, 'cmd_desc_fast') },
package/dist/main.js CHANGED
@@ -9,6 +9,7 @@ import { APP_HOME, DEFAULT_ENV_PATH, DEFAULT_LOG_PATH, DEFAULT_STATUS_PATH, getL
9
9
  import { acquireProcessLock, LockHeldError } from './lock.js';
10
10
  import { readRuntimeStatus, writeRuntimeStatus } from './runtime.js';
11
11
  import { refreshFoxclawExecStartDropIns, removeFoxclawExecStartDropIns } from './systemd.js';
12
+ import { createSelfUpdateRuntime, inferPnpmHomeFromEntryPoint, performSelfUpdate } from './update.js';
12
13
  const rawCommand = process.argv[2];
13
14
  const command = rawCommand || 'serve';
14
15
  loadEnv();
@@ -53,6 +54,18 @@ async function main() {
53
54
  startService('restart');
54
55
  return;
55
56
  }
57
+ if (command === 'update') {
58
+ requireNode24(command);
59
+ const notificationFile = readOptionValue('--notification-file');
60
+ const options = {
61
+ entryPoint,
62
+ nodePath: process.execPath,
63
+ version: readPackageVersion(),
64
+ ...(notificationFile ? { notificationFile } : {}),
65
+ };
66
+ const outcome = performSelfUpdate(options);
67
+ process.exit(outcome.ok ? 0 : 1);
68
+ }
56
69
  if (command === 'stop') {
57
70
  stopService();
58
71
  return;
@@ -116,6 +129,7 @@ Usage:
116
129
  foxclaw doctor
117
130
  foxclaw status
118
131
  foxclaw start|restart|stop
132
+ foxclaw update
119
133
  foxclaw install-systemd|uninstall-systemd
120
134
  foxclaw install-launchd
121
135
  foxclaw weixin-login [account-id]
@@ -152,7 +166,14 @@ async function runServeCli() {
152
166
  ? new WeixinMessagingPort(store, (id) => loadWeixinAccount(config.weixinAccountsDir, id))
153
167
  : null;
154
168
  const outbound = new BridgeMessagingRouter(telegramMessaging, weixinMessaging);
155
- const core = new BridgeSessionCore(config, store, logger, bot, app, outbound);
169
+ const selfUpdater = createSelfUpdateRuntime({
170
+ entryPoint,
171
+ nodePath: process.execPath,
172
+ version: readPackageVersion(),
173
+ statusPath: config.statusPath,
174
+ logPath: path.join(APP_HOME, 'logs', 'update.log'),
175
+ });
176
+ const core = new BridgeSessionCore(config, store, logger, bot, app, outbound, selfUpdater);
156
177
  const telegram = new TelegramChannelAdapter(core);
157
178
  if (config.wxEnabled) {
158
179
  weixinAdapter = new WeixinChannelAdapter(core, store, config, logger);
@@ -436,6 +457,11 @@ function escapeRegExp(value) {
436
457
  function editorCommand(envPath) {
437
458
  return `${process.env.EDITOR?.trim() || '$EDITOR'} ${envPath}`;
438
459
  }
460
+ function readOptionValue(option) {
461
+ const index = process.argv.indexOf(option);
462
+ const value = index >= 0 ? process.argv[index + 1] : undefined;
463
+ return value && !value.startsWith('--') ? value : undefined;
464
+ }
439
465
  function startService(action) {
440
466
  if (!runDoctorChecks()) {
441
467
  console.error('');
@@ -750,9 +776,17 @@ function stopLaunchd() {
750
776
  console.log(`Stopped ${plist}`);
751
777
  }
752
778
  function buildServicePath(nodeDir) {
779
+ const pnpmPath = resolveCommand('pnpm');
780
+ const inferredPnpmHome = inferPnpmHomeFromEntryPoint(entryPoint) || '';
781
+ const configuredPnpmHome = process.env.PNPM_HOME?.trim() || '';
753
782
  const parts = [
754
783
  path.join(process.env.HOME || '', '.local', 'bin'),
755
784
  nodeDir,
785
+ inferredPnpmHome,
786
+ inferredPnpmHome ? path.join(inferredPnpmHome, 'bin') : '',
787
+ configuredPnpmHome,
788
+ configuredPnpmHome ? path.join(configuredPnpmHome, 'bin') : '',
789
+ pnpmPath ? path.dirname(pnpmPath) : '',
756
790
  '/usr/local/sbin',
757
791
  '/usr/local/bin',
758
792
  '/usr/sbin',
@@ -0,0 +1,50 @@
1
+ import type { AppLocale } from './types.js';
2
+ export type SelfUpdateState = 'pending' | 'succeeded' | 'failed';
3
+ export interface SelfUpdateStatus {
4
+ state: SelfUpdateState;
5
+ scopeId: string;
6
+ locale: AppLocale;
7
+ fromVersion: string;
8
+ toVersion: string | null;
9
+ error: string | null;
10
+ updatedAt: string;
11
+ }
12
+ export interface SelfUpdateRuntime {
13
+ launch(scopeId: string, locale: AppLocale): Promise<void>;
14
+ readStatus(): Promise<SelfUpdateStatus | null>;
15
+ clearStatus(): Promise<void>;
16
+ }
17
+ export interface SelfUpdateInstaller {
18
+ manager: 'npm' | 'pnpm';
19
+ command: string;
20
+ installArgs: string[];
21
+ rootArgs: string[];
22
+ }
23
+ interface CreateSelfUpdateRuntimeOptions {
24
+ entryPoint: string;
25
+ nodePath: string;
26
+ version: string;
27
+ statusPath: string;
28
+ logPath: string;
29
+ }
30
+ interface PerformSelfUpdateOptions {
31
+ entryPoint: string;
32
+ nodePath: string;
33
+ version: string;
34
+ notificationFile?: string;
35
+ env?: NodeJS.ProcessEnv;
36
+ }
37
+ export interface SelfUpdateOutcome {
38
+ ok: boolean;
39
+ fromVersion: string;
40
+ toVersion: string | null;
41
+ error: string | null;
42
+ }
43
+ export declare function selfUpdateStatusPath(statusPath: string): string;
44
+ export declare function inferPnpmHomeFromEntryPoint(entryPoint: string): string | null;
45
+ export declare function resolveSelfUpdateInstaller(entryPoint: string, nodePath?: string, exists?: (target: string) => boolean, env?: NodeJS.ProcessEnv): SelfUpdateInstaller;
46
+ export declare function readSelfUpdateStatus(statusFile: string): SelfUpdateStatus | null;
47
+ export declare function writeSelfUpdateStatus(statusFile: string, status: SelfUpdateStatus): void;
48
+ export declare function createSelfUpdateRuntime(options: CreateSelfUpdateRuntimeOptions): SelfUpdateRuntime;
49
+ export declare function performSelfUpdate(options: PerformSelfUpdateOptions): SelfUpdateOutcome;
50
+ export {};
package/dist/update.js ADDED
@@ -0,0 +1,251 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import process from 'node:process';
4
+ import { spawn, spawnSync } from 'node:child_process';
5
+ const PACKAGE_SPEC = '@foxden-app/foxclaw@latest';
6
+ const UPDATE_STATUS_FILENAME = 'self-update.json';
7
+ export function selfUpdateStatusPath(statusPath) {
8
+ return path.join(path.dirname(statusPath), UPDATE_STATUS_FILENAME);
9
+ }
10
+ export function inferPnpmHomeFromEntryPoint(entryPoint) {
11
+ const normalizedEntryPoint = entryPoint.replace(/\\/g, '/');
12
+ const globalMarker = '/global/';
13
+ const globalIndex = normalizedEntryPoint.indexOf(globalMarker);
14
+ if (globalIndex <= 0 || !normalizedEntryPoint.includes('/.pnpm/')) {
15
+ return null;
16
+ }
17
+ return normalizedEntryPoint.slice(0, globalIndex);
18
+ }
19
+ export function resolveSelfUpdateInstaller(entryPoint, nodePath = process.execPath, exists = fs.existsSync, env = process.env) {
20
+ const pnpmHome = inferPnpmHomeFromEntryPoint(entryPoint);
21
+ if (pnpmHome) {
22
+ const commandName = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm';
23
+ const candidates = executableCandidates(commandName, nodePath, env, [
24
+ path.join(pnpmHome, commandName),
25
+ path.join(pnpmHome, 'bin', commandName),
26
+ env.PNPM_HOME?.trim() ? path.join(env.PNPM_HOME.trim(), commandName) : '',
27
+ env.PNPM_HOME?.trim() ? path.join(env.PNPM_HOME.trim(), 'bin', commandName) : '',
28
+ ]);
29
+ const pnpmCommand = candidates.find((candidate) => exists(candidate));
30
+ if (pnpmCommand) {
31
+ return {
32
+ manager: 'pnpm',
33
+ command: pnpmCommand,
34
+ installArgs: ['add', '--global', PACKAGE_SPEC],
35
+ rootArgs: ['root', '--global'],
36
+ };
37
+ }
38
+ const npmCommandName = process.platform === 'win32' ? 'npm.cmd' : 'npm';
39
+ const npmCommand = executableCandidates(npmCommandName, nodePath, env)
40
+ .find((candidate) => exists(candidate)) ?? npmCommandName;
41
+ return {
42
+ manager: 'pnpm',
43
+ command: npmCommand,
44
+ installArgs: ['exec', '--yes', '--package=pnpm@latest', '--', 'pnpm', 'add', '--global', PACKAGE_SPEC],
45
+ rootArgs: ['exec', '--yes', '--package=pnpm@latest', '--', 'pnpm', 'root', '--global'],
46
+ };
47
+ }
48
+ const adjacentNpm = path.join(path.dirname(nodePath), process.platform === 'win32' ? 'npm.cmd' : 'npm');
49
+ return {
50
+ manager: 'npm',
51
+ command: exists(adjacentNpm) ? adjacentNpm : (process.platform === 'win32' ? 'npm.cmd' : 'npm'),
52
+ installArgs: ['install', '--global', PACKAGE_SPEC],
53
+ rootArgs: ['root', '--global'],
54
+ };
55
+ }
56
+ export function readSelfUpdateStatus(statusFile) {
57
+ try {
58
+ const parsed = JSON.parse(fs.readFileSync(statusFile, 'utf8'));
59
+ if ((parsed.state !== 'pending' && parsed.state !== 'succeeded' && parsed.state !== 'failed')
60
+ || typeof parsed.scopeId !== 'string'
61
+ || (parsed.locale !== 'en' && parsed.locale !== 'zh')
62
+ || typeof parsed.fromVersion !== 'string'
63
+ || typeof parsed.updatedAt !== 'string') {
64
+ return null;
65
+ }
66
+ return {
67
+ state: parsed.state,
68
+ scopeId: parsed.scopeId,
69
+ locale: parsed.locale,
70
+ fromVersion: parsed.fromVersion,
71
+ toVersion: typeof parsed.toVersion === 'string' ? parsed.toVersion : null,
72
+ error: typeof parsed.error === 'string' ? parsed.error : null,
73
+ updatedAt: parsed.updatedAt,
74
+ };
75
+ }
76
+ catch {
77
+ return null;
78
+ }
79
+ }
80
+ export function writeSelfUpdateStatus(statusFile, status) {
81
+ fs.mkdirSync(path.dirname(statusFile), { recursive: true });
82
+ const temporaryFile = `${statusFile}.${process.pid}.tmp`;
83
+ fs.writeFileSync(temporaryFile, `${JSON.stringify(status, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
84
+ fs.renameSync(temporaryFile, statusFile);
85
+ }
86
+ export function createSelfUpdateRuntime(options) {
87
+ const statusFile = selfUpdateStatusPath(options.statusPath);
88
+ return {
89
+ async launch(scopeId, locale) {
90
+ const current = readSelfUpdateStatus(statusFile);
91
+ if (current?.state === 'pending') {
92
+ throw new Error('A FoxClaw update is already running.');
93
+ }
94
+ writeSelfUpdateStatus(statusFile, {
95
+ state: 'pending',
96
+ scopeId,
97
+ locale,
98
+ fromVersion: options.version,
99
+ toVersion: null,
100
+ error: null,
101
+ updatedAt: new Date().toISOString(),
102
+ });
103
+ fs.mkdirSync(path.dirname(options.logPath), { recursive: true });
104
+ const logFd = fs.openSync(options.logPath, 'a', 0o600);
105
+ try {
106
+ const child = spawn(options.nodePath, [options.entryPoint, 'update', '--notification-file', statusFile], {
107
+ detached: true,
108
+ stdio: ['ignore', logFd, logFd],
109
+ env: process.env,
110
+ });
111
+ child.unref();
112
+ }
113
+ catch (error) {
114
+ writeSelfUpdateStatus(statusFile, {
115
+ state: 'failed',
116
+ scopeId,
117
+ locale,
118
+ fromVersion: options.version,
119
+ toVersion: null,
120
+ error: formatError(error),
121
+ updatedAt: new Date().toISOString(),
122
+ });
123
+ throw error;
124
+ }
125
+ finally {
126
+ fs.closeSync(logFd);
127
+ }
128
+ },
129
+ async readStatus() {
130
+ return readSelfUpdateStatus(statusFile);
131
+ },
132
+ async clearStatus() {
133
+ fs.rmSync(statusFile, { force: true });
134
+ },
135
+ };
136
+ }
137
+ export function performSelfUpdate(options) {
138
+ const env = options.env ?? process.env;
139
+ let toVersion = null;
140
+ try {
141
+ const installer = resolveSelfUpdateInstaller(options.entryPoint, options.nodePath, fs.existsSync, env);
142
+ const installerEnv = buildInstallerEnv(options.entryPoint, installer, env);
143
+ console.log(`[UPDATE] Installing ${PACKAGE_SPEC} with ${installer.manager}...`);
144
+ runInherited(installer.command, installer.installArgs, installerEnv);
145
+ const updatedEntryPoint = resolveUpdatedEntryPoint(installer, installerEnv);
146
+ toVersion = readInstalledPackageVersion(updatedEntryPoint);
147
+ console.log('[UPDATE] Running checks and restarting the FoxClaw service...');
148
+ runInherited(options.nodePath, [updatedEntryPoint, 'start'], installerEnv);
149
+ completeNotification(options.notificationFile, 'succeeded', toVersion, null);
150
+ console.log(`[OK] FoxClaw updated and restarted: ${options.version} -> ${toVersion}`);
151
+ return {
152
+ ok: true,
153
+ fromVersion: options.version,
154
+ toVersion,
155
+ error: null,
156
+ };
157
+ }
158
+ catch (error) {
159
+ const message = formatError(error);
160
+ completeNotification(options.notificationFile, 'failed', toVersion, message);
161
+ console.error(`[FAIL] FoxClaw update failed: ${message}`);
162
+ return {
163
+ ok: false,
164
+ fromVersion: options.version,
165
+ toVersion,
166
+ error: message,
167
+ };
168
+ }
169
+ }
170
+ function executableCandidates(commandName, nodePath, env, preferred = []) {
171
+ return [
172
+ ...preferred,
173
+ path.join(path.dirname(nodePath), commandName),
174
+ ...(env.PATH || '').split(path.delimiter).filter(Boolean).map((dir) => path.join(dir, commandName)),
175
+ ].filter((candidate, index, all) => candidate && all.indexOf(candidate) === index);
176
+ }
177
+ function buildInstallerEnv(entryPoint, installer, env) {
178
+ const pnpmHome = installer.manager === 'pnpm' ? inferPnpmHomeFromEntryPoint(entryPoint) : null;
179
+ if (!pnpmHome) {
180
+ return env;
181
+ }
182
+ const configuredPnpmHome = env.PNPM_HOME?.trim() || pnpmHome;
183
+ const pathEntries = [
184
+ configuredPnpmHome,
185
+ path.join(configuredPnpmHome, 'bin'),
186
+ pnpmHome,
187
+ path.join(pnpmHome, 'bin'),
188
+ ...(env.PATH || '').split(path.delimiter).filter(Boolean),
189
+ ];
190
+ return {
191
+ ...env,
192
+ PNPM_HOME: configuredPnpmHome,
193
+ PATH: pathEntries.filter((entry, index, all) => all.indexOf(entry) === index).join(path.delimiter),
194
+ };
195
+ }
196
+ function runInherited(command, args, env) {
197
+ const result = spawnSync(command, args, { stdio: 'inherit', env });
198
+ if (result.error) {
199
+ throw result.error;
200
+ }
201
+ if (result.status !== 0) {
202
+ throw new Error(`${command} ${args.join(' ')} exited with status ${result.status ?? 'unknown'}.`);
203
+ }
204
+ }
205
+ function resolveUpdatedEntryPoint(installer, env) {
206
+ const result = spawnSync(installer.command, installer.rootArgs, { encoding: 'utf8', env });
207
+ if (result.error) {
208
+ throw result.error;
209
+ }
210
+ if (result.status !== 0) {
211
+ throw new Error(`Could not locate the updated global package root using ${installer.manager}.`);
212
+ }
213
+ const globalRoot = result.stdout.trim().split(/\r?\n/).filter(Boolean).at(-1);
214
+ if (!globalRoot) {
215
+ throw new Error(`Could not locate the updated global package root using ${installer.manager}.`);
216
+ }
217
+ const updatedEntryPoint = path.join(globalRoot, '@foxden-app', 'foxclaw', 'dist', 'main.js');
218
+ if (!fs.existsSync(updatedEntryPoint)) {
219
+ throw new Error(`Updated FoxClaw entry point was not found at ${updatedEntryPoint}.`);
220
+ }
221
+ return updatedEntryPoint;
222
+ }
223
+ function readInstalledPackageVersion(updatedEntryPoint) {
224
+ try {
225
+ const packageFile = path.resolve(path.dirname(updatedEntryPoint), '..', 'package.json');
226
+ const pkg = JSON.parse(fs.readFileSync(packageFile, 'utf8'));
227
+ return typeof pkg.version === 'string' && pkg.version.trim() ? pkg.version : 'unknown';
228
+ }
229
+ catch {
230
+ return 'unknown';
231
+ }
232
+ }
233
+ function completeNotification(notificationFile, state, toVersion, error) {
234
+ if (!notificationFile) {
235
+ return;
236
+ }
237
+ const pending = readSelfUpdateStatus(notificationFile);
238
+ if (!pending) {
239
+ return;
240
+ }
241
+ writeSelfUpdateStatus(notificationFile, {
242
+ ...pending,
243
+ state,
244
+ toVersion,
245
+ error,
246
+ updatedAt: new Date().toISOString(),
247
+ });
248
+ }
249
+ function formatError(error) {
250
+ return error instanceof Error ? error.message : String(error);
251
+ }
@@ -277,10 +277,11 @@ foxclaw uninstall-systemd
277
277
  Update FoxClaw later:
278
278
 
279
279
  ```bash
280
- npm install -g @foxden-app/foxclaw@latest
281
- foxclaw start
280
+ foxclaw update
282
281
  ```
283
282
 
283
+ You can also send `/update` in an authorized Telegram chat. When no turn, approval, or question is active, it upgrades, checks, restarts the service, and reports the result after restart.
284
+
284
285
  ## Next Step
285
286
 
286
287
  After the first install works, read the [User Manual](./user-manual.md) for `/help`, `/setup`, `/threads`, `/watch`, `/auth`, Codex login, and multi-account auth rotation.
@@ -223,7 +223,7 @@ Then run:
223
223
  foxclaw restart
224
224
  ```
225
225
 
226
- FoxClaw writes proxychains into the main service and removes stale FoxClaw `ExecStart` overrides, so later upgrades still only need the normal `pnpm install -g` and `foxclaw restart`.
226
+ FoxClaw writes proxychains into the main service and removes stale FoxClaw `ExecStart` overrides, so later upgrades only need `foxclaw update`.
227
227
 
228
228
  ## Service Starts With The Wrong Node Version
229
229
 
@@ -136,6 +136,8 @@ foxclaw start
136
136
  foxclaw status
137
137
  ```
138
138
 
139
+ For later upgrades, run `foxclaw update`. It uses the npm or pnpm installation method currently managing FoxClaw, upgrades globally, runs checks, and restarts the background service.
140
+
139
141
  Linux service logs:
140
142
 
141
143
  ```bash
@@ -226,11 +228,12 @@ For group messages:
226
228
 
227
229
  Later commands are sorted by recent usage. Plain text, photos, and files continue the currently bound thread; if no thread is bound, FoxClaw creates one.
228
230
 
229
- ### 3.2 `/status`, `/account`, `/quota`
231
+ ### 3.2 `/status`, `/account`, `/quota`, `/update`
230
232
 
231
233
  - `/status`: FoxClaw, app-server, current thread binding, model, access, and Codex usage summary.
232
234
  - `/account`: current Codex account.
233
235
  - `/quota`: Codex usage and quota window.
236
+ - `/update`: upgrade FoxClaw, run checks, and restart the service; it refuses while a turn, approval, or question is active, then reports the result after restart.
234
237
 
235
238
  ### 3.3 `/config`, `/requirements`, `/provider`
236
239
 
@@ -275,10 +275,11 @@ foxclaw uninstall-systemd
275
275
  以后升级 FoxClaw:
276
276
 
277
277
  ```bash
278
- npm install -g @foxden-app/foxclaw@latest
279
- foxclaw start
278
+ foxclaw update
280
279
  ```
281
280
 
281
+ 也可以在已授权的 Telegram 私聊里发送 `/update`。它会在当前没有运行中回复、审批或待确认问题时,完成升级、自检和服务重启,并在重启后回报结果。
282
+
282
283
  如果 `~/.foxclaw/.env` 已经存在,`foxclaw init` 会先询问是否更新 Telegram 和工作目录相关字段,其它配置保持不变。
283
284
 
284
285
  ## 鸣谢
@@ -224,7 +224,7 @@ FOXCLAW_PROXYCHAINS_CONF=/home/wuya/.proxychains-rt.conf
224
224
  foxclaw restart
225
225
  ```
226
226
 
227
- FoxClaw 会把 proxychains 写进主 service,并清理旧的 FoxClaw `ExecStart` 覆盖,后续升级仍然只需要正常 `pnpm install -g` 和 `foxclaw restart`。
227
+ FoxClaw 会把 proxychains 写进主 service,并清理旧的 FoxClaw `ExecStart` 覆盖,后续升级直接运行 `foxclaw update` 即可。
228
228
 
229
229
  ## 服务用了错误的 Node 版本
230
230
 
@@ -136,6 +136,8 @@ foxclaw start
136
136
  foxclaw status
137
137
  ```
138
138
 
139
+ 后续升级只需运行 `foxclaw update`;它会使用当前安装 FoxClaw 的 npm 或 pnpm,全局升级后执行自检并重启后台服务。
140
+
139
141
  Linux 查看服务日志:
140
142
 
141
143
  ```bash
@@ -226,11 +228,12 @@ TG_ALLOWED_TOPIC_ID=42
226
228
 
227
229
  后面的命令会按你最近使用情况排序。直接发送普通文本、图片或文件时,FoxClaw 会继续当前绑定线程;如果没有绑定线程,会自动新建线程。
228
230
 
229
- ### 3.2 `/status`、`/account`、`/quota`
231
+ ### 3.2 `/status`、`/account`、`/quota`、`/update`
230
232
 
231
233
  - `/status`:查看 FoxClaw、app-server、当前绑定线程、模型、权限和 Codex 用量摘要。
232
234
  - `/account`:查看当前 Codex 登录账号。
233
235
  - `/quota`:查看 Codex 用量和额度窗口。
236
+ - `/update`:升级 FoxClaw、自检并重启服务;当前有运行中回复、审批或待确认问题时会拒绝执行,重启后会回报结果。
234
237
 
235
238
  ### 3.3 `/config`、`/requirements`、`/provider`
236
239
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@foxden-app/foxclaw",
3
- "version": "0.3.13",
3
+ "version": "0.3.15",
4
4
  "description": "Foxden local execution claw for controlling Codex from trusted chat interfaces.",
5
5
  "type": "module",
6
6
  "main": "dist/main.js",