botmux 2.28.0 → 2.29.0

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.
Files changed (80) hide show
  1. package/README.en.md +6 -3
  2. package/README.md +6 -3
  3. package/dist/adapters/cli/claude-code.d.ts.map +1 -1
  4. package/dist/adapters/cli/claude-code.js +26 -28
  5. package/dist/adapters/cli/claude-code.js.map +1 -1
  6. package/dist/adapters/cli/shared-hints.d.ts +3 -4
  7. package/dist/adapters/cli/shared-hints.d.ts.map +1 -1
  8. package/dist/adapters/cli/shared-hints.js +14 -13
  9. package/dist/adapters/cli/shared-hints.js.map +1 -1
  10. package/dist/adapters/cli/types.d.ts +2 -0
  11. package/dist/adapters/cli/types.d.ts.map +1 -1
  12. package/dist/bot-registry.d.ts +5 -1
  13. package/dist/bot-registry.d.ts.map +1 -1
  14. package/dist/bot-registry.js +6 -1
  15. package/dist/bot-registry.js.map +1 -1
  16. package/dist/cli.js +317 -27
  17. package/dist/cli.js.map +1 -1
  18. package/dist/config.d.ts +0 -1
  19. package/dist/config.d.ts.map +1 -1
  20. package/dist/config.js +0 -1
  21. package/dist/config.js.map +1 -1
  22. package/dist/core/command-handler.d.ts +2 -1
  23. package/dist/core/command-handler.d.ts.map +1 -1
  24. package/dist/core/command-handler.js +139 -161
  25. package/dist/core/command-handler.js.map +1 -1
  26. package/dist/core/session-manager.d.ts +6 -2
  27. package/dist/core/session-manager.d.ts.map +1 -1
  28. package/dist/core/session-manager.js +52 -32
  29. package/dist/core/session-manager.js.map +1 -1
  30. package/dist/core/worker-pool.d.ts.map +1 -1
  31. package/dist/core/worker-pool.js +40 -17
  32. package/dist/core/worker-pool.js.map +1 -1
  33. package/dist/daemon.d.ts.map +1 -1
  34. package/dist/daemon.js +17 -16
  35. package/dist/daemon.js.map +1 -1
  36. package/dist/global-config.d.ts +15 -0
  37. package/dist/global-config.d.ts.map +1 -0
  38. package/dist/global-config.js +73 -0
  39. package/dist/global-config.js.map +1 -0
  40. package/dist/i18n/en.d.ts +3 -0
  41. package/dist/i18n/en.d.ts.map +1 -0
  42. package/dist/i18n/en.js +251 -0
  43. package/dist/i18n/en.js.map +1 -0
  44. package/dist/i18n/index.d.ts +33 -0
  45. package/dist/i18n/index.d.ts.map +1 -0
  46. package/dist/i18n/index.js +74 -0
  47. package/dist/i18n/index.js.map +1 -0
  48. package/dist/i18n/types.d.ts +4 -0
  49. package/dist/i18n/types.d.ts.map +1 -0
  50. package/dist/i18n/types.js +5 -0
  51. package/dist/i18n/types.js.map +1 -0
  52. package/dist/i18n/zh.d.ts +6 -0
  53. package/dist/i18n/zh.d.ts.map +1 -0
  54. package/dist/i18n/zh.js +254 -0
  55. package/dist/i18n/zh.js.map +1 -0
  56. package/dist/im/lark/card-builder.d.ts +10 -9
  57. package/dist/im/lark/card-builder.d.ts.map +1 -1
  58. package/dist/im/lark/card-builder.js +58 -53
  59. package/dist/im/lark/card-builder.js.map +1 -1
  60. package/dist/im/lark/card-handler.d.ts.map +1 -1
  61. package/dist/im/lark/card-handler.js +44 -51
  62. package/dist/im/lark/card-handler.js.map +1 -1
  63. package/dist/im/lark/client.d.ts +4 -2
  64. package/dist/im/lark/client.d.ts.map +1 -1
  65. package/dist/im/lark/client.js +1 -7
  66. package/dist/im/lark/client.js.map +1 -1
  67. package/dist/im/lark/message-parser.d.ts.map +1 -1
  68. package/dist/im/lark/message-parser.js +11 -3
  69. package/dist/im/lark/message-parser.js.map +1 -1
  70. package/dist/index-daemon.js +10 -0
  71. package/dist/index-daemon.js.map +1 -1
  72. package/dist/setup/bot-config-editor.d.ts +44 -0
  73. package/dist/setup/bot-config-editor.d.ts.map +1 -0
  74. package/dist/setup/bot-config-editor.js +170 -0
  75. package/dist/setup/bot-config-editor.js.map +1 -0
  76. package/dist/types.d.ts +1 -0
  77. package/dist/types.d.ts.map +1 -1
  78. package/dist/worker.js +20 -1
  79. package/dist/worker.js.map +1 -1
  80. package/package.json +1 -1
@@ -20,6 +20,7 @@ import { discoverAdoptableSessions, validateAdoptTarget } from './session-discov
20
20
  import { generateAuthUrl, getTokenStatus } from '../utils/user-token.js';
21
21
  import { bindOncall, unbindOncall, getOncallStatus } from '../services/oncall-store.js';
22
22
  import { sessionKey, sessionAnchorId } from './types.js';
23
+ import { t, localeForBot } from '../i18n/index.js';
23
24
  // ─── Exported constants ──────────────────────────────────────────────────────
24
25
  export const DAEMON_COMMANDS = new Set(['/close', '/restart', '/status', '/help', '/cd', '/repo', '/skip', '/schedule', '/login', '/adopt', '/oncall']);
25
26
  /**
@@ -37,20 +38,20 @@ const MULTILINE_COMMANDS = new Set(['/schedule']);
37
38
  * with full filesystem access, so an allowlist would be theater. We only do
38
39
  * the typo guards: exists and is a directory.
39
40
  */
40
- export function validateWorkingDir(input) {
41
+ export function validateWorkingDir(input, locale) {
41
42
  const resolvedPath = resolve(expandHome(input));
42
43
  if (!existsSync(resolvedPath)) {
43
- return { ok: false, error: `目录不存在:${resolvedPath}` };
44
+ return { ok: false, error: t('cmd.cd.dir_not_exist', { path: resolvedPath }, locale) };
44
45
  }
45
46
  let isDir = false;
46
47
  try {
47
48
  isDir = statSync(resolvedPath).isDirectory();
48
49
  }
49
50
  catch (e) {
50
- return { ok: false, error: `无法读取路径:${resolvedPath}(${e?.message ?? e})` };
51
+ return { ok: false, error: t('cmd.cd.cannot_read', { path: resolvedPath, msg: e?.message ?? String(e) }, locale) };
51
52
  }
52
53
  if (!isDir) {
53
- return { ok: false, error: `路径不是目录:${resolvedPath}` };
54
+ return { ok: false, error: t('cmd.cd.not_a_directory', { path: resolvedPath }, locale) };
54
55
  }
55
56
  return { ok: true, resolvedPath };
56
57
  }
@@ -124,22 +125,28 @@ async function handleScheduleCommand(args, rootId, chatId, deps, larkAppId) {
124
125
  const { activeSessions } = deps;
125
126
  const sessionReply = (rid, content, msgType) => deps.sessionReply(rid, content, msgType, larkAppId);
126
127
  const trimmed = args.trim();
128
+ const loc = localeForBot(larkAppId);
129
+ // Format dates using a locale that matches the user's UI choice. Both
130
+ // forms include the wall-clock components the user cares about; the
131
+ // difference is just punctuation and digit order.
132
+ const timeLocale = loc === 'en' ? 'en-US' : 'zh-CN';
133
+ const timeZone = 'Asia/Shanghai';
127
134
  // /schedule list | /schedule 列表
128
135
  if (!trimmed || trimmed === 'list' || trimmed === '列表') {
129
136
  const tasks = scheduleStore.listTasks();
130
137
  if (tasks.length === 0) {
131
- await sessionReply(rootId, '暂无定时任务。\n\n用法示例:\n/schedule 每日17:50 帮我看看今天AI圈有什么新闻\n/schedule 工作日每天9:00 检查服务状态\n/schedule 每周一10:00 生成周报');
138
+ await sessionReply(rootId, t('schedule.empty_with_examples', undefined, loc));
132
139
  return;
133
140
  }
134
- const lines = tasks.map(t => {
135
- const status = t.enabled ? '✅' : '⏸️';
136
- const next = t.enabled ? scheduler.getNextRun(t.id) : null;
137
- const nextStr = next ? ` 下次: ${next.toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' })}` : '';
138
- const lastStr = t.lastRunAt ? ` | 上次: ${new Date(t.lastRunAt).toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' })}` : '';
139
- const display = t.parsed?.display ?? t.schedule;
140
- return `${status} [${t.id}] ${display} | ${t.name}\n prompt: ${t.prompt.substring(0, 50)}${t.prompt.length > 50 ? '...' : ''}${nextStr}${lastStr}`;
141
+ const lines = tasks.map(task => {
142
+ const status = task.enabled ? '✅' : '⏸️';
143
+ const next = task.enabled ? scheduler.getNextRun(task.id) : null;
144
+ const nextStr = next ? t('schedule.next_label', { time: next.toLocaleString(timeLocale, { timeZone }) }, loc) : '';
145
+ const lastStr = task.lastRunAt ? t('schedule.last_label', { time: new Date(task.lastRunAt).toLocaleString(timeLocale, { timeZone }) }, loc) : '';
146
+ const display = task.parsed?.display ?? task.schedule;
147
+ return `${status} [${task.id}] ${display} | ${task.name}\n prompt: ${task.prompt.substring(0, 50)}${task.prompt.length > 50 ? '...' : ''}${nextStr}${lastStr}`;
141
148
  });
142
- await sessionReply(rootId, `定时任务列表 (${tasks.length}):\n\n${lines.join('\n\n')}`);
149
+ await sessionReply(rootId, `${t('schedule.list_header', { count: tasks.length }, loc)}\n\n${lines.join('\n\n')}`);
143
150
  return;
144
151
  }
145
152
  // /schedule remove <id> | /schedule 删除 <id>
@@ -147,10 +154,10 @@ async function handleScheduleCommand(args, rootId, chatId, deps, larkAppId) {
147
154
  if (removeMatch) {
148
155
  const id = removeMatch[1];
149
156
  if (scheduler.removeTask(id)) {
150
- await sessionReply(rootId, `已删除定时任务 ${id}`);
157
+ await sessionReply(rootId, t('schedule.removed', { id }, loc));
151
158
  }
152
159
  else {
153
- await sessionReply(rootId, `未找到任务 ${id}`);
160
+ await sessionReply(rootId, t('schedule.not_found', { id }, loc));
154
161
  }
155
162
  return;
156
163
  }
@@ -159,10 +166,10 @@ async function handleScheduleCommand(args, rootId, chatId, deps, larkAppId) {
159
166
  if (enableMatch) {
160
167
  const id = enableMatch[1];
161
168
  if (scheduler.enableTask(id)) {
162
- await sessionReply(rootId, `已启用定时任务 ${id}`);
169
+ await sessionReply(rootId, t('schedule.enabled', { id }, loc));
163
170
  }
164
171
  else {
165
- await sessionReply(rootId, `未找到任务 ${id}`);
172
+ await sessionReply(rootId, t('schedule.not_found', { id }, loc));
166
173
  }
167
174
  return;
168
175
  }
@@ -171,10 +178,10 @@ async function handleScheduleCommand(args, rootId, chatId, deps, larkAppId) {
171
178
  if (disableMatch) {
172
179
  const id = disableMatch[1];
173
180
  if (scheduler.disableTask(id)) {
174
- await sessionReply(rootId, `已禁用定时任务 ${id}`);
181
+ await sessionReply(rootId, t('schedule.disabled', { id }, loc));
175
182
  }
176
183
  else {
177
- await sessionReply(rootId, `未找到任务 ${id}`);
184
+ await sessionReply(rootId, t('schedule.not_found', { id }, loc));
178
185
  }
179
186
  return;
180
187
  }
@@ -183,10 +190,10 @@ async function handleScheduleCommand(args, rootId, chatId, deps, larkAppId) {
183
190
  if (runMatch) {
184
191
  const id = runMatch[1];
185
192
  if (scheduler.runTaskNow(id)) {
186
- await sessionReply(rootId, `已触发定时任务 ${id} 立即执行`);
193
+ await sessionReply(rootId, t('schedule.triggered_now', { id }, loc));
187
194
  }
188
195
  else {
189
- await sessionReply(rootId, `未找到任务 ${id}`);
196
+ await sessionReply(rootId, t('schedule.not_found', { id }, loc));
190
197
  }
191
198
  return;
192
199
  }
@@ -195,14 +202,10 @@ async function handleScheduleCommand(args, rootId, chatId, deps, larkAppId) {
195
202
  if (parsed) {
196
203
  const ds = larkAppId ? activeSessions.get(sessionKey(rootId, larkAppId)) : undefined;
197
204
  const workingDir = ds?.workingDir ?? (ds?.larkAppId ? getBot(ds.larkAppId).config.workingDir ?? '~' : getAllBots()[0]?.config.workingDir ?? '~');
198
- // For chat-scope sessions, `rootId` here is actually the chatId (the
199
- // session's anchor). The scheduler keys cross-target routing on
200
- // rootMessageId — for chat-scope tasks we set rootMessageId=undefined and
201
- // rely on chatId + scope='chat' to do plain chat sends at fire time.
202
205
  const taskScope = ds?.scope === 'chat' ? 'chat' : 'thread';
203
206
  const task = scheduler.addTask({
204
207
  name: parsed.name,
205
- schedule: trimmed, // raw user input (schedule + prompt blob, kept only for display)
208
+ schedule: trimmed,
206
209
  parsed: parsed.parsed,
207
210
  prompt: parsed.prompt,
208
211
  workingDir,
@@ -213,20 +216,28 @@ async function handleScheduleCommand(args, rootId, chatId, deps, larkAppId) {
213
216
  larkAppId,
214
217
  });
215
218
  const next = scheduler.getNextRun(task.id);
216
- const nextStr = next ? next.toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' }) : 'N/A';
217
- await sessionReply(rootId, `✅ 定时任务已创建!\n\nID: ${task.id}\n名称: ${task.name}\n规则: ${parsed.parsed.display}\nPrompt: ${task.prompt}\n工作目录: ${expandHome(workingDir)}\n下次执行: ${nextStr}`);
219
+ const nextStr = next ? next.toLocaleString(timeLocale, { timeZone }) : 'N/A';
220
+ await sessionReply(rootId, t('schedule.created', {
221
+ id: task.id,
222
+ name: task.name,
223
+ rule: parsed.parsed.display,
224
+ prompt: task.prompt,
225
+ dir: expandHome(workingDir),
226
+ next: nextStr,
227
+ }, loc));
218
228
  return;
219
229
  }
220
230
  // Unrecognized format
221
- await sessionReply(rootId, `无法解析定时任务,请使用自然语言格式:\n\n/schedule 每日17:50 帮我看看今天AI圈有什么新闻\n/schedule 工作日每天9:00 检查服务状态\n/schedule 每周一10:00 生成周报\n/schedule 每小时 检查服务健康状态\n/schedule 每30分钟 ping一下服务\n/schedule 每月1号9:00 生成月报\n\n管理命令:\n/schedule list — 查看所有任务\n/schedule remove <id> — 删除任务\n/schedule enable <id> — 启用任务\n/schedule disable <id> — 禁用任务\n/schedule run <id> — 立即执行一次`);
231
+ await sessionReply(rootId, t('schedule.parse_failed', undefined, loc));
222
232
  }
223
233
  // ─── Main command handler ────────────────────────────────────────────────────
224
234
  export async function handleCommand(cmd, rootId, message, deps, larkAppId) {
225
235
  const { activeSessions, getActiveCount, lastRepoScan } = deps;
226
236
  const sessionReply = (rid, content, msgType) => deps.sessionReply(rid, content, msgType, larkAppId);
227
237
  const ds = larkAppId ? activeSessions.get(sessionKey(rootId, larkAppId)) : undefined;
228
- const t = ds ? tag(ds) : rootId.substring(0, 12);
229
- logger.info(`[${t}] Command: ${cmd}`);
238
+ const logTag = ds ? tag(ds) : rootId.substring(0, 12);
239
+ const loc = localeForBot(ds?.larkAppId ?? larkAppId);
240
+ logger.info(`[${logTag}] Command: ${cmd}`);
230
241
  logger.debug(`repo command`, message);
231
242
  try {
232
243
  switch (cmd) {
@@ -238,10 +249,6 @@ export async function handleCommand(cmd, rootId, message, deps, larkAppId) {
238
249
  const closedCliId = ds.session.cliId ?? botCfg.cliId;
239
250
  const closedAnchor = sessionAnchorId(ds);
240
251
  const closedWorkingDir = ds.session.workingDir;
241
- // Resolve the CLI-native resume command BEFORE killing the worker
242
- // — for codex this consults `~/.codex/history.jsonl` which is
243
- // populated by the live worker; reading it post-kill still works
244
- // (the file lives on disk) but capturing here keeps intent clear.
245
252
  const cliResumeCommand = (() => {
246
253
  try {
247
254
  const adapter = createCliAdapterSync(closedCliId, botCfg.cliPathOverride);
@@ -257,12 +264,12 @@ export async function handleCommand(cmd, rootId, message, deps, larkAppId) {
257
264
  killWorker(ds);
258
265
  sessionStore.closeSession(closedSessionId);
259
266
  activeSessions.delete(sessionKey(rootId, larkAppId));
260
- const card = buildSessionClosedCard(closedSessionId, closedAnchor, closedTitle, closedCliId, closedWorkingDir, cliResumeCommand);
267
+ const card = buildSessionClosedCard(closedSessionId, closedAnchor, closedTitle, closedCliId, closedWorkingDir, cliResumeCommand, loc);
261
268
  await sessionReply(rootId, card, 'interactive');
262
- logger.info(`[${t}] Session closed by /close command`);
269
+ logger.info(`[${logTag}] Session closed by /close command`);
263
270
  }
264
271
  else {
265
- await sessionReply(rootId, '当前话题没有活跃的会话。');
272
+ await sessionReply(rootId, t('cmd.no_active_session', undefined, loc));
266
273
  }
267
274
  break;
268
275
  }
@@ -271,31 +278,31 @@ export async function handleCommand(cmd, rootId, message, deps, larkAppId) {
271
278
  if (ds.worker && !ds.worker.killed) {
272
279
  ds.worker.send({ type: 'restart' });
273
280
  const cliName = getCliDisplayName(getBot(ds.larkAppId).config.cliId);
274
- await sessionReply(rootId, `🔄 正在重启 ${cliName}...`);
281
+ await sessionReply(rootId, t('cmd.restart.in_progress', { cliName }, loc));
275
282
  }
276
283
  else {
277
284
  killWorker(ds);
278
285
  const cliName = getCliDisplayName(getBot(ds.larkAppId).config.cliId);
279
- await sessionReply(rootId, `${cliName} 进程已终止,下次发消息时将自动恢复。`);
286
+ await sessionReply(rootId, t('cmd.restart.terminated', { cliName }, loc));
280
287
  }
281
- logger.info(`[${t}] Restart by /restart command`);
288
+ logger.info(`[${logTag}] Restart by /restart command`);
282
289
  }
283
290
  else {
284
- await sessionReply(rootId, '当前话题没有活跃的会话。');
291
+ await sessionReply(rootId, t('cmd.no_active_session', undefined, loc));
285
292
  }
286
293
  break;
287
294
  }
288
295
  case '/cd': {
289
296
  const targetPath = message.content.replace(/^\/cd\s*/, '').trim();
290
297
  if (!targetPath) {
291
- await sessionReply(rootId, '用法:/cd <path>\n例如:/cd ~/projects/my-app');
298
+ await sessionReply(rootId, t('cmd.cd.usage', undefined, loc));
292
299
  break;
293
300
  }
294
301
  if (!ds) {
295
- await sessionReply(rootId, '当前话题没有活跃的会话。');
302
+ await sessionReply(rootId, t('cmd.no_active_session', undefined, loc));
296
303
  break;
297
304
  }
298
- const validation = validateWorkingDir(targetPath);
305
+ const validation = validateWorkingDir(targetPath, loc);
299
306
  if (!validation.ok) {
300
307
  await sessionReply(rootId, validation.error);
301
308
  break;
@@ -305,22 +312,21 @@ export async function handleCommand(cmd, rootId, message, deps, larkAppId) {
305
312
  ds.workingDir = targetPath;
306
313
  ds.session.workingDir = targetPath;
307
314
  sessionStore.updateSession(ds.session);
308
- await sessionReply(rootId, `工作目录已切换到 ${resolvedPath},下次发消息时将在新目录下恢复。`);
309
- logger.info(`[${t}] Working directory changed to ${resolvedPath} by /cd command`);
315
+ await sessionReply(rootId, t('cmd.cd.switched', { path: resolvedPath }, loc));
316
+ logger.info(`[${logTag}] Working directory changed to ${resolvedPath} by /cd command`);
310
317
  break;
311
318
  }
312
319
  case '/repo': {
313
320
  const repoArg = message.content.replace(/^\/repo\s*/, '').trim();
314
321
  const repoIndex = repoArg ? parseInt(repoArg, 10) : NaN;
315
- // /repo <N> — quick select from last scan
316
322
  if (!isNaN(repoIndex) && ds) {
317
323
  const cached = lastRepoScan.get(ds.chatId);
318
324
  if (!cached || cached.length === 0) {
319
- await sessionReply(rootId, '请先执行 /repo 查看项目列表。');
325
+ await sessionReply(rootId, t('cmd.repo.no_prior_scan', undefined, loc));
320
326
  break;
321
327
  }
322
328
  if (repoIndex < 1 || repoIndex > cached.length) {
323
- await sessionReply(rootId, `序号超出范围,有效范围:1-${cached.length}`);
329
+ await sessionReply(rootId, t('cmd.repo.index_out_of_range', { max: cached.length }, loc));
324
330
  break;
325
331
  }
326
332
  const project = cached[repoIndex - 1];
@@ -334,61 +340,55 @@ export async function handleCommand(cmd, rootId, message, deps, larkAppId) {
334
340
  const botCfg = selfBot.config;
335
341
  ds.pendingRepo = false;
336
342
  const { buildNewTopicPrompt, getAvailableBots } = await import('./session-manager.js');
337
- const prompt = buildNewTopicPrompt(ds.pendingPrompt ?? '', ds.session.sessionId, botCfg.cliId, botCfg.cliPathOverride, ds.pendingAttachments, ds.pendingMentions, await getAvailableBots(ds.larkAppId, ds.chatId), ds.pendingFollowUps, { name: selfBot.botName, openId: selfBot.botOpenId });
343
+ const prompt = buildNewTopicPrompt(ds.pendingPrompt ?? '', ds.session.sessionId, botCfg.cliId, botCfg.cliPathOverride, ds.pendingAttachments, ds.pendingMentions, await getAvailableBots(ds.larkAppId, ds.chatId), ds.pendingFollowUps, { name: selfBot.botName, openId: selfBot.botOpenId }, loc);
338
344
  ds.pendingPrompt = undefined;
339
345
  ds.pendingAttachments = undefined;
340
346
  ds.pendingMentions = undefined;
341
347
  ds.pendingFollowUps = undefined;
342
348
  forkWorker(ds, prompt);
343
- await sessionReply(rootId, `✅ 已选择 ${displayName}`);
349
+ await sessionReply(rootId, t('cmd.repo.selected_in_pending', { name: displayName }, loc));
344
350
  }
345
351
  else {
346
352
  killWorker(ds);
347
353
  sessionStore.closeSession(ds.session.sessionId);
348
354
  const session = sessionStore.createSession(ds.chatId, rootId, displayName, ds.chatType);
349
355
  ds.session = session;
350
- // Pin workingDir + larkAppId onto the freshly-created record before
351
- // forkWorker — otherwise a daemon restart restores the session with
352
- // an empty workingDir and falls back to the bot's default cwd,
353
- // making `claude --resume` look in the wrong .claude/projects/ dir.
354
356
  ds.session.workingDir = selectedPath;
355
357
  ds.session.larkAppId = ds.larkAppId;
356
358
  sessionStore.updateSession(ds.session);
357
359
  ds.hasHistory = false;
358
360
  forkWorker(ds, '', false);
359
- await sessionReply(rootId, `🔄 已切换到 ${displayName}`);
361
+ await sessionReply(rootId, t('cmd.repo.switched_to', { name: displayName }, loc));
360
362
  }
361
- // Withdraw repo selection card
362
363
  if (ds.repoCardMessageId) {
363
364
  deleteMessage(ds.larkAppId, ds.repoCardMessageId);
364
365
  ds.repoCardMessageId = undefined;
365
366
  }
366
- logger.info(`[${t}] Repo selected via /repo ${repoIndex}: ${selectedPath}`);
367
+ logger.info(`[${logTag}] Repo selected via /repo ${repoIndex}: ${selectedPath}`);
367
368
  break;
368
369
  }
369
- // /repo — show project list card
370
370
  if (ds?.worker && !ds.worker.killed) {
371
- await sessionReply(rootId, '⚠️ 当前会话已在运行中,切换仓库将关闭当前会话并创建新会话。\n如需切换,请在下方卡片中选择新仓库。');
371
+ await sessionReply(rootId, t('cmd.repo.warning_running', undefined, loc));
372
372
  }
373
373
  const scanDirs = getProjectScanDirs(ds);
374
374
  const validDirs = scanDirs.filter(d => existsSync(d));
375
375
  if (validDirs.length === 0) {
376
- await sessionReply(rootId, `扫描目录不存在:${scanDirs.join(', ')}\n请设置 PROJECT_SCAN_DIR 或 WORKING_DIR 环境变量。`);
376
+ await sessionReply(rootId, t('cmd.repo.scan_dir_not_exist', { dirs: scanDirs.join(', ') }, loc));
377
377
  break;
378
378
  }
379
379
  const projects = scanMultipleProjects(validDirs);
380
380
  if (projects.length === 0) {
381
- await sessionReply(rootId, `在 ${validDirs.join(', ')} 下未找到 git 仓库。`);
381
+ await sessionReply(rootId, t('cmd.repo.no_git_repos', { dirs: validDirs.join(', ') }, loc));
382
382
  break;
383
383
  }
384
384
  if (ds)
385
385
  lastRepoScan.set(ds.chatId, projects);
386
386
  const currentCwd = getSessionWorkingDir(ds);
387
- const cardJson = buildRepoSelectCard(projects, currentCwd, rootId);
387
+ const cardJson = buildRepoSelectCard(projects, currentCwd, rootId, loc);
388
388
  const repoCardMsgId = await sessionReply(rootId, cardJson, 'interactive');
389
389
  if (ds)
390
390
  ds.repoCardMessageId = repoCardMsgId;
391
- logger.info(`[${t}] Sent repo card with ${projects.length} project(s)`);
391
+ logger.info(`[${logTag}] Sent repo card with ${projects.length} project(s)`);
392
392
  break;
393
393
  }
394
394
  case '/skip': {
@@ -397,22 +397,22 @@ export async function handleCommand(cmd, rootId, message, deps, larkAppId) {
397
397
  const botCfg = selfBot.config;
398
398
  ds.pendingRepo = false;
399
399
  const { buildNewTopicPrompt, getAvailableBots } = await import('./session-manager.js');
400
- const prompt = buildNewTopicPrompt(ds.pendingPrompt ?? '', ds.session.sessionId, botCfg.cliId, botCfg.cliPathOverride, ds.pendingAttachments, ds.pendingMentions, await getAvailableBots(ds.larkAppId, ds.chatId), ds.pendingFollowUps, { name: selfBot.botName, openId: selfBot.botOpenId });
400
+ const prompt = buildNewTopicPrompt(ds.pendingPrompt ?? '', ds.session.sessionId, botCfg.cliId, botCfg.cliPathOverride, ds.pendingAttachments, ds.pendingMentions, await getAvailableBots(ds.larkAppId, ds.chatId), ds.pendingFollowUps, { name: selfBot.botName, openId: selfBot.botOpenId }, loc);
401
401
  ds.pendingPrompt = undefined;
402
402
  ds.pendingAttachments = undefined;
403
403
  ds.pendingMentions = undefined;
404
404
  ds.pendingFollowUps = undefined;
405
405
  forkWorker(ds, prompt);
406
406
  const cwd = getSessionWorkingDir(ds);
407
- await sessionReply(rootId, `▶️ 已直接开启会话(工作目录:${cwd})`);
407
+ await sessionReply(rootId, t('cmd.skip.opened', { cwd }, loc));
408
408
  if (ds.repoCardMessageId) {
409
409
  deleteMessage(ds.larkAppId, ds.repoCardMessageId);
410
410
  ds.repoCardMessageId = undefined;
411
411
  }
412
- logger.info(`[${t}] Skip repo via /skip, spawning CLI in ${cwd}`);
412
+ logger.info(`[${logTag}] Skip repo via /skip, spawning CLI in ${cwd}`);
413
413
  }
414
414
  else {
415
- await sessionReply(rootId, '当前没有待选择的仓库。');
415
+ await sessionReply(rootId, t('cmd.skip.no_pending', undefined, loc));
416
416
  }
417
417
  break;
418
418
  }
@@ -423,7 +423,7 @@ export async function handleCommand(cmd, rootId, message, deps, larkAppId) {
423
423
  const termUrl = ds.workerPort ? `http://${config.web.externalHost}:${ds.workerPort}` : '-';
424
424
  const lines = [
425
425
  `Session: ${ds.session.sessionId}`,
426
- `Status: ${alive ? '运行中' : '等待中'}`,
426
+ `Status: ${alive ? t('cmd.status.running', undefined, loc) : t('cmd.status.waiting', undefined, loc)}`,
427
427
  `Terminal: ${termUrl}`,
428
428
  `CWD: ${getSessionWorkingDir(ds)}`,
429
429
  `${getCliDisplayName(getBot(ds.larkAppId).config.cliId)}: v${ds.cliVersion}${ds.cliVersion !== getCurrentCliVersion() ? ` (latest: v${getCurrentCliVersion()})` : ''}`,
@@ -435,7 +435,11 @@ export async function handleCommand(cmd, rootId, message, deps, larkAppId) {
435
435
  }
436
436
  else {
437
437
  const fallbackCliName = larkAppId ? getCliDisplayName(getBot(larkAppId).config.cliId) : 'CLI';
438
- await sessionReply(rootId, `当前话题没有活跃的会话。\nDaemon active sessions: ${getActiveCount()}\n${fallbackCliName}: v${getCurrentCliVersion()}`);
438
+ await sessionReply(rootId, t('cmd.status.fallback_no_session', {
439
+ count: getActiveCount(),
440
+ cliName: fallbackCliName,
441
+ version: getCurrentCliVersion(),
442
+ }, loc));
439
443
  }
440
444
  break;
441
445
  }
@@ -443,7 +447,7 @@ export async function handleCommand(cmd, rootId, message, deps, larkAppId) {
443
447
  const scheduleArgs = message.content.replace(/^\/schedule\s*/, '');
444
448
  const chatId = ds?.chatId;
445
449
  await handleScheduleCommand(scheduleArgs, rootId, chatId, deps, larkAppId);
446
- logger.info(`[${t}] Schedule command handled`);
450
+ logger.info(`[${logTag}] Schedule command handled`);
447
451
  break;
448
452
  }
449
453
  case '/login': {
@@ -452,63 +456,54 @@ export async function handleCommand(cmd, rootId, message, deps, larkAppId) {
452
456
  await sessionReply(rootId, getTokenStatus());
453
457
  break;
454
458
  }
455
- // Generate OAuth URL
456
459
  const botCfg2 = ds ? getBot(ds.larkAppId).config : (larkAppId ? getBot(larkAppId).config : getAllBots()[0]?.config);
457
460
  if (!botCfg2?.larkAppId || !botCfg2?.larkAppSecret) {
458
- await sessionReply(rootId, ' 无法获取应用凭证');
461
+ await sessionReply(rootId, t('cmd.login.no_credentials', undefined, loc));
459
462
  break;
460
463
  }
461
464
  const { authUrl } = generateAuthUrl(botCfg2.larkAppId, botCfg2.larkAppSecret);
462
465
  await sessionReply(rootId, [
463
- '🔐 飞书用户授权',
466
+ t('cmd.login.title', undefined, loc),
464
467
  '',
465
- '1. 点击下方链接完成授权:',
468
+ t('cmd.login.step1', undefined, loc),
466
469
  authUrl,
467
470
  '',
468
- '2. 授权后浏览器会跳转到一个打不开的页面(正常)',
469
- '3. 复制浏览器地址栏的完整 URL,发送到本话题',
471
+ t('cmd.login.step2', undefined, loc),
472
+ t('cmd.login.step3', undefined, loc),
470
473
  '',
471
- '授权后可下载第三方卡片中的图片等资源。',
472
- '查看状态:/login status',
474
+ t('cmd.login.footer', undefined, loc),
475
+ t('cmd.login.status_hint', undefined, loc),
473
476
  ].join('\n'));
474
477
  break;
475
478
  }
476
479
  case '/adopt': {
477
480
  const adoptArgs = message.content.replace(/^\/adopt\s*/i, '').trim();
478
- // Refuse re-adopt when the thread is already bridged. Otherwise the
479
- // user sees the misleading "未发现可接入 CLI 会话" branch whenever the
480
- // discovery scan happens to return zero (e.g. the original CLI exited
481
- // mid-bridge, or pane filters mismatch) — they have no idea why their
482
- // working session was "lost". Force the explicit 断开 → /adopt swap.
483
481
  if (ds?.adoptedFrom) {
484
482
  const adopted = ds.adoptedFrom;
485
483
  const cliName = getCliDisplayName(adopted.cliId ?? 'claude-code');
486
484
  const project = adopted.cwd ? (adopted.cwd.split('/').pop() || adopted.cwd) : '';
487
485
  const label = project ? `${cliName} · ${project}` : cliName;
488
- await sessionReply(rootId, `本话题已接入 ${label} (${adopted.tmuxTarget})。\n` +
489
- '请先点击卡片上的「断开」按钮,再 /adopt 切换 CLI 会话(原 CLI 不受影响)。');
486
+ await sessionReply(rootId, t('cmd.adopt.already_adopted', { label, pane: adopted.tmuxTarget }, loc));
490
487
  break;
491
488
  }
492
- // Only show sessions matching this bot's CLI type
493
489
  const botCliId = ds ? getBot(ds.larkAppId).config.cliId : undefined;
494
490
  const sessions = discoverAdoptableSessions(botCliId);
495
491
  if (sessions.length === 0) {
496
- await sessionReply(rootId, '未发现可接入的 CLI 会话');
492
+ await sessionReply(rootId, t('cmd.adopt.no_sessions', undefined, loc));
497
493
  break;
498
494
  }
499
495
  const directTarget = adoptArgs;
500
496
  if (directTarget) {
501
497
  const target = sessions.find(s => s.tmuxTarget === directTarget);
502
498
  if (!target) {
503
- await sessionReply(rootId, `未找到 tmux pane ${directTarget}`);
499
+ await sessionReply(rootId, t('cmd.adopt.pane_not_found', { pane: directTarget }, loc));
504
500
  break;
505
501
  }
506
502
  if (ds)
507
503
  await startAdoptSession(target, ds, deps, larkAppId);
508
504
  break;
509
505
  }
510
- // Show selection card
511
- const cardJson = buildAdoptSelectCard(sessions, rootId);
506
+ const cardJson = buildAdoptSelectCard(sessions, rootId, loc);
512
507
  await sessionReply(rootId, cardJson, 'interactive');
513
508
  break;
514
509
  }
@@ -518,40 +513,26 @@ export async function handleCommand(cmd, rootId, message, deps, larkAppId) {
518
513
  const appId = larkAppId ?? ds?.larkAppId;
519
514
  const chatId = ds?.chatId;
520
515
  if (!appId || !chatId) {
521
- await sessionReply(rootId, '/oncall 需要在群聊中、以新话题方式使用。');
516
+ await sessionReply(rootId, t('cmd.oncall.need_group', undefined, loc));
522
517
  break;
523
518
  }
524
519
  if (!sub || sub === 'status' || sub === '状态') {
525
520
  const entry = getOncallStatus(appId, chatId);
526
521
  if (!entry) {
527
- await sessionReply(rootId, [
528
- '当前群尚未绑定 oncall 项目。',
529
- '',
530
- '用法:',
531
- '/oncall bind <path> — 绑定当前群到某个项目目录,跳过仓库选择卡片',
532
- '/oncall unbind — 解除当前群的 oncall 绑定',
533
- '/oncall status — 查看当前绑定状态',
534
- '',
535
- '绑定后:群内任何成员都可以 @ 机器人提问;仅 allowedUsers 能点卡片按钮、执行 /cd /restart /close 等命令。',
536
- ].join('\n'));
522
+ await sessionReply(rootId, t('cmd.oncall.not_bound', undefined, loc));
537
523
  }
538
524
  else {
539
- await sessionReply(rootId, [
540
- '🟢 已绑定 oncall',
541
- `工作目录:${entry.workingDir}`,
542
- '',
543
- '/oncall unbind 可解除绑定;/cd <path> 切换工作目录(仍保留 oncall 模式)。',
544
- ].join('\n'));
525
+ await sessionReply(rootId, t('cmd.oncall.bound', { dir: entry.workingDir }, loc));
545
526
  }
546
527
  break;
547
528
  }
548
529
  if (sub === 'bind' || sub === '绑定') {
549
530
  const target = rest.join(' ').trim();
550
531
  if (!target) {
551
- await sessionReply(rootId, '用法:/oncall bind <path>\n例如:/oncall bind ~/projects/payments-service');
532
+ await sessionReply(rootId, t('cmd.oncall.bind_usage', undefined, loc));
552
533
  break;
553
534
  }
554
- const validation = validateWorkingDir(target);
535
+ const validation = validateWorkingDir(target, loc);
555
536
  if (!validation.ok) {
556
537
  await sessionReply(rootId, validation.error);
557
538
  break;
@@ -560,82 +541,81 @@ export async function handleCommand(cmd, rootId, message, deps, larkAppId) {
560
541
  const result = await bindOncall(appId, chatId, target);
561
542
  if (!result.ok) {
562
543
  if (result.reason === 'bot_not_in_config') {
563
- await sessionReply(rootId, '⚠️ 无法在配置文件中找到当前机器人条目,绑定失败。');
544
+ await sessionReply(rootId, t('cmd.oncall.bind_failed_no_bot', undefined, loc));
564
545
  }
565
546
  else {
566
- await sessionReply(rootId, `⚠️ 绑定失败:${result.reason}`);
547
+ await sessionReply(rootId, t('cmd.oncall.bind_failed', { reason: result.reason }, loc));
567
548
  }
568
549
  break;
569
550
  }
570
- const verb = result.created ? '已绑定' : '已更新';
571
- await sessionReply(rootId, [
572
- `✅ ${verb} oncall`,
573
- `群:${chatId}`,
574
- `工作目录:${target} → ${resolvedPath}`,
575
- '',
576
- '下次在本群开新话题时会直接用此目录启动 CLI,不再弹仓库选择卡片。',
577
- ].join('\n'));
578
- logger.info(`[${t}] /oncall bind chat=${chatId} dir=${target}`);
551
+ const verb = result.created
552
+ ? t('cmd.oncall.verb_bound', undefined, loc)
553
+ : t('cmd.oncall.verb_updated', undefined, loc);
554
+ await sessionReply(rootId, t('cmd.oncall.bind_success', {
555
+ verb,
556
+ chatId,
557
+ target,
558
+ resolved: resolvedPath,
559
+ }, loc));
560
+ logger.info(`[${logTag}] /oncall bind chat=${chatId} dir=${target}`);
579
561
  break;
580
562
  }
581
563
  if (sub === 'unbind' || sub === '解绑') {
582
564
  const result = await unbindOncall(appId, chatId);
583
565
  if (!result.ok) {
584
- await sessionReply(rootId, `⚠️ 解绑失败:${result.reason}`);
566
+ await sessionReply(rootId, t('cmd.oncall.unbind_failed', { reason: result.reason }, loc));
585
567
  break;
586
568
  }
587
569
  if (!result.wasBound) {
588
- // Tombstone was still written — surface that softly so users
589
- // understand subsequent default-oncall won't re-bind this chat.
590
- await sessionReply(rootId, '当前群未绑定 oncall。(已记录解绑意图,default-oncall 不会再自动绑此群)');
570
+ await sessionReply(rootId, t('cmd.oncall.unbind_not_bound', undefined, loc));
591
571
  }
592
572
  else {
593
- await sessionReply(rootId, ' 已解除 oncall 绑定。下次开新话题将恢复默认仓库选择卡片流程。');
573
+ await sessionReply(rootId, t('cmd.oncall.unbind_success', undefined, loc));
594
574
  }
595
- logger.info(`[${t}] /oncall unbind chat=${chatId} wasBound=${result.wasBound}`);
575
+ logger.info(`[${logTag}] /oncall unbind chat=${chatId} wasBound=${result.wasBound}`);
596
576
  break;
597
577
  }
598
- await sessionReply(rootId, `未知子命令:${sub}\n支持:/oncall bind <path> | /oncall unbind | /oncall status`);
578
+ await sessionReply(rootId, t('cmd.oncall.unknown_sub', { sub }, loc));
599
579
  break;
600
580
  }
601
581
  case '/help': {
602
582
  const botCfg = ds ? getBot(ds.larkAppId).config : getAllBots()[0]?.config;
603
583
  const cliName = getCliDisplayName(botCfg?.cliId ?? 'claude-code');
604
584
  const help = [
605
- '📌 会话管理:',
606
- `/close - 关闭当前会话,终止 ${cliName} 进程`,
607
- `/restart - 重启 ${cliName} 进程(保留 session)`,
608
- `/cd <path> - 切换工作目录并重启 ${cliName} 进程`,
609
- '/repo - 查看项目列表(交互式下拉 + 文本列表)',
610
- '/repo <N> - 切换到第 N 个项目',
611
- '/status - 查看当前会话状态(含终端链接)',
585
+ t('help.heading_session', undefined, loc),
586
+ t('help.close', { cliName }, loc),
587
+ t('help.restart', { cliName }, loc),
588
+ t('help.cd', { cliName }, loc),
589
+ t('help.repo_list', undefined, loc),
590
+ t('help.repo_n', undefined, loc),
591
+ t('help.status', undefined, loc),
612
592
  '',
613
- `🔀 透传给 ${cliName}(字面送达,供其内置 slash 命令处理):`,
593
+ t('help.heading_passthrough', { cliName }, loc),
614
594
  '/compact /model /clear /plugin /usage',
615
595
  '',
616
- '⏰ 定时任务:',
617
- '/schedule 每日17:50 帮我看AI新闻 - 创建定时任务(自然语言)',
618
- '/schedule list - 查看所有定时任务',
619
- '/schedule remove <id> - 删除任务',
620
- '/schedule enable/disable <id> - 启用/禁用任务',
621
- '/schedule run <id> - 立即执行一次',
596
+ t('help.heading_schedule', undefined, loc),
597
+ t('help.schedule_create', undefined, loc),
598
+ t('help.schedule_list', undefined, loc),
599
+ t('help.schedule_remove', undefined, loc),
600
+ t('help.schedule_toggle', undefined, loc),
601
+ t('help.schedule_run', undefined, loc),
622
602
  '',
623
- '支持的时间格式:每日/每天、每周X、每月X号、工作日每天、每N小时、每N分钟',
603
+ t('help.schedule_formats', undefined, loc),
624
604
  '',
625
- '📡 会话接入:',
626
- '/adopt - 接入本机正在运行的 CLI 会话',
627
- '/adopt <tmux_pane> - 直接接入指定 tmux pane',
605
+ t('help.heading_adopt', undefined, loc),
606
+ t('help.adopt', undefined, loc),
607
+ t('help.adopt_pane', undefined, loc),
628
608
  '',
629
- '🔐 用户授权:',
630
- '/login - 飞书用户授权(可下载第三方卡片图片等)',
631
- '/login status - 查看授权状态',
609
+ t('help.heading_login', undefined, loc),
610
+ t('help.login', undefined, loc),
611
+ t('help.login_status', undefined, loc),
632
612
  '',
633
- '🛎️ Oncall 模式(群聊):',
634
- '/oncall bind <path> - 把当前群绑到某个项目,跳过仓库选择卡片',
635
- '/oncall unbind - 解绑当前群',
636
- '/oncall status - 查看当前群的 oncall 绑定',
613
+ t('help.heading_oncall', undefined, loc),
614
+ t('help.oncall_bind', undefined, loc),
615
+ t('help.oncall_unbind', undefined, loc),
616
+ t('help.oncall_status', undefined, loc),
637
617
  '',
638
- '/help - 显示此帮助',
618
+ t('help.help', undefined, loc),
639
619
  ];
640
620
  await sessionReply(rootId, help.join('\n'));
641
621
  break;
@@ -643,19 +623,18 @@ export async function handleCommand(cmd, rootId, message, deps, larkAppId) {
643
623
  }
644
624
  }
645
625
  catch (err) {
646
- logger.error(`[${t}] Command ${cmd} error: ${err.message}`);
626
+ logger.error(`[${logTag}] Command ${cmd} error: ${err.message}`);
647
627
  }
648
628
  }
649
629
  // ─── Adopt session helper ────────────────────────────────────────────────────
650
630
  export async function startAdoptSession(target, ds, deps, larkAppId) {
651
631
  const sessionReply = (rid, content, msgType) => deps.sessionReply(rid, content, msgType, larkAppId);
652
- // Validate target is still alive
632
+ const loc = localeForBot(ds.larkAppId ?? larkAppId);
653
633
  if (!validateAdoptTarget(target.tmuxTarget, target.cliPid)) {
654
- await sessionReply(sessionAnchorId(ds), '⚠️ 目标 CLI 会话已退出');
634
+ await sessionReply(sessionAnchorId(ds), t('cmd.adopt.target_exited', undefined, loc));
655
635
  return;
656
636
  }
657
637
  const project = target.cwd.split('/').pop() || target.cwd;
658
- // Update the existing DaemonSession with adopt info
659
638
  ds.workingDir = target.cwd;
660
639
  ds.session.workingDir = target.cwd;
661
640
  ds.session.title = `Adopt: ${project}`;
@@ -668,11 +647,10 @@ export async function startAdoptSession(target, ds, deps, larkAppId) {
668
647
  paneCols: target.paneCols,
669
648
  paneRows: target.paneRows,
670
649
  };
671
- // Persist adopt metadata so the session can be restored after daemon restart
672
650
  ds.session.adoptedFrom = { ...ds.adoptedFrom };
673
651
  sessionStore.updateSession(ds.session);
674
652
  forkAdoptWorker(ds);
675
653
  const cliName = getCliDisplayName(target.cliId);
676
- await sessionReply(sessionAnchorId(ds), `📡 已接入 ${cliName} · ${project} (${target.tmuxTarget})`);
654
+ await sessionReply(sessionAnchorId(ds), t('cmd.adopt.success', { cliName, project, pane: target.tmuxTarget }, loc));
677
655
  }
678
656
  //# sourceMappingURL=command-handler.js.map