@foxden-app/foxclaw 0.3.11 → 0.3.13

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/.env.example CHANGED
@@ -29,12 +29,15 @@ TELEGRAM_PREVIEW_THROTTLE_MS=800
29
29
  THREAD_LIST_LIMIT=10
30
30
  CODEX_CLI_BIN=/absolute/path/to/codex
31
31
 
32
- # Optional: proxy for ChatGPT/Codex backend requests when FoxClaw runs as a service.
32
+ # Optional: standard HTTP(S) proxy for Telegram and ChatGPT/Codex backend requests.
33
33
  # Put these in the same env file that `foxclaw start` installs into systemd/launchd.
34
+ # FoxClaw passes them to the service and enables Node's env proxy support.
34
35
  # HTTP_PROXY=http://127.0.0.1:7890
35
36
  # HTTPS_PROXY=http://127.0.0.1:7890
36
37
  # ALL_PROXY=socks5://127.0.0.1:7891
37
38
  # NO_PROXY=127.0.0.1,localhost
39
+ # Optional Linux-only fallback when a service must run through proxychains4.
40
+ # FOXCLAW_PROXYCHAINS_CONF=/absolute/path/to/proxychains.conf
38
41
 
39
42
  # Weixin (iLink): run `foxclaw weixin-login` once, then enable:
40
43
  # WX_ENABLED=true
@@ -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
+ }
@@ -255,8 +255,10 @@ export declare class BridgeSessionCore {
255
255
  private buildNativeCollaborationMode;
256
256
  private buildCodexUsageStatusLines;
257
257
  private buildCodexLocalUsageStatusLines;
258
+ private formatCodexLocalOutputSpeedStatusLines;
258
259
  private resolveFastStatusLabel;
259
260
  private readCachedCodexLocalUsageStats;
261
+ private sendThreadContextSummary;
260
262
  private handleModelCommand;
261
263
  private handleEffortCommand;
262
264
  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';
@@ -505,6 +505,7 @@ export class BridgeSessionCore {
505
505
  lines.push(revealError ? t(locale, 'codex_sync_failed', { error: revealError }) : t(locale, 'opened_in_codex'));
506
506
  }
507
507
  await this.sendMessage(scopeId, lines.join('\n'));
508
+ await this.sendThreadContextSummary(scopeId, locale, binding.threadId);
508
509
  return;
509
510
  }
510
511
  case 'watch': {
@@ -831,13 +832,16 @@ export class BridgeSessionCore {
831
832
  const mode = watch.mode;
832
833
  if (mode === 'already') {
833
834
  await this.sendMessage(scopeId, t(locale, 'watch_already_enabled', { threadId: watchedThreadId }));
835
+ await this.sendThreadContextSummary(scopeId, locale, watchedThreadId);
834
836
  return;
835
837
  }
836
838
  if (mode === 'active') {
837
839
  await this.sendMessage(scopeId, t(locale, 'watch_started_active', { threadId: watchedThreadId }));
840
+ await this.sendThreadContextSummary(scopeId, locale, watchedThreadId);
838
841
  return;
839
842
  }
840
843
  await this.sendMessage(scopeId, t(locale, 'watch_started_idle', { threadId: watchedThreadId }));
844
+ await this.sendThreadContextSummary(scopeId, locale, watchedThreadId);
841
845
  }
842
846
  async handleThreadArchiveIndexCommand(scopeId, locale, args) {
843
847
  const index = Number.parseInt(args[0] || '', 10);
@@ -4439,6 +4443,7 @@ export class BridgeSessionCore {
4439
4443
  cached: formatTokenCount(stats.totals.cachedInputTokens),
4440
4444
  reasoning: formatTokenCount(stats.totals.reasoningOutputTokens),
4441
4445
  }),
4446
+ ...this.formatCodexLocalOutputSpeedStatusLines(locale, stats),
4442
4447
  ];
4443
4448
  }
4444
4449
  catch (error) {
@@ -4446,6 +4451,18 @@ export class BridgeSessionCore {
4446
4451
  return [t(locale, 'status_codex_local_usage_unavailable', { error: formatShortStatusError(error) })];
4447
4452
  }
4448
4453
  }
4454
+ formatCodexLocalOutputSpeedStatusLines(locale, stats) {
4455
+ const speed = stats.outputSpeed;
4456
+ if (speed.samples === 0 || speed.outputTokens <= 0 || speed.seconds <= 0) {
4457
+ return [];
4458
+ }
4459
+ const avg = speed.outputTokens / speed.seconds;
4460
+ return [t(locale, 'status_codex_local_speed', {
4461
+ avg: formatCompactNumber(avg),
4462
+ latest: speed.latestTokensPerSecond === null ? t(locale, 'unknown') : formatCompactNumber(speed.latestTokensPerSecond),
4463
+ samples: formatTokenCount(speed.samples),
4464
+ })];
4465
+ }
4449
4466
  async resolveFastStatusLabel(locale, settings) {
4450
4467
  try {
4451
4468
  const models = await this.app.listModels();
@@ -4466,6 +4483,22 @@ export class BridgeSessionCore {
4466
4483
  this.localUsageCache = { stats, expiresAt: now + CODEX_LOCAL_USAGE_CACHE_MS };
4467
4484
  return stats;
4468
4485
  }
4486
+ async sendThreadContextSummary(scopeId, locale, threadId) {
4487
+ try {
4488
+ const turns = await this.app.listThreadTurns(threadId, 5);
4489
+ const text = formatThreadContextSummary(locale, turns);
4490
+ if (text) {
4491
+ await this.sendMessage(scopeId, text);
4492
+ }
4493
+ }
4494
+ catch (error) {
4495
+ this.logger.warn('codex.thread_context_summary_failed', {
4496
+ scopeId,
4497
+ threadId,
4498
+ error: formatUserError(error),
4499
+ });
4500
+ }
4501
+ }
4469
4502
  async handleModelCommand(event, locale, args) {
4470
4503
  const scopeId = event.scopeId;
4471
4504
  if (args.length === 0) {
@@ -4627,6 +4660,7 @@ export class BridgeSessionCore {
4627
4660
  callbackText = revealError ? t(locale, 'opened_sync_failed_short') : t(locale, 'opened_in_codex_short');
4628
4661
  }
4629
4662
  await this.messaging.answerCallback(event.callbackQueryId, callbackText);
4663
+ await this.sendThreadContextSummary(scopeId, locale, binding.threadId);
4630
4664
  }
4631
4665
  async handleThreadActionCallback(event, action, threadId, locale) {
4632
4666
  const scopeId = event.scopeId;
@@ -4673,6 +4707,7 @@ export class BridgeSessionCore {
4673
4707
  const watch = await this.watchThread(scopeId, target.chatId, target.chatType, target.topicId, binding);
4674
4708
  await this.showThreadsPanelFromStoredState(scopeId, event.messageId, locale, false);
4675
4709
  await this.messaging.answerCallback(event.callbackQueryId, formatWatchCallbackText(locale, watch.mode, watch.threadId));
4710
+ await this.sendThreadContextSummary(scopeId, locale, watch.threadId);
4676
4711
  return;
4677
4712
  }
4678
4713
  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
@@ -111,6 +111,7 @@ declare const MESSAGES: {
111
111
  readonly status_codex_usage_unavailable: "Codex usage: unavailable ({error})";
112
112
  readonly status_codex_local_history: "Codex local history: {sessions} sessions, {turns} turns, {events} usage records";
113
113
  readonly status_codex_local_tokens: "Codex local tokens: total {total}; input {input}, output {output}, cached input {cached}, reasoning output {reasoning}";
114
+ readonly status_codex_local_speed: "Codex local output speed: avg {avg} token/s, latest {latest} token/s ({samples} samples)";
114
115
  readonly status_codex_local_usage_unavailable: "Codex local history: unavailable ({error})";
115
116
  readonly status_codex_credits: "Codex credits: {value}";
116
117
  readonly status_codex_limit_reached: "Codex limit: {value}";
@@ -315,6 +316,9 @@ declare const MESSAGES: {
315
316
  readonly threads_filter: "Filter: <code>{searchTerm}</code>";
316
317
  readonly threads_range: "Showing {start}-{end}";
317
318
  readonly threads_filter_cleared_short: "Filter cleared";
319
+ readonly thread_context_title: "Recent context:";
320
+ readonly thread_context_user: "User:";
321
+ readonly thread_context_codex: "Codex:";
318
322
  readonly button_prev_page: "⬅️ Prev";
319
323
  readonly button_next_page: "➡️ Next";
320
324
  readonly button_clear_filter: "🧹 Clear";
@@ -661,6 +665,7 @@ declare const MESSAGES: {
661
665
  readonly status_codex_usage_unavailable: "Codex 用量:无法获取({error})";
662
666
  readonly status_codex_local_history: "Codex 本地历史:{sessions} 个会话,{turns} 轮,{events} 条用量记录";
663
667
  readonly status_codex_local_tokens: "Codex 本地 Token:总计 {total};输入 {input},输出 {output},缓存输入 {cached},推理输出 {reasoning}";
668
+ readonly status_codex_local_speed: "Codex 本地输出速度:平均 {avg} token/s,最近 {latest} token/s({samples} 个样本)";
664
669
  readonly status_codex_local_usage_unavailable: "Codex 本地历史:无法获取({error})";
665
670
  readonly status_codex_credits: "Codex 额度:{value}";
666
671
  readonly status_codex_limit_reached: "Codex 限制:{value}";
@@ -865,6 +870,9 @@ declare const MESSAGES: {
865
870
  readonly threads_filter: "筛选:<code>{searchTerm}</code>";
866
871
  readonly threads_range: "显示第 {start}-{end} 条";
867
872
  readonly threads_filter_cleared_short: "已清除筛选";
873
+ readonly thread_context_title: "最近上下文:";
874
+ readonly thread_context_user: "用户指令:";
875
+ readonly thread_context_codex: "Codex 最后输出:";
868
876
  readonly button_prev_page: "⬅️ 上一页";
869
877
  readonly button_next_page: "➡️ 下一页";
870
878
  readonly button_clear_filter: "🧹 清除";
package/dist/i18n.js CHANGED
@@ -109,6 +109,7 @@ const MESSAGES = {
109
109
  status_codex_usage_unavailable: 'Codex usage: unavailable ({error})',
110
110
  status_codex_local_history: 'Codex local history: {sessions} sessions, {turns} turns, {events} usage records',
111
111
  status_codex_local_tokens: 'Codex local tokens: total {total}; input {input}, output {output}, cached input {cached}, reasoning output {reasoning}',
112
+ status_codex_local_speed: 'Codex local output speed: avg {avg} token/s, latest {latest} token/s ({samples} samples)',
112
113
  status_codex_local_usage_unavailable: 'Codex local history: unavailable ({error})',
113
114
  status_codex_credits: 'Codex credits: {value}',
114
115
  status_codex_limit_reached: 'Codex limit: {value}',
@@ -313,6 +314,9 @@ const MESSAGES = {
313
314
  threads_filter: 'Filter: <code>{searchTerm}</code>',
314
315
  threads_range: 'Showing {start}-{end}',
315
316
  threads_filter_cleared_short: 'Filter cleared',
317
+ thread_context_title: 'Recent context:',
318
+ thread_context_user: 'User:',
319
+ thread_context_codex: 'Codex:',
316
320
  button_prev_page: '⬅️ Prev',
317
321
  button_next_page: '➡️ Next',
318
322
  button_clear_filter: '🧹 Clear',
@@ -659,6 +663,7 @@ const MESSAGES = {
659
663
  status_codex_usage_unavailable: 'Codex 用量:无法获取({error})',
660
664
  status_codex_local_history: 'Codex 本地历史:{sessions} 个会话,{turns} 轮,{events} 条用量记录',
661
665
  status_codex_local_tokens: 'Codex 本地 Token:总计 {total};输入 {input},输出 {output},缓存输入 {cached},推理输出 {reasoning}',
666
+ status_codex_local_speed: 'Codex 本地输出速度:平均 {avg} token/s,最近 {latest} token/s({samples} 个样本)',
662
667
  status_codex_local_usage_unavailable: 'Codex 本地历史:无法获取({error})',
663
668
  status_codex_credits: 'Codex 额度:{value}',
664
669
  status_codex_limit_reached: 'Codex 限制:{value}',
@@ -863,6 +868,9 @@ const MESSAGES = {
863
868
  threads_filter: '筛选:<code>{searchTerm}</code>',
864
869
  threads_range: '显示第 {start}-{end} 条',
865
870
  threads_filter_cleared_short: '已清除筛选',
871
+ thread_context_title: '最近上下文:',
872
+ thread_context_user: '用户指令:',
873
+ thread_context_codex: 'Codex 最后输出:',
866
874
  button_prev_page: '⬅️ 上一页',
867
875
  button_next_page: '➡️ 下一页',
868
876
  button_clear_filter: '🧹 清除',
package/dist/main.js CHANGED
@@ -8,7 +8,7 @@ import { fileURLToPath } from 'node:url';
8
8
  import { APP_HOME, DEFAULT_ENV_PATH, DEFAULT_LOG_PATH, DEFAULT_STATUS_PATH, getLoadedEnvPath, loadConfig, loadEnv, } from './config.js';
9
9
  import { acquireProcessLock, LockHeldError } from './lock.js';
10
10
  import { readRuntimeStatus, writeRuntimeStatus } from './runtime.js';
11
- import { refreshFoxclawExecStartDropIns } from './systemd.js';
11
+ import { refreshFoxclawExecStartDropIns, removeFoxclawExecStartDropIns } from './systemd.js';
12
12
  const rawCommand = process.argv[2];
13
13
  const command = rawCommand || 'serve';
14
14
  loadEnv();
@@ -24,6 +24,7 @@ const PROXY_ENV_KEYS = [
24
24
  'all_proxy',
25
25
  'no_proxy',
26
26
  ];
27
+ const STANDARD_NODE_PROXY_ENV_KEYS = ['HTTP_PROXY', 'HTTPS_PROXY', 'http_proxy', 'https_proxy'];
27
28
  async function main() {
28
29
  if (isVersionCommand(command)) {
29
30
  console.log(readPackageVersion());
@@ -493,9 +494,38 @@ function runDoctorChecks() {
493
494
  passed = false;
494
495
  }
495
496
  warnIfProxyEnvMissingFromLoadedEnv();
497
+ warnIfProxyConfigNeedsAttention();
496
498
  warnIfInstalledServiceNodeLooksWrong();
497
499
  return passed;
498
500
  }
501
+ function warnIfProxyConfigNeedsAttention() {
502
+ const proxychainsConf = process.env.FOXCLAW_PROXYCHAINS_CONF?.trim() || '';
503
+ if (proxychainsConf) {
504
+ if (process.platform !== 'linux') {
505
+ console.log('[WARN] FOXCLAW_PROXYCHAINS_CONF is only used by systemd on Linux.');
506
+ }
507
+ else if (!fs.existsSync(proxychainsConf)) {
508
+ console.log(`[WARN] FOXCLAW_PROXYCHAINS_CONF does not exist: ${proxychainsConf}`);
509
+ }
510
+ else if (!hasCommand('proxychains4')) {
511
+ console.log('[WARN] proxychains4 is not available, but FOXCLAW_PROXYCHAINS_CONF is set.');
512
+ }
513
+ else {
514
+ console.log(`[OK] proxychains config exists: ${proxychainsConf}`);
515
+ }
516
+ return;
517
+ }
518
+ const proxyKeys = PROXY_ENV_KEYS.filter((key) => proxyEnvValue(key));
519
+ if (proxyKeys.length === 0) {
520
+ return;
521
+ }
522
+ if (hasStandardNodeProxyEnv()) {
523
+ console.log(`[OK] service proxy env configured: ${proxyKeys.join(', ')}`);
524
+ return;
525
+ }
526
+ console.log('[WARN] Only ALL_PROXY/all_proxy is configured. Node service proxying works best with HTTP_PROXY/HTTPS_PROXY.');
527
+ console.log('[WARN] For SOCKS-only hosts, set FOXCLAW_PROXYCHAINS_CONF=/absolute/path/to/proxychains.conf and run foxclaw restart.');
528
+ }
499
529
  function warnIfProxyEnvMissingFromLoadedEnv() {
500
530
  const envPath = serviceEnvPath();
501
531
  const proxyUpdates = detectMissingProxyEnv(envPath);
@@ -519,7 +549,7 @@ function warnIfInstalledServiceNodeLooksWrong() {
519
549
  return;
520
550
  }
521
551
  const execStart = text.match(/^ExecStart=(.+)$/m)?.[1]?.trim();
522
- const nodePath = execStart ? systemdUnescape(execStart.split(/\s+/)[0] ?? '') : '';
552
+ const nodePath = execStart ? extractNodePathFromExecStart(execStart) : '';
523
553
  if (!nodePath) {
524
554
  return;
525
555
  }
@@ -538,6 +568,14 @@ function warnIfInstalledServiceNodeLooksWrong() {
538
568
  console.log(`[WARN] installed service node is older than 24: ${nodePath}${version ? ` (${version})` : ''}`);
539
569
  console.log('[WARN] Run foxclaw start from a Node 24 shell to refresh the service unit.');
540
570
  }
571
+ function extractNodePathFromExecStart(execStart) {
572
+ const tokens = execStart.split(/\s+/).map(systemdUnescape).filter(Boolean);
573
+ const directNode = tokens[0] || '';
574
+ if (path.basename(directNode) === 'node') {
575
+ return directNode;
576
+ }
577
+ return tokens.find((token) => path.basename(token) === 'node') || directNode;
578
+ }
541
579
  function installSystemd() {
542
580
  if (!hasCommand('systemctl')) {
543
581
  console.error('systemctl not found (need systemd)');
@@ -551,6 +589,12 @@ function installSystemd() {
551
589
  const nodeBin = process.execPath;
552
590
  const nodeDir = path.dirname(nodeBin);
553
591
  const pathValue = buildServicePath(nodeDir);
592
+ const proxychainsConf = process.env.FOXCLAW_PROXYCHAINS_CONF?.trim() || '';
593
+ const proxychainsBin = proxychainsConf ? resolveCommand('proxychains4') || '/usr/bin/proxychains4' : '';
594
+ const nodeProxyArgs = !proxychainsConf && hasStandardNodeProxyEnv() ? ' --use-env-proxy' : '';
595
+ const execStart = proxychainsConf
596
+ ? `${systemdEscape(proxychainsBin)} -f ${systemdEscape(proxychainsConf)} ${systemdEscape(nodeBin)} ${systemdEscape(entryPoint)} serve`
597
+ : `${systemdEscape(nodeBin)}${nodeProxyArgs} ${systemdEscape(entryPoint)} serve`;
554
598
  fs.mkdirSync(userSystemdDir, { recursive: true });
555
599
  fs.mkdirSync(configDir, { recursive: true });
556
600
  fs.mkdirSync(path.join(APP_HOME, 'logs'), { recursive: true });
@@ -566,12 +610,13 @@ StartLimitBurst=5
566
610
  [Service]
567
611
  Type=simple
568
612
  WorkingDirectory=${systemdEscape(configDir)}
613
+ EnvironmentFile=-${systemdEscape(envPath)}
569
614
  Environment=HOME=${systemdEscape(process.env.HOME || '')}
570
615
  Environment=USER=${systemdEscape(process.env.USER || '')}
571
616
  Environment=LOGNAME=${systemdEscape(process.env.LOGNAME || process.env.USER || '')}
572
617
  Environment=PATH=${systemdEscape(pathValue)}
573
618
  Environment=FOXCLAW_ENV=${systemdEscape(envPath)}
574
- ExecStart=${systemdEscape(nodeBin)} ${escapedEntryPoint} serve
619
+ ExecStart=${execStart}
575
620
  Restart=always
576
621
  RestartSec=10
577
622
  TimeoutStopSec=45
@@ -580,9 +625,23 @@ KillMode=process
580
625
  [Install]
581
626
  WantedBy=default.target
582
627
  `);
583
- const dropInUpdates = refreshFoxclawExecStartDropIns(userSystemdDir, unitName, escapedEntryPoint);
584
- for (const update of dropInUpdates) {
585
- console.log(`[OK] updated FoxClaw ExecStart override: ${update.path}`);
628
+ if (proxychainsConf) {
629
+ console.log(`[OK] systemd proxychains enabled: ${proxychainsConf}`);
630
+ }
631
+ else if (nodeProxyArgs) {
632
+ console.log('[OK] systemd Node env proxy enabled');
633
+ }
634
+ if (proxychainsConf) {
635
+ const dropInUpdates = removeFoxclawExecStartDropIns(userSystemdDir, unitName);
636
+ for (const update of dropInUpdates) {
637
+ console.log(`[OK] removed stale FoxClaw ExecStart override: ${update.path}`);
638
+ }
639
+ }
640
+ else {
641
+ const dropInUpdates = refreshFoxclawExecStartDropIns(userSystemdDir, unitName, escapedEntryPoint);
642
+ for (const update of dropInUpdates) {
643
+ console.log(`[OK] updated FoxClaw ExecStart override: ${update.path}`);
644
+ }
586
645
  }
587
646
  spawnChecked('systemctl', ['--user', 'daemon-reload']);
588
647
  spawnChecked('systemctl', ['--user', 'enable', unitName]);
@@ -625,6 +684,9 @@ function installLaunchd() {
625
684
  const plist = path.join(home, 'Library', 'LaunchAgents', 'app.foxden.foxclaw.plist');
626
685
  const envPath = serviceEnvPath();
627
686
  const configDir = path.dirname(envPath);
687
+ const nodeProxyArgs = hasStandardNodeProxyEnv() ? ['--use-env-proxy'] : [];
688
+ const nodeProxyArgXml = nodeProxyArgs.map((arg) => ` <string>${xmlEscape(arg)}</string>`).join('\n');
689
+ const proxyEnvXml = buildLaunchdProxyEnvironmentXml();
628
690
  fs.mkdirSync(path.dirname(plist), { recursive: true });
629
691
  fs.mkdirSync(configDir, { recursive: true });
630
692
  fs.mkdirSync(path.join(APP_HOME, 'logs'), { recursive: true });
@@ -637,7 +699,7 @@ function installLaunchd() {
637
699
  <key>ProgramArguments</key>
638
700
  <array>
639
701
  <string>${xmlEscape(process.execPath)}</string>
640
- <string>${xmlEscape(entryPoint)}</string>
702
+ ${nodeProxyArgXml ? `${nodeProxyArgXml}\n` : ''} <string>${xmlEscape(entryPoint)}</string>
641
703
  <string>serve</string>
642
704
  </array>
643
705
  <key>WorkingDirectory</key>
@@ -654,6 +716,7 @@ function installLaunchd() {
654
716
  <string>${xmlEscape(process.env.LOGNAME || process.env.USER || '')}</string>
655
717
  <key>FOXCLAW_ENV</key>
656
718
  <string>${xmlEscape(envPath)}</string>
719
+ ${proxyEnvXml}
657
720
  </dict>
658
721
  <key>RunAtLoad</key>
659
722
  <true/>
@@ -669,6 +732,9 @@ function installLaunchd() {
669
732
  spawnSync('launchctl', ['unload', plist], { stdio: 'ignore' });
670
733
  spawnChecked('launchctl', ['load', plist]);
671
734
  console.log(`Installed ${plist}`);
735
+ if (nodeProxyArgs.length > 0) {
736
+ console.log('[OK] launchd Node env proxy enabled');
737
+ }
672
738
  }
673
739
  function stopLaunchd() {
674
740
  if (process.platform !== 'darwin') {
@@ -699,6 +765,23 @@ function buildServicePath(nodeDir) {
699
765
  function serviceEnvPath() {
700
766
  return path.resolve(process.env.FOXCLAW_ENV?.trim() || getLoadedEnvPath() || DEFAULT_ENV_PATH);
701
767
  }
768
+ function hasStandardNodeProxyEnv() {
769
+ return STANDARD_NODE_PROXY_ENV_KEYS.some((key) => Boolean(proxyEnvValue(key)));
770
+ }
771
+ function proxyEnvValue(key) {
772
+ return process.env[key]?.trim() || '';
773
+ }
774
+ function buildLaunchdProxyEnvironmentXml() {
775
+ const entries = [];
776
+ for (const key of PROXY_ENV_KEYS) {
777
+ const value = proxyEnvValue(key);
778
+ if (!value)
779
+ continue;
780
+ entries.push(` <key>${xmlEscape(key)}</key>`);
781
+ entries.push(` <string>${xmlEscape(value)}</string>`);
782
+ }
783
+ return entries.length > 0 ? `${entries.join('\n')}\n` : '';
784
+ }
702
785
  function spawnChecked(commandName, args) {
703
786
  const result = spawnSync(commandName, args, { stdio: 'inherit' });
704
787
  if (result.status !== 0) {
package/dist/systemd.d.ts CHANGED
@@ -3,7 +3,12 @@ export interface SystemdDropInUpdate {
3
3
  replacements: number;
4
4
  }
5
5
  export declare function refreshFoxclawExecStartDropIns(userSystemdDir: string, unitName: string, escapedEntryPoint: string): SystemdDropInUpdate[];
6
+ export declare function removeFoxclawExecStartDropIns(userSystemdDir: string, unitName: string): SystemdDropInUpdate[];
6
7
  export declare function refreshFoxclawExecStartText(text: string, escapedEntryPoint: string): {
7
8
  text: string;
8
9
  replacements: number;
9
10
  };
11
+ export declare function removeFoxclawExecStartText(text: string): {
12
+ text: string;
13
+ replacements: number;
14
+ };
package/dist/systemd.js CHANGED
@@ -29,6 +29,39 @@ export function refreshFoxclawExecStartDropIns(userSystemdDir, unitName, escaped
29
29
  }
30
30
  return updates;
31
31
  }
32
+ export function removeFoxclawExecStartDropIns(userSystemdDir, unitName) {
33
+ const dropInDir = path.join(userSystemdDir, `${unitName}.d`);
34
+ let names;
35
+ try {
36
+ names = fs.readdirSync(dropInDir).filter((name) => name.endsWith('.conf')).sort();
37
+ }
38
+ catch {
39
+ return [];
40
+ }
41
+ const updates = [];
42
+ for (const name of names) {
43
+ const filePath = path.join(dropInDir, name);
44
+ let before = '';
45
+ try {
46
+ before = fs.readFileSync(filePath, 'utf8');
47
+ }
48
+ catch {
49
+ continue;
50
+ }
51
+ const { text, replacements } = removeFoxclawExecStartText(before);
52
+ if (replacements === 0 || text === before) {
53
+ continue;
54
+ }
55
+ if (isEmptyServiceDropIn(text)) {
56
+ fs.rmSync(filePath, { force: true });
57
+ }
58
+ else {
59
+ fs.writeFileSync(filePath, text, 'utf8');
60
+ }
61
+ updates.push({ path: filePath, replacements });
62
+ }
63
+ return updates;
64
+ }
32
65
  export function refreshFoxclawExecStartText(text, escapedEntryPoint) {
33
66
  let replacements = 0;
34
67
  const lines = text.split(/(\r?\n)/);
@@ -43,3 +76,28 @@ export function refreshFoxclawExecStartText(text, escapedEntryPoint) {
43
76
  });
44
77
  return { text: refreshed.join(''), replacements };
45
78
  }
79
+ export function removeFoxclawExecStartText(text) {
80
+ const hasFoxclawExecStart = text
81
+ .split(/\r?\n/)
82
+ .some((line) => line.startsWith('ExecStart=') && FOXCLAW_MAIN_PATH_RE.test(line));
83
+ if (!hasFoxclawExecStart) {
84
+ return { text, replacements: 0 };
85
+ }
86
+ let replacements = 0;
87
+ const lines = text.split(/(\r?\n)/);
88
+ const cleaned = lines.map((part) => {
89
+ if (!part.startsWith('ExecStart=')) {
90
+ return part;
91
+ }
92
+ replacements += 1;
93
+ return '';
94
+ });
95
+ return { text: cleaned.join(''), replacements };
96
+ }
97
+ function isEmptyServiceDropIn(text) {
98
+ const meaningfulLines = text
99
+ .split(/\r?\n/)
100
+ .map((line) => line.trim())
101
+ .filter(Boolean);
102
+ return meaningfulLines.length === 0 || (meaningfulLines.length === 1 && meaningfulLines[0] === '[Service]');
103
+ }
@@ -203,12 +203,28 @@ ALL_PROXY=socks5://127.0.0.1:20170
203
203
  NO_PROXY=127.0.0.1,localhost
204
204
  ```
205
205
 
206
+ When `HTTP_PROXY` or `HTTPS_PROXY` is configured, FoxClaw passes those variables to systemd/launchd explicitly and starts Node with `--use-env-proxy`. Do not rely on proxy variables from the current shell; service processes do not inherit them automatically.
207
+
206
208
  Restart FoxClaw after editing. The restart also restarts the managed Codex app-server so the new proxy environment takes effect:
207
209
 
208
210
  ```bash
209
211
  foxclaw restart
210
212
  ```
211
213
 
214
+ If a Linux host must use `proxychains4` to reach Telegram or ChatGPT, do not hand-write a systemd drop-in that overrides `ExecStart`. Add this to the FoxClaw env file instead:
215
+
216
+ ```dotenv
217
+ FOXCLAW_PROXYCHAINS_CONF=/home/wuya/.proxychains-rt.conf
218
+ ```
219
+
220
+ Then run:
221
+
222
+ ```bash
223
+ foxclaw restart
224
+ ```
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`.
227
+
212
228
  ## Service Starts With The Wrong Node Version
213
229
 
214
230
  The systemd installer records the absolute path of the Node process that is currently running FoxClaw. It does not rely on systemd loading `nvm.sh` or any other shell init script. Whether you use nvm, fnm, asdf, mise, Volta, Homebrew, or system Node, run `foxclaw start` from a Node 24+ shell and the service will keep using that Node 24+ path.
@@ -100,7 +100,7 @@ Both install the same published npm package. Use one global package manager cons
100
100
 
101
101
  ### 1.6 Fill In The Config
102
102
 
103
- `foxclaw init` creates the default config file at `~/.foxclaw/.env` and prompts for the Telegram bot token, your numeric Telegram user id, and the default workspace. If the current shell has proxy variables such as `HTTP_PROXY`, `HTTPS_PROXY`, or `ALL_PROXY`, it also asks whether to save them into the FoxClaw config so the service-side Codex app-server uses the same network. Press Enter on any field to skip it, then edit manually if needed:
103
+ `foxclaw init` creates the default config file at `~/.foxclaw/.env` and prompts for the Telegram bot token, your numeric Telegram user id, and the default workspace. If the current shell has proxy variables such as `HTTP_PROXY`, `HTTPS_PROXY`, or `ALL_PROXY`, it also asks whether to save them into the FoxClaw config. When `HTTP_PROXY` or `HTTPS_PROXY` is configured, FoxClaw passes it to systemd/launchd explicitly and enables Node's env proxy support. Press Enter on any field to skip it, then edit manually if needed:
104
104
 
105
105
  ```bash
106
106
  $EDITOR ~/.foxclaw/.env
@@ -204,12 +204,28 @@ ALL_PROXY=socks5://127.0.0.1:20170
204
204
  NO_PROXY=127.0.0.1,localhost
205
205
  ```
206
206
 
207
+ 只要配置了 `HTTP_PROXY` 或 `HTTPS_PROXY`,FoxClaw 安装 systemd/launchd 时会把这些变量显式传给服务,并给 Node 加上 `--use-env-proxy`。不要依赖“当前 shell 里有代理变量”,服务进程不会自动继承它们。
208
+
207
209
  改完后重启 FoxClaw。重启会同时重启托管的 Codex app-server,让新代理生效:
208
210
 
209
211
  ```bash
210
212
  foxclaw restart
211
213
  ```
212
214
 
215
+ 如果这台 Linux 机器必须用 `proxychains4` 才能访问 Telegram 或 ChatGPT,不要手写 systemd drop-in 覆盖 `ExecStart`。在 FoxClaw env 文件里写:
216
+
217
+ ```dotenv
218
+ FOXCLAW_PROXYCHAINS_CONF=/home/wuya/.proxychains-rt.conf
219
+ ```
220
+
221
+ 然后运行:
222
+
223
+ ```bash
224
+ foxclaw restart
225
+ ```
226
+
227
+ FoxClaw 会把 proxychains 写进主 service,并清理旧的 FoxClaw `ExecStart` 覆盖,后续升级仍然只需要正常 `pnpm install -g` 和 `foxclaw restart`。
228
+
213
229
  ## 服务用了错误的 Node 版本
214
230
 
215
231
  systemd 安装脚本会记录当时正在运行的 Node 绝对路径,不依赖 systemd 去加载 `nvm.sh` 或其它 shell 初始化脚本。无论你用 nvm、fnm、asdf、mise、Volta、Homebrew 还是系统 Node,原则都是:从 Node 24+ 的 shell 里执行 `foxclaw start`,服务之后就固定使用这个 Node 24+ 路径。
@@ -100,7 +100,7 @@ foxclaw init
100
100
 
101
101
  ### 1.6 填写配置
102
102
 
103
- `foxclaw init` 会创建默认配置文件 `~/.foxclaw/.env`,并提示填写 Telegram bot token、Telegram 数字用户 ID 和默认工作目录。如果当前 shell 里有 `HTTP_PROXY`、`HTTPS_PROXY`、`ALL_PROXY` 等代理变量,它也会询问是否写入 FoxClaw 配置,让服务里的 Codex app-server 使用同样的网络。任何一项都可以直接回车跳过,之后再手动编辑:
103
+ `foxclaw init` 会创建默认配置文件 `~/.foxclaw/.env`,并提示填写 Telegram bot token、Telegram 数字用户 ID 和默认工作目录。如果当前 shell 里有 `HTTP_PROXY`、`HTTPS_PROXY`、`ALL_PROXY` 等代理变量,它也会询问是否写入 FoxClaw 配置。配置了 `HTTP_PROXY` `HTTPS_PROXY` 后,FoxClaw 会在安装服务时显式传给 systemd/launchd,并启用 Node 的 env proxy。任何一项都可以直接回车跳过,之后再手动编辑:
104
104
 
105
105
  ```bash
106
106
  $EDITOR ~/.foxclaw/.env
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@foxden-app/foxclaw",
3
- "version": "0.3.11",
3
+ "version": "0.3.13",
4
4
  "description": "Foxden local execution claw for controlling Codex from trusted chat interfaces.",
5
5
  "type": "module",
6
6
  "main": "dist/main.js",
@@ -184,6 +184,7 @@ Use this checklist when the user asks for standard closing actions, release wrap
184
184
  - If the user has a pnpm global FoxClaw install, prefer `pnpm add -g <repo-path>` so the global `foxclaw` points at the local repo.
185
185
  - Rebuild before restarting because local linked installs run `dist/main.js`.
186
186
  - Refresh systemd with the existing service env path, for example `FOXCLAW_ENV=<existing-env> <node24> dist/main.js install-systemd`. Do not run `install-systemd` from the repo without `FOXCLAW_ENV`, because it may rewrite the service to use the repo `.env`.
187
+ - Do not create systemd drop-ins that override FoxClaw `ExecStart`. For Linux hosts that require proxychains, set `FOXCLAW_PROXYCHAINS_CONF=/absolute/path/to/proxychains.conf` in the FoxClaw env file and rerun `foxclaw restart`.
187
188
  - For macOS launchd, use the launchd install/start path from this skill and verify with `node dist/main.js status`.
188
189
  - Verify the running service reports the expected FoxClaw version in `status`.
189
190
  - If `doctor` fails only because `DEFAULT_CWD` is missing, report that separately; do not treat it as evidence that the service update failed.