@foxden-app/foxclaw 0.3.12 → 0.3.14

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
  ```
@@ -11,6 +11,14 @@ export interface CodexLocalUsageStats {
11
11
  turns: number;
12
12
  usageEvents: number;
13
13
  totals: CodexLocalUsageTotals;
14
+ outputSpeed: CodexLocalOutputSpeedStats;
14
15
  latestSessionMtimeMs: number | null;
15
16
  }
17
+ export interface CodexLocalOutputSpeedStats {
18
+ samples: number;
19
+ outputTokens: number;
20
+ seconds: number;
21
+ latestTokensPerSecond: number | null;
22
+ latestSampleAtMs: number | null;
23
+ }
16
24
  export declare function readCodexLocalUsageStats(codexHome?: string): Promise<CodexLocalUsageStats>;
@@ -13,6 +13,7 @@ export async function readCodexLocalUsageStats(codexHome = resolveCodexHome()) {
13
13
  let sessionsWithUsage = 0;
14
14
  let usageEvents = 0;
15
15
  let latestSessionMtimeMs = null;
16
+ const outputSpeed = emptyOutputSpeedStats();
16
17
  for (const filePath of sessionFiles) {
17
18
  const stat = await fs.stat(filePath).catch(() => null);
18
19
  if (stat) {
@@ -20,6 +21,7 @@ export async function readCodexLocalUsageStats(codexHome = resolveCodexHome()) {
20
21
  }
21
22
  const fileUsage = await readSessionUsage(filePath, turnIds);
22
23
  usageEvents += fileUsage.usageEvents;
24
+ addOutputSpeed(outputSpeed, fileUsage.outputSpeed);
23
25
  if (fileUsage.totalUsage) {
24
26
  sessionsWithUsage += 1;
25
27
  addUsage(totals, fileUsage.totalUsage);
@@ -31,6 +33,7 @@ export async function readCodexLocalUsageStats(codexHome = resolveCodexHome()) {
31
33
  turns: turnIds.size,
32
34
  usageEvents,
33
35
  totals,
36
+ outputSpeed,
34
37
  latestSessionMtimeMs,
35
38
  };
36
39
  }
@@ -66,6 +69,8 @@ async function readSessionUsage(filePath, turnIds) {
66
69
  const reader = readline.createInterface({ input: stream, crlfDelay: Infinity });
67
70
  let totalUsage = null;
68
71
  let usageEvents = 0;
72
+ let generationStartMs = null;
73
+ const outputSpeed = emptyOutputSpeedStats();
69
74
  try {
70
75
  for await (const line of reader) {
71
76
  if (!line.trim())
@@ -77,13 +82,22 @@ async function readSessionUsage(filePath, turnIds) {
77
82
  catch {
78
83
  continue;
79
84
  }
85
+ const timestampMs = parseTimestampMs(event?.timestamp);
80
86
  const turnId = typeof event?.payload?.turn_id === 'string' ? event.payload.turn_id : null;
81
87
  if (turnId) {
82
88
  turnIds.add(turnId);
83
89
  }
90
+ if (timestampMs !== null && isGenerationBoundary(event)) {
91
+ generationStartMs = timestampMs;
92
+ }
84
93
  const info = event?.payload?.info;
85
- if (info?.last_token_usage || info?.lastTokenUsage) {
94
+ const lastTokenUsage = info?.last_token_usage ?? info?.lastTokenUsage;
95
+ if (lastTokenUsage) {
86
96
  usageEvents += 1;
97
+ addOutputSpeedSample(outputSpeed, lastTokenUsage, generationStartMs, timestampMs);
98
+ if (timestampMs !== null) {
99
+ generationStartMs = timestampMs;
100
+ }
87
101
  }
88
102
  const totalTokenUsage = info?.total_token_usage ?? info?.totalTokenUsage;
89
103
  if (totalTokenUsage) {
@@ -94,7 +108,7 @@ async function readSessionUsage(filePath, turnIds) {
94
108
  finally {
95
109
  reader.close();
96
110
  }
97
- return { usageEvents, totalUsage };
111
+ return { usageEvents, totalUsage, outputSpeed };
98
112
  }
99
113
  function emptyTotals() {
100
114
  return {
@@ -105,6 +119,15 @@ function emptyTotals() {
105
119
  totalTokens: 0,
106
120
  };
107
121
  }
122
+ function emptyOutputSpeedStats() {
123
+ return {
124
+ samples: 0,
125
+ outputTokens: 0,
126
+ seconds: 0,
127
+ latestTokensPerSecond: null,
128
+ latestSampleAtMs: null,
129
+ };
130
+ }
108
131
  function addUsage(totals, usage) {
109
132
  const inputTokens = numberField(usage, 'input_tokens', 'inputTokens');
110
133
  const outputTokens = numberField(usage, 'output_tokens', 'outputTokens');
@@ -115,9 +138,57 @@ function addUsage(totals, usage) {
115
138
  totals.reasoningOutputTokens += numberField(usage, 'reasoning_output_tokens', 'reasoningOutputTokens');
116
139
  totals.totalTokens += totalTokens || inputTokens + outputTokens;
117
140
  }
141
+ function addOutputSpeed(target, source) {
142
+ target.samples += source.samples;
143
+ target.outputTokens += source.outputTokens;
144
+ target.seconds += source.seconds;
145
+ if (source.latestTokensPerSecond !== null
146
+ && source.latestSampleAtMs !== null
147
+ && (target.latestSampleAtMs === null || source.latestSampleAtMs > target.latestSampleAtMs)) {
148
+ target.latestTokensPerSecond = source.latestTokensPerSecond;
149
+ target.latestSampleAtMs = source.latestSampleAtMs;
150
+ }
151
+ }
152
+ function addOutputSpeedSample(stats, usage, generationStartMs, timestampMs) {
153
+ if (generationStartMs === null || timestampMs === null || timestampMs <= generationStartMs) {
154
+ return;
155
+ }
156
+ const outputTokens = numberField(usage, 'output_tokens', 'outputTokens');
157
+ if (outputTokens <= 0) {
158
+ return;
159
+ }
160
+ const seconds = (timestampMs - generationStartMs) / 1000;
161
+ if (!Number.isFinite(seconds) || seconds <= 0) {
162
+ return;
163
+ }
164
+ stats.samples += 1;
165
+ stats.outputTokens += outputTokens;
166
+ stats.seconds += seconds;
167
+ stats.latestTokensPerSecond = outputTokens / seconds;
168
+ stats.latestSampleAtMs = timestampMs;
169
+ }
118
170
  function numberField(source, snakeKey, camelKey) {
119
171
  const snakeValue = source[snakeKey];
120
172
  const value = snakeValue === undefined || snakeValue === null ? source[camelKey] : snakeValue;
121
173
  const numeric = Number(value);
122
174
  return Number.isFinite(numeric) ? numeric : 0;
123
175
  }
176
+ function parseTimestampMs(value) {
177
+ if (typeof value !== 'string' || value.trim().length === 0) {
178
+ return null;
179
+ }
180
+ const timestamp = Date.parse(value);
181
+ return Number.isFinite(timestamp) ? timestamp : null;
182
+ }
183
+ function isGenerationBoundary(event) {
184
+ const payload = event?.payload;
185
+ if (event?.type === 'response_item' && payload?.type === 'function_call_output') {
186
+ return true;
187
+ }
188
+ if (event?.type !== 'event_msg') {
189
+ return false;
190
+ }
191
+ return payload?.type === 'task_started'
192
+ || payload?.type === 'exec_command_end'
193
+ || payload?.type === 'user_message';
194
+ }
@@ -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;
@@ -255,8 +263,10 @@ export declare class BridgeSessionCore {
255
263
  private buildNativeCollaborationMode;
256
264
  private buildCodexUsageStatusLines;
257
265
  private buildCodexLocalUsageStatusLines;
266
+ private formatCodexLocalOutputSpeedStatusLines;
258
267
  private resolveFastStatusLabel;
259
268
  private readCachedCodexLocalUsageStats;
269
+ private sendThreadContextSummary;
260
270
  private handleModelCommand;
261
271
  private handleEffortCommand;
262
272
  private handleThreadOpenCallback;
@@ -4,7 +4,7 @@ import os from 'node:os';
4
4
  import path from 'node:path';
5
5
  import { normalizeLocale, t } from '../i18n.js';
6
6
  import { parseCommand } from './commands.js';
7
- import { buildAccessSettingsKeyboard, buildModelSettingsKeyboard, buildSetupPanelKeyboard, buildThreadListKeyboard, buildThreadsKeyboard, clampEffortToModel, formatAccessPresetLabel, formatActiveTurnMessageModeLabel, formatAccessSettingsMessage, formatApprovalPolicyLabel, formatCollaborationModeLabel, formatModelSettingsMessage, formatSandboxModeLabel, formatServiceTierStatusLabel, formatSetupPanelMessage, formatThreadsMessage, formatWeixinAccessCopyPaste, formatWeixinModelCopyPaste, formatWeixinThreadsCopyPaste, formatWeixinWhereNavCopyPaste, formatWhereMessage, normalizeRequestedEffort, resolveCurrentModel, resolveActiveTurnMessageMode, resolveRequestedModel, } from './presentation.js';
7
+ import { buildAccessSettingsKeyboard, buildModelSettingsKeyboard, buildSetupPanelKeyboard, buildThreadListKeyboard, buildThreadsKeyboard, clampEffortToModel, formatAccessPresetLabel, formatActiveTurnMessageModeLabel, formatAccessSettingsMessage, formatApprovalPolicyLabel, formatCollaborationModeLabel, formatModelSettingsMessage, formatSandboxModeLabel, formatServiceTierStatusLabel, formatSetupPanelMessage, formatThreadContextSummary, formatThreadsMessage, formatWeixinAccessCopyPaste, formatWeixinModelCopyPaste, formatWeixinThreadsCopyPaste, formatWeixinWhereNavCopyPaste, formatWhereMessage, normalizeRequestedEffort, resolveCurrentModel, resolveActiveTurnMessageMode, resolveRequestedModel, } from './presentation.js';
8
8
  import { clampServiceTierToModel, resolveFastTierForModel } from './service_tier.js';
9
9
  import { TELEGRAM_BOT_API_DOWNLOAD_LIMIT_BYTES, buildAttachmentPrompt, isNativeImageAttachment, planAttachmentStoragePath, summarizeTelegramInput, } from '../telegram/media.js';
10
10
  import { TELEGRAM_MESSAGE_LIMIT, chunkTelegramMessage, chunkTelegramStreamMessage, clipTelegramDraftMessage, } from '../telegram/text.js';
@@ -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;
@@ -505,6 +516,7 @@ export class BridgeSessionCore {
505
516
  lines.push(revealError ? t(locale, 'codex_sync_failed', { error: revealError }) : t(locale, 'opened_in_codex'));
506
517
  }
507
518
  await this.sendMessage(scopeId, lines.join('\n'));
519
+ await this.sendThreadContextSummary(scopeId, locale, binding.threadId);
508
520
  return;
509
521
  }
510
522
  case 'watch': {
@@ -831,13 +843,16 @@ export class BridgeSessionCore {
831
843
  const mode = watch.mode;
832
844
  if (mode === 'already') {
833
845
  await this.sendMessage(scopeId, t(locale, 'watch_already_enabled', { threadId: watchedThreadId }));
846
+ await this.sendThreadContextSummary(scopeId, locale, watchedThreadId);
834
847
  return;
835
848
  }
836
849
  if (mode === 'active') {
837
850
  await this.sendMessage(scopeId, t(locale, 'watch_started_active', { threadId: watchedThreadId }));
851
+ await this.sendThreadContextSummary(scopeId, locale, watchedThreadId);
838
852
  return;
839
853
  }
840
854
  await this.sendMessage(scopeId, t(locale, 'watch_started_idle', { threadId: watchedThreadId }));
855
+ await this.sendThreadContextSummary(scopeId, locale, watchedThreadId);
841
856
  }
842
857
  async handleThreadArchiveIndexCommand(scopeId, locale, args) {
843
858
  const index = Number.parseInt(args[0] || '', 10);
@@ -3637,6 +3652,74 @@ export class BridgeSessionCore {
3637
3652
  lines.push(...await this.buildCodexUsageStatusLines(locale));
3638
3653
  await this.sendMessage(scopeId, lines.join('\n'));
3639
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
+ }
3640
3723
  async handleAuthCommand(scopeId, locale, args) {
3641
3724
  const action = args[0]?.toLowerCase() ?? 'list';
3642
3725
  if (action === 'reload' || action === 'restart') {
@@ -4439,6 +4522,7 @@ export class BridgeSessionCore {
4439
4522
  cached: formatTokenCount(stats.totals.cachedInputTokens),
4440
4523
  reasoning: formatTokenCount(stats.totals.reasoningOutputTokens),
4441
4524
  }),
4525
+ ...this.formatCodexLocalOutputSpeedStatusLines(locale, stats),
4442
4526
  ];
4443
4527
  }
4444
4528
  catch (error) {
@@ -4446,6 +4530,18 @@ export class BridgeSessionCore {
4446
4530
  return [t(locale, 'status_codex_local_usage_unavailable', { error: formatShortStatusError(error) })];
4447
4531
  }
4448
4532
  }
4533
+ formatCodexLocalOutputSpeedStatusLines(locale, stats) {
4534
+ const speed = stats.outputSpeed;
4535
+ if (speed.samples === 0 || speed.outputTokens <= 0 || speed.seconds <= 0) {
4536
+ return [];
4537
+ }
4538
+ const avg = speed.outputTokens / speed.seconds;
4539
+ return [t(locale, 'status_codex_local_speed', {
4540
+ avg: formatCompactNumber(avg),
4541
+ latest: speed.latestTokensPerSecond === null ? t(locale, 'unknown') : formatCompactNumber(speed.latestTokensPerSecond),
4542
+ samples: formatTokenCount(speed.samples),
4543
+ })];
4544
+ }
4449
4545
  async resolveFastStatusLabel(locale, settings) {
4450
4546
  try {
4451
4547
  const models = await this.app.listModels();
@@ -4466,6 +4562,22 @@ export class BridgeSessionCore {
4466
4562
  this.localUsageCache = { stats, expiresAt: now + CODEX_LOCAL_USAGE_CACHE_MS };
4467
4563
  return stats;
4468
4564
  }
4565
+ async sendThreadContextSummary(scopeId, locale, threadId) {
4566
+ try {
4567
+ const turns = await this.app.listThreadTurns(threadId, 5);
4568
+ const text = formatThreadContextSummary(locale, turns);
4569
+ if (text) {
4570
+ await this.sendMessage(scopeId, text);
4571
+ }
4572
+ }
4573
+ catch (error) {
4574
+ this.logger.warn('codex.thread_context_summary_failed', {
4575
+ scopeId,
4576
+ threadId,
4577
+ error: formatUserError(error),
4578
+ });
4579
+ }
4580
+ }
4469
4581
  async handleModelCommand(event, locale, args) {
4470
4582
  const scopeId = event.scopeId;
4471
4583
  if (args.length === 0) {
@@ -4627,6 +4739,7 @@ export class BridgeSessionCore {
4627
4739
  callbackText = revealError ? t(locale, 'opened_sync_failed_short') : t(locale, 'opened_in_codex_short');
4628
4740
  }
4629
4741
  await this.messaging.answerCallback(event.callbackQueryId, callbackText);
4742
+ await this.sendThreadContextSummary(scopeId, locale, binding.threadId);
4630
4743
  }
4631
4744
  async handleThreadActionCallback(event, action, threadId, locale) {
4632
4745
  const scopeId = event.scopeId;
@@ -4673,6 +4786,7 @@ export class BridgeSessionCore {
4673
4786
  const watch = await this.watchThread(scopeId, target.chatId, target.chatType, target.topicId, binding);
4674
4787
  await this.showThreadsPanelFromStoredState(scopeId, event.messageId, locale, false);
4675
4788
  await this.messaging.answerCallback(event.callbackQueryId, formatWatchCallbackText(locale, watch.mode, watch.threadId));
4789
+ await this.sendThreadContextSummary(scopeId, locale, watch.threadId);
4676
4790
  return;
4677
4791
  }
4678
4792
  if (action === 'archive') {
@@ -1,4 +1,4 @@
1
- import type { AccessPresetValue, ActiveTurnMessageMode, AppLocale, AppThread, ApprovalPolicyValue, ChatSessionSettings, CollaborationModeValue, ModelInfo, ReasoningEffortValue, SandboxModeValue } from '../types.js';
1
+ import type { AccessPresetValue, ActiveTurnMessageMode, AppLocale, AppThread, AppTurnSnapshot, ApprovalPolicyValue, ChatSessionSettings, CollaborationModeValue, ModelInfo, ReasoningEffortValue, SandboxModeValue } from '../types.js';
2
2
  import type { ResolvedAccessMode } from './access.js';
3
3
  type InlineButton = {
4
4
  text: string;
@@ -60,6 +60,7 @@ pageOffset?: number): string;
60
60
  export declare function formatWeixinModelCopyPaste(locale: AppLocale, models: ModelInfo[], settings: ChatSessionSettings | null): string;
61
61
  export declare function formatWeixinAccessCopyPaste(locale: AppLocale): string;
62
62
  export declare function formatWeixinWhereNavCopyPaste(locale: AppLocale, hasBinding: boolean, defaultCwd?: string): string;
63
+ export declare function formatThreadContextSummary(locale: AppLocale, turns: AppTurnSnapshot[]): string | null;
63
64
  export declare function formatSetupPanelMessage(locale: AppLocale, ctx: SetupPanelContext): string;
64
65
  export declare function buildSetupPanelKeyboard(locale: AppLocale, ctx: SetupPanelContext): InlineButton[][];
65
66
  export declare function resolveSetupSummaryLine(ctx: SetupPanelContext, locale?: AppLocale): string;
@@ -225,6 +225,20 @@ export function formatWeixinWhereNavCopyPaste(locale, hasBinding, defaultCwd) {
225
225
  }
226
226
  return lines.join('\n');
227
227
  }
228
+ export function formatThreadContextSummary(locale, turns) {
229
+ const context = selectRecentThreadContext(turns);
230
+ if (!context) {
231
+ return null;
232
+ }
233
+ return [
234
+ t(locale, 'thread_context_title'),
235
+ t(locale, 'thread_context_user'),
236
+ context.userText || t(locale, 'empty'),
237
+ '',
238
+ t(locale, 'thread_context_codex'),
239
+ context.codexText || t(locale, 'empty'),
240
+ ].join('\n');
241
+ }
228
242
  export function formatSetupPanelMessage(locale, ctx) {
229
243
  const currentModel = resolveCurrentModel(ctx.models, ctx.settings?.model ?? null);
230
244
  const fastTier = resolveFastTierForModel(currentModel);
@@ -393,6 +407,52 @@ function formatFastSetupLabel(locale, supported, enabled, tierName) {
393
407
  function resolveCollaborationMode(mode) {
394
408
  return mode === 'plan' ? 'plan' : 'default';
395
409
  }
410
+ function selectRecentThreadContext(turns) {
411
+ for (const turn of turns) {
412
+ const userText = lastItemText(turn.items, isUserTurnItem);
413
+ const codexText = lastItemText(turn.items, isCodexTurnItem);
414
+ if (userText || codexText) {
415
+ return { userText, codexText };
416
+ }
417
+ }
418
+ return null;
419
+ }
420
+ function lastItemText(items, predicate) {
421
+ for (let index = items.length - 1; index >= 0; index -= 1) {
422
+ const item = items[index];
423
+ if (!predicate(item)) {
424
+ continue;
425
+ }
426
+ const text = normalizeThreadContextText(item.text ?? item.aggregatedOutput ?? item.command ?? '');
427
+ if (text) {
428
+ return text;
429
+ }
430
+ }
431
+ return null;
432
+ }
433
+ function isUserTurnItem(item) {
434
+ const type = normalizeTurnItemType(item.type);
435
+ return type === 'user'
436
+ || type === 'usermessage'
437
+ || type === 'humanmessage'
438
+ || type === 'userinput'
439
+ || type === 'userrequest';
440
+ }
441
+ function isCodexTurnItem(item) {
442
+ const type = normalizeTurnItemType(item.type);
443
+ return type === 'agent'
444
+ || type === 'assistant'
445
+ || type === 'agentmessage'
446
+ || type === 'assistantmessage'
447
+ || type === 'plan';
448
+ }
449
+ function normalizeTurnItemType(value) {
450
+ return value.replace(/[^a-z]/gi, '').toLowerCase();
451
+ }
452
+ function normalizeThreadContextText(value) {
453
+ const trimmed = value.replace(/\r\n/g, '\n').trim().replace(/\n{3,}/g, '\n\n');
454
+ return truncate(trimmed, 1200);
455
+ }
396
456
  function selectedButtonText(selected, label) {
397
457
  return `${selected ? '• ' : ''}${label}`;
398
458
  }
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";
@@ -111,9 +112,16 @@ declare const MESSAGES: {
111
112
  readonly status_codex_usage_unavailable: "Codex usage: unavailable ({error})";
112
113
  readonly status_codex_local_history: "Codex local history: {sessions} sessions, {turns} turns, {events} usage records";
113
114
  readonly status_codex_local_tokens: "Codex local tokens: total {total}; input {input}, output {output}, cached input {cached}, reasoning output {reasoning}";
115
+ readonly status_codex_local_speed: "Codex local output speed: avg {avg} token/s, latest {latest} token/s ({samples} samples)";
114
116
  readonly status_codex_local_usage_unavailable: "Codex local history: unavailable ({error})";
115
117
  readonly status_codex_credits: "Codex credits: {value}";
116
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.";
117
125
  readonly auth_reload_restarting: "Restarting Codex app-server to reload auth...";
118
126
  readonly auth_reload_done: "Codex app-server restarted. Current auth has been reloaded.";
119
127
  readonly auth_reload_blocked_active: "Cannot reload Codex auth while a turn, approval, or question is active. Wait or use /interrupt first.";
@@ -315,6 +323,9 @@ declare const MESSAGES: {
315
323
  readonly threads_filter: "Filter: <code>{searchTerm}</code>";
316
324
  readonly threads_range: "Showing {start}-{end}";
317
325
  readonly threads_filter_cleared_short: "Filter cleared";
326
+ readonly thread_context_title: "Recent context:";
327
+ readonly thread_context_user: "User:";
328
+ readonly thread_context_codex: "Codex:";
318
329
  readonly button_prev_page: "⬅️ Prev";
319
330
  readonly button_next_page: "➡️ Next";
320
331
  readonly button_clear_filter: "🧹 Clear";
@@ -596,6 +607,7 @@ declare const MESSAGES: {
596
607
  readonly cmd_desc_fast: "切换 Fast 模式";
597
608
  readonly cmd_desc_active: "运行中新消息处理方式";
598
609
  readonly cmd_desc_status: "查看桥接状态";
610
+ readonly cmd_desc_update: "升级并重启 FoxClaw";
599
611
  readonly cmd_desc_account: "Codex 账号";
600
612
  readonly cmd_desc_quota: "Codex 用量";
601
613
  readonly cmd_desc_login_device: "ChatGPT 设备登录";
@@ -661,9 +673,16 @@ declare const MESSAGES: {
661
673
  readonly status_codex_usage_unavailable: "Codex 用量:无法获取({error})";
662
674
  readonly status_codex_local_history: "Codex 本地历史:{sessions} 个会话,{turns} 轮,{events} 条用量记录";
663
675
  readonly status_codex_local_tokens: "Codex 本地 Token:总计 {total};输入 {input},输出 {output},缓存输入 {cached},推理输出 {reasoning}";
676
+ readonly status_codex_local_speed: "Codex 本地输出速度:平均 {avg} token/s,最近 {latest} token/s({samples} 个样本)";
664
677
  readonly status_codex_local_usage_unavailable: "Codex 本地历史:无法获取({error})";
665
678
  readonly status_codex_credits: "Codex 额度:{value}";
666
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。";
667
686
  readonly auth_reload_restarting: "正在重启 Codex app-server 以重新读取 auth...";
668
687
  readonly auth_reload_done: "Codex app-server 已重启,当前 auth 已重新读取。";
669
688
  readonly auth_reload_blocked_active: "当前有回复、审批或问题在进行中,不能重载 Codex auth。请先等待,或使用 /interrupt。";
@@ -865,6 +884,9 @@ declare const MESSAGES: {
865
884
  readonly threads_filter: "筛选:<code>{searchTerm}</code>";
866
885
  readonly threads_range: "显示第 {start}-{end} 条";
867
886
  readonly threads_filter_cleared_short: "已清除筛选";
887
+ readonly thread_context_title: "最近上下文:";
888
+ readonly thread_context_user: "用户指令:";
889
+ readonly thread_context_codex: "Codex 最后输出:";
868
890
  readonly button_prev_page: "⬅️ 上一页";
869
891
  readonly button_next_page: "➡️ 下一页";
870
892
  readonly button_clear_filter: "🧹 清除";
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',
@@ -109,9 +110,16 @@ const MESSAGES = {
109
110
  status_codex_usage_unavailable: 'Codex usage: unavailable ({error})',
110
111
  status_codex_local_history: 'Codex local history: {sessions} sessions, {turns} turns, {events} usage records',
111
112
  status_codex_local_tokens: 'Codex local tokens: total {total}; input {input}, output {output}, cached input {cached}, reasoning output {reasoning}',
113
+ status_codex_local_speed: 'Codex local output speed: avg {avg} token/s, latest {latest} token/s ({samples} samples)',
112
114
  status_codex_local_usage_unavailable: 'Codex local history: unavailable ({error})',
113
115
  status_codex_credits: 'Codex credits: {value}',
114
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.',
115
123
  auth_reload_restarting: 'Restarting Codex app-server to reload auth...',
116
124
  auth_reload_done: 'Codex app-server restarted. Current auth has been reloaded.',
117
125
  auth_reload_blocked_active: 'Cannot reload Codex auth while a turn, approval, or question is active. Wait or use /interrupt first.',
@@ -313,6 +321,9 @@ const MESSAGES = {
313
321
  threads_filter: 'Filter: <code>{searchTerm}</code>',
314
322
  threads_range: 'Showing {start}-{end}',
315
323
  threads_filter_cleared_short: 'Filter cleared',
324
+ thread_context_title: 'Recent context:',
325
+ thread_context_user: 'User:',
326
+ thread_context_codex: 'Codex:',
316
327
  button_prev_page: '⬅️ Prev',
317
328
  button_next_page: '➡️ Next',
318
329
  button_clear_filter: '🧹 Clear',
@@ -594,6 +605,7 @@ const MESSAGES = {
594
605
  cmd_desc_fast: '切换 Fast 模式',
595
606
  cmd_desc_active: '运行中新消息处理方式',
596
607
  cmd_desc_status: '查看桥接状态',
608
+ cmd_desc_update: '升级并重启 FoxClaw',
597
609
  cmd_desc_account: 'Codex 账号',
598
610
  cmd_desc_quota: 'Codex 用量',
599
611
  cmd_desc_login_device: 'ChatGPT 设备登录',
@@ -659,9 +671,16 @@ const MESSAGES = {
659
671
  status_codex_usage_unavailable: 'Codex 用量:无法获取({error})',
660
672
  status_codex_local_history: 'Codex 本地历史:{sessions} 个会话,{turns} 轮,{events} 条用量记录',
661
673
  status_codex_local_tokens: 'Codex 本地 Token:总计 {total};输入 {input},输出 {output},缓存输入 {cached},推理输出 {reasoning}',
674
+ status_codex_local_speed: 'Codex 本地输出速度:平均 {avg} token/s,最近 {latest} token/s({samples} 个样本)',
662
675
  status_codex_local_usage_unavailable: 'Codex 本地历史:无法获取({error})',
663
676
  status_codex_credits: 'Codex 额度:{value}',
664
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。',
665
684
  auth_reload_restarting: '正在重启 Codex app-server 以重新读取 auth...',
666
685
  auth_reload_done: 'Codex app-server 已重启,当前 auth 已重新读取。',
667
686
  auth_reload_blocked_active: '当前有回复、审批或问题在进行中,不能重载 Codex auth。请先等待,或使用 /interrupt。',
@@ -863,6 +882,9 @@ const MESSAGES = {
863
882
  threads_filter: '筛选:<code>{searchTerm}</code>',
864
883
  threads_range: '显示第 {start}-{end} 条',
865
884
  threads_filter_cleared_short: '已清除筛选',
885
+ thread_context_title: '最近上下文:',
886
+ thread_context_user: '用户指令:',
887
+ thread_context_codex: 'Codex 最后输出:',
866
888
  button_prev_page: '⬅️ 上一页',
867
889
  button_next_page: '➡️ 下一页',
868
890
  button_clear_filter: '🧹 清除',
@@ -1113,6 +1135,7 @@ export function getTelegramCommands(locale) {
1113
1135
  { command: 'help', description: t(locale, 'cmd_desc_help') },
1114
1136
  { command: 'setup', description: t(locale, 'cmd_desc_setup') },
1115
1137
  { command: 'status', description: t(locale, 'cmd_desc_status') },
1138
+ { command: 'update', description: t(locale, 'cmd_desc_update') },
1116
1139
  { command: 'threads', description: t(locale, 'cmd_desc_threads') },
1117
1140
  { command: 'auth', description: t(locale, 'cmd_desc_auth') },
1118
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, 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('');
@@ -0,0 +1,49 @@
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 resolveSelfUpdateInstaller(entryPoint: string, nodePath?: string, exists?: (target: string) => boolean): SelfUpdateInstaller;
45
+ export declare function readSelfUpdateStatus(statusFile: string): SelfUpdateStatus | null;
46
+ export declare function writeSelfUpdateStatus(statusFile: string, status: SelfUpdateStatus): void;
47
+ export declare function createSelfUpdateRuntime(options: CreateSelfUpdateRuntimeOptions): SelfUpdateRuntime;
48
+ export declare function performSelfUpdate(options: PerformSelfUpdateOptions): SelfUpdateOutcome;
49
+ export {};
package/dist/update.js ADDED
@@ -0,0 +1,203 @@
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 resolveSelfUpdateInstaller(entryPoint, nodePath = process.execPath, exists = fs.existsSync) {
11
+ const normalizedEntryPoint = entryPoint.replace(/\\/g, '/');
12
+ const globalMarker = '/global/';
13
+ const globalIndex = normalizedEntryPoint.indexOf(globalMarker);
14
+ if (globalIndex > 0 && normalizedEntryPoint.includes('/.pnpm/')) {
15
+ const pnpmHome = normalizedEntryPoint.slice(0, globalIndex);
16
+ const pnpmCommand = path.join(pnpmHome, process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm');
17
+ if (!exists(pnpmCommand)) {
18
+ throw new Error(`Current installation is managed by pnpm, but pnpm was not found at ${pnpmCommand}.`);
19
+ }
20
+ return {
21
+ manager: 'pnpm',
22
+ command: pnpmCommand,
23
+ installArgs: ['add', '--global', PACKAGE_SPEC],
24
+ rootArgs: ['root', '--global'],
25
+ };
26
+ }
27
+ const adjacentNpm = path.join(path.dirname(nodePath), process.platform === 'win32' ? 'npm.cmd' : 'npm');
28
+ return {
29
+ manager: 'npm',
30
+ command: exists(adjacentNpm) ? adjacentNpm : (process.platform === 'win32' ? 'npm.cmd' : 'npm'),
31
+ installArgs: ['install', '--global', PACKAGE_SPEC],
32
+ rootArgs: ['root', '--global'],
33
+ };
34
+ }
35
+ export function readSelfUpdateStatus(statusFile) {
36
+ try {
37
+ const parsed = JSON.parse(fs.readFileSync(statusFile, 'utf8'));
38
+ if ((parsed.state !== 'pending' && parsed.state !== 'succeeded' && parsed.state !== 'failed')
39
+ || typeof parsed.scopeId !== 'string'
40
+ || (parsed.locale !== 'en' && parsed.locale !== 'zh')
41
+ || typeof parsed.fromVersion !== 'string'
42
+ || typeof parsed.updatedAt !== 'string') {
43
+ return null;
44
+ }
45
+ return {
46
+ state: parsed.state,
47
+ scopeId: parsed.scopeId,
48
+ locale: parsed.locale,
49
+ fromVersion: parsed.fromVersion,
50
+ toVersion: typeof parsed.toVersion === 'string' ? parsed.toVersion : null,
51
+ error: typeof parsed.error === 'string' ? parsed.error : null,
52
+ updatedAt: parsed.updatedAt,
53
+ };
54
+ }
55
+ catch {
56
+ return null;
57
+ }
58
+ }
59
+ export function writeSelfUpdateStatus(statusFile, status) {
60
+ fs.mkdirSync(path.dirname(statusFile), { recursive: true });
61
+ const temporaryFile = `${statusFile}.${process.pid}.tmp`;
62
+ fs.writeFileSync(temporaryFile, `${JSON.stringify(status, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
63
+ fs.renameSync(temporaryFile, statusFile);
64
+ }
65
+ export function createSelfUpdateRuntime(options) {
66
+ const statusFile = selfUpdateStatusPath(options.statusPath);
67
+ return {
68
+ async launch(scopeId, locale) {
69
+ const current = readSelfUpdateStatus(statusFile);
70
+ if (current?.state === 'pending') {
71
+ throw new Error('A FoxClaw update is already running.');
72
+ }
73
+ writeSelfUpdateStatus(statusFile, {
74
+ state: 'pending',
75
+ scopeId,
76
+ locale,
77
+ fromVersion: options.version,
78
+ toVersion: null,
79
+ error: null,
80
+ updatedAt: new Date().toISOString(),
81
+ });
82
+ fs.mkdirSync(path.dirname(options.logPath), { recursive: true });
83
+ const logFd = fs.openSync(options.logPath, 'a', 0o600);
84
+ try {
85
+ const child = spawn(options.nodePath, [options.entryPoint, 'update', '--notification-file', statusFile], {
86
+ detached: true,
87
+ stdio: ['ignore', logFd, logFd],
88
+ env: process.env,
89
+ });
90
+ child.unref();
91
+ }
92
+ catch (error) {
93
+ writeSelfUpdateStatus(statusFile, {
94
+ state: 'failed',
95
+ scopeId,
96
+ locale,
97
+ fromVersion: options.version,
98
+ toVersion: null,
99
+ error: formatError(error),
100
+ updatedAt: new Date().toISOString(),
101
+ });
102
+ throw error;
103
+ }
104
+ finally {
105
+ fs.closeSync(logFd);
106
+ }
107
+ },
108
+ async readStatus() {
109
+ return readSelfUpdateStatus(statusFile);
110
+ },
111
+ async clearStatus() {
112
+ fs.rmSync(statusFile, { force: true });
113
+ },
114
+ };
115
+ }
116
+ export function performSelfUpdate(options) {
117
+ const env = options.env ?? process.env;
118
+ let toVersion = null;
119
+ try {
120
+ const installer = resolveSelfUpdateInstaller(options.entryPoint, options.nodePath);
121
+ console.log(`[UPDATE] Installing ${PACKAGE_SPEC} with ${installer.manager}...`);
122
+ runInherited(installer.command, installer.installArgs, env);
123
+ const updatedEntryPoint = resolveUpdatedEntryPoint(installer, env);
124
+ toVersion = readInstalledPackageVersion(updatedEntryPoint);
125
+ console.log('[UPDATE] Running checks and restarting the FoxClaw service...');
126
+ runInherited(options.nodePath, [updatedEntryPoint, 'start'], env);
127
+ completeNotification(options.notificationFile, 'succeeded', toVersion, null);
128
+ console.log(`[OK] FoxClaw updated and restarted: ${options.version} -> ${toVersion}`);
129
+ return {
130
+ ok: true,
131
+ fromVersion: options.version,
132
+ toVersion,
133
+ error: null,
134
+ };
135
+ }
136
+ catch (error) {
137
+ const message = formatError(error);
138
+ completeNotification(options.notificationFile, 'failed', toVersion, message);
139
+ console.error(`[FAIL] FoxClaw update failed: ${message}`);
140
+ return {
141
+ ok: false,
142
+ fromVersion: options.version,
143
+ toVersion,
144
+ error: message,
145
+ };
146
+ }
147
+ }
148
+ function runInherited(command, args, env) {
149
+ const result = spawnSync(command, args, { stdio: 'inherit', env });
150
+ if (result.error) {
151
+ throw result.error;
152
+ }
153
+ if (result.status !== 0) {
154
+ throw new Error(`${command} ${args.join(' ')} exited with status ${result.status ?? 'unknown'}.`);
155
+ }
156
+ }
157
+ function resolveUpdatedEntryPoint(installer, env) {
158
+ const result = spawnSync(installer.command, installer.rootArgs, { encoding: 'utf8', env });
159
+ if (result.error) {
160
+ throw result.error;
161
+ }
162
+ if (result.status !== 0) {
163
+ throw new Error(`Could not locate the updated global package root using ${installer.manager}.`);
164
+ }
165
+ const globalRoot = result.stdout.trim().split(/\r?\n/).filter(Boolean).at(-1);
166
+ if (!globalRoot) {
167
+ throw new Error(`Could not locate the updated global package root using ${installer.manager}.`);
168
+ }
169
+ const updatedEntryPoint = path.join(globalRoot, '@foxden-app', 'foxclaw', 'dist', 'main.js');
170
+ if (!fs.existsSync(updatedEntryPoint)) {
171
+ throw new Error(`Updated FoxClaw entry point was not found at ${updatedEntryPoint}.`);
172
+ }
173
+ return updatedEntryPoint;
174
+ }
175
+ function readInstalledPackageVersion(updatedEntryPoint) {
176
+ try {
177
+ const packageFile = path.resolve(path.dirname(updatedEntryPoint), '..', 'package.json');
178
+ const pkg = JSON.parse(fs.readFileSync(packageFile, 'utf8'));
179
+ return typeof pkg.version === 'string' && pkg.version.trim() ? pkg.version : 'unknown';
180
+ }
181
+ catch {
182
+ return 'unknown';
183
+ }
184
+ }
185
+ function completeNotification(notificationFile, state, toVersion, error) {
186
+ if (!notificationFile) {
187
+ return;
188
+ }
189
+ const pending = readSelfUpdateStatus(notificationFile);
190
+ if (!pending) {
191
+ return;
192
+ }
193
+ writeSelfUpdateStatus(notificationFile, {
194
+ ...pending,
195
+ state,
196
+ toVersion,
197
+ error,
198
+ updatedAt: new Date().toISOString(),
199
+ });
200
+ }
201
+ function formatError(error) {
202
+ return error instanceof Error ? error.message : String(error);
203
+ }
@@ -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.12",
3
+ "version": "0.3.14",
4
4
  "description": "Foxden local execution claw for controlling Codex from trusted chat interfaces.",
5
5
  "type": "module",
6
6
  "main": "dist/main.js",