bingocode 1.0.29 → 1.0.31

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 (52) hide show
  1. package/adapters/common/__tests__/chat-queue.test.ts +61 -0
  2. package/adapters/common/__tests__/format.test.ts +148 -0
  3. package/adapters/common/__tests__/http-client.test.ts +105 -0
  4. package/adapters/common/__tests__/message-buffer.test.ts +84 -0
  5. package/adapters/common/__tests__/message-dedup.test.ts +57 -0
  6. package/adapters/common/__tests__/session-store.test.ts +62 -0
  7. package/adapters/common/__tests__/ws-bridge.test.ts +177 -0
  8. package/adapters/common/attachment/__tests__/attachment-limits.test.ts +52 -0
  9. package/adapters/common/attachment/__tests__/attachment-store.test.ts +108 -0
  10. package/adapters/common/attachment/__tests__/image-block-watcher.test.ts +115 -0
  11. package/adapters/feishu/__tests__/card-errors.test.ts +194 -0
  12. package/adapters/feishu/__tests__/cardkit.test.ts +295 -0
  13. package/adapters/feishu/__tests__/extract-payload.test.ts +77 -0
  14. package/adapters/feishu/__tests__/feishu.test.ts +907 -0
  15. package/adapters/feishu/__tests__/flush-controller.test.ts +290 -0
  16. package/adapters/feishu/__tests__/markdown-style.test.ts +353 -0
  17. package/adapters/feishu/__tests__/media.test.ts +120 -0
  18. package/adapters/feishu/__tests__/streaming-card.test.ts +914 -0
  19. package/adapters/telegram/__tests__/media.test.ts +86 -0
  20. package/adapters/telegram/__tests__/telegram.test.ts +115 -0
  21. package/bin/bingo-win.cjs +26 -0
  22. package/bin/bingocode-win.cjs +55 -3
  23. package/bin/claude-win.cjs +55 -3
  24. package/package.json +1 -1
  25. package/src/entrypoints/cli.tsx +4 -2
  26. package/src/manager/CliMenuManager.tsx +48 -17
  27. package/src/server/__tests__/conversation-service.test.ts +173 -0
  28. package/src/server/__tests__/conversations.test.ts +458 -0
  29. package/src/server/__tests__/cron-scheduler.test.ts +575 -0
  30. package/src/server/__tests__/e2e/business-flow.test.ts +841 -0
  31. package/src/server/__tests__/e2e/full-flow.test.ts +357 -0
  32. package/src/server/__tests__/fixtures/mock-sdk-cli.ts +123 -0
  33. package/src/server/__tests__/haha-oauth-api.test.ts +146 -0
  34. package/src/server/__tests__/haha-oauth-service.test.ts +185 -0
  35. package/src/server/__tests__/providers-real.test.ts +244 -0
  36. package/src/server/__tests__/providers.test.ts +579 -0
  37. package/src/server/__tests__/proxy-streaming.test.ts +317 -0
  38. package/src/server/__tests__/proxy-transform.test.ts +469 -0
  39. package/src/server/__tests__/real-llm-test.ts +526 -0
  40. package/src/server/__tests__/scheduled-tasks.test.ts +371 -0
  41. package/src/server/__tests__/sessions.test.ts +786 -0
  42. package/src/server/__tests__/settings.test.ts +376 -0
  43. package/src/server/__tests__/skills.test.ts +125 -0
  44. package/src/server/__tests__/tasks.test.ts +171 -0
  45. package/src/server/__tests__/team-watcher.test.ts +400 -0
  46. package/src/server/__tests__/teams.test.ts +627 -0
  47. package/src/server/ensureSingletonLocalServer.ts +1 -1
  48. package/src/server/middleware/cors.test.ts +27 -0
  49. package/src/utils/__tests__/cronFrequency.test.ts +153 -0
  50. package/src/utils/__tests__/cronTasks.test.ts +204 -0
  51. package/src/utils/computerUse/permissions.test.ts +44 -0
  52. package/src/utils/config.ts +15 -0
@@ -0,0 +1,86 @@
1
+ import { describe, it, expect, beforeEach, afterEach, mock } from 'bun:test'
2
+ import * as fs from 'node:fs/promises'
3
+ import * as path from 'node:path'
4
+ import * as os from 'node:os'
5
+ import { TelegramMediaService } from '../media.js'
6
+ import { AttachmentStore } from '../../common/attachment/attachment-store.js'
7
+
8
+ let tmpRoot: string
9
+ let originalFetch: typeof fetch
10
+
11
+ function makeMockBot() {
12
+ const fetchMock = mock(async (url: string | URL) => {
13
+ const u = typeof url === 'string' ? url : url.toString()
14
+ expect(u).toContain('/file/botFAKE_TOKEN/photos/abc.jpg')
15
+ return new Response(Buffer.from('PHOTODATA'), {
16
+ status: 200,
17
+ headers: { 'content-type': 'image/jpeg' },
18
+ })
19
+ })
20
+ ;(globalThis as any).fetch = fetchMock
21
+ return {
22
+ token: 'FAKE_TOKEN',
23
+ api: {
24
+ getFile: mock(async (fileId: string) => ({
25
+ file_id: fileId,
26
+ file_unique_id: 'unique',
27
+ file_path: 'photos/abc.jpg',
28
+ })),
29
+ sendPhoto: mock(async () => ({ message_id: 1 })),
30
+ sendDocument: mock(async () => ({ message_id: 2 })),
31
+ },
32
+ fetchMock,
33
+ }
34
+ }
35
+
36
+ beforeEach(async () => {
37
+ originalFetch = globalThis.fetch
38
+ tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'tg-media-test-'))
39
+ })
40
+
41
+ afterEach(async () => {
42
+ globalThis.fetch = originalFetch
43
+ await fs.rm(tmpRoot, { recursive: true, force: true })
44
+ })
45
+
46
+ describe('TelegramMediaService', () => {
47
+ it('downloadFile fetches the real URL and stores a LocalAttachment', async () => {
48
+ const bot = makeMockBot()
49
+ const store = new AttachmentStore({ root: tmpRoot, retentionMs: 60_000 })
50
+ const svc = new TelegramMediaService(bot as any, store)
51
+ const local = await svc.downloadFile('fid_123', 'sess-1', {
52
+ fileName: 'abc.jpg',
53
+ mimeType: 'image/jpeg',
54
+ })
55
+ expect(local.kind).toBe('image')
56
+ expect(local.name).toBe('abc.jpg')
57
+ expect(local.size).toBe('PHOTODATA'.length)
58
+ expect(local.buffer.toString()).toBe('PHOTODATA')
59
+ const onDisk = await fs.readFile(local.path)
60
+ expect(onDisk.toString()).toBe('PHOTODATA')
61
+ })
62
+
63
+ it('sendPhoto calls bot.api.sendPhoto with InputFile-like payload', async () => {
64
+ const bot = makeMockBot()
65
+ const store = new AttachmentStore({ root: tmpRoot, retentionMs: 60_000 })
66
+ const svc = new TelegramMediaService(bot as any, store)
67
+ await svc.sendPhoto(42, Buffer.from('IMG'), 'caption text')
68
+ expect(bot.api.sendPhoto).toHaveBeenCalledTimes(1)
69
+ const args = (bot.api.sendPhoto as any).mock.calls[0]
70
+ expect(args[0]).toBe(42)
71
+ // grammY InputFile wraps the buffer; just verify it's an object.
72
+ expect(args[1]).toBeDefined()
73
+ expect(args[2]?.caption).toBe('caption text')
74
+ })
75
+
76
+ it('sendDocument calls bot.api.sendDocument', async () => {
77
+ const bot = makeMockBot()
78
+ const store = new AttachmentStore({ root: tmpRoot, retentionMs: 60_000 })
79
+ const svc = new TelegramMediaService(bot as any, store)
80
+ await svc.sendDocument(42, Buffer.from('DOC'), 'spec.pdf')
81
+ expect(bot.api.sendDocument).toHaveBeenCalledTimes(1)
82
+ const args = (bot.api.sendDocument as any).mock.calls[0]
83
+ expect(args[0]).toBe(42)
84
+ expect(args[1]).toBeDefined()
85
+ })
86
+ })
@@ -0,0 +1,115 @@
1
+ import { describe, it, expect } from 'bun:test'
2
+ import { splitMessage, formatPermissionRequest, truncateInput, escapeMarkdownV2 } from '../../common/format.js'
3
+
4
+ /**
5
+ * Telegram Adapter 翻译逻辑测试
6
+ *
7
+ * 由于 grammy Bot 需要实际 Token 才能初始化,
8
+ * 这里测试的是不依赖 Bot 实例的核心翻译逻辑。
9
+ */
10
+
11
+ describe('Telegram message formatting', () => {
12
+ describe('long message splitting', () => {
13
+ it('splits messages at Telegram 4096 char limit', () => {
14
+ const longText = 'a'.repeat(8000)
15
+ const chunks = splitMessage(longText, 4000)
16
+ expect(chunks.length).toBe(2)
17
+ expect(chunks[0]!.length).toBeLessThanOrEqual(4000)
18
+ expect(chunks[1]!.length).toBeLessThanOrEqual(4000)
19
+ })
20
+
21
+ it('keeps short messages as single chunk', () => {
22
+ const chunks = splitMessage('Hello World', 4000)
23
+ expect(chunks).toEqual(['Hello World'])
24
+ })
25
+
26
+ it('splits at paragraph boundary when possible', () => {
27
+ const text = 'A'.repeat(2000) + '\n\n' + 'B'.repeat(2000)
28
+ const chunks = splitMessage(text, 3000)
29
+ expect(chunks.length).toBe(2)
30
+ })
31
+ })
32
+
33
+ describe('permission request formatting', () => {
34
+ it('formats Bash command request', () => {
35
+ const result = formatPermissionRequest('Bash', { command: 'npm test' }, 'abcde')
36
+ expect(result).toContain('🔐')
37
+ expect(result).toContain('Bash')
38
+ expect(result).toContain('npm test')
39
+ expect(result).toContain('abcde')
40
+ })
41
+
42
+ it('formats Write file request', () => {
43
+ const result = formatPermissionRequest(
44
+ 'Write',
45
+ { file_path: '/src/index.ts', content: 'console.log("hello")' },
46
+ 'fghij',
47
+ )
48
+ expect(result).toContain('Write')
49
+ expect(result).toContain('index.ts')
50
+ expect(result).toContain('fghij')
51
+ })
52
+
53
+ it('truncates long input in permission request', () => {
54
+ const longInput = { command: 'x'.repeat(500) }
55
+ const result = formatPermissionRequest('Bash', longInput, 'xxxxx')
56
+ expect(result.length).toBeLessThan(600)
57
+ })
58
+ })
59
+
60
+ describe('callback_data parsing', () => {
61
+ it('parses permit:requestId:yes format', () => {
62
+ const data = 'permit:abcde:yes'
63
+ const parts = data.split(':')
64
+ expect(parts[0]).toBe('permit')
65
+ expect(parts[1]).toBe('abcde')
66
+ expect(parts[2]).toBe('yes')
67
+ })
68
+
69
+ it('parses permit:requestId:no format', () => {
70
+ const data = 'permit:abcde:no'
71
+ const parts = data.split(':')
72
+ expect(parts[2]).toBe('no')
73
+ })
74
+
75
+ it('ignores non-permit callbacks', () => {
76
+ const data = 'other:action'
77
+ expect(data.startsWith('permit:')).toBe(false)
78
+ })
79
+ })
80
+
81
+ describe('MarkdownV2 escaping', () => {
82
+ it('escapes underscores', () => {
83
+ expect(escapeMarkdownV2('hello_world')).toBe('hello\\_world')
84
+ })
85
+
86
+ it('escapes multiple special chars', () => {
87
+ const result = escapeMarkdownV2('file.ts (line 42)')
88
+ expect(result).toBe('file\\.ts \\(line 42\\)')
89
+ })
90
+
91
+ it('handles code blocks safely', () => {
92
+ const result = escapeMarkdownV2('`code`')
93
+ expect(result).toBe('\\`code\\`')
94
+ })
95
+ })
96
+
97
+ describe('whitelist logic', () => {
98
+ it('empty allowedUsers means allow all', () => {
99
+ const allowedUsers: number[] = []
100
+ const isAllowed = (userId: number) =>
101
+ allowedUsers.length === 0 || allowedUsers.includes(userId)
102
+ expect(isAllowed(12345)).toBe(true)
103
+ expect(isAllowed(99999)).toBe(true)
104
+ })
105
+
106
+ it('non-empty allowedUsers filters correctly', () => {
107
+ const allowedUsers = [111, 222]
108
+ const isAllowed = (userId: number) =>
109
+ allowedUsers.length === 0 || allowedUsers.includes(userId)
110
+ expect(isAllowed(111)).toBe(true)
111
+ expect(isAllowed(222)).toBe(true)
112
+ expect(isAllowed(333)).toBe(false)
113
+ })
114
+ })
115
+ })
package/bin/bingo-win.cjs CHANGED
@@ -71,6 +71,32 @@ if (!bunExists()) {
71
71
  // 安装后 bun.exe 在固定位置;若在 PATH 里则直接用 "bun"
72
72
  const bun = fs.existsSync(bunPath) ? bunPath : 'bun';
73
73
 
74
+ // ── 自动检测并安装 git ──
75
+ function gitExists() {
76
+ const result = spawnSync('git', ['--version'], { stdio: 'ignore', shell: true });
77
+ return result.status === 0;
78
+ }
79
+
80
+ function installGit() {
81
+ console.log('[bingo] git 未检测到,正在通过 winget 自动安装...');
82
+ const result = spawnSync(
83
+ 'winget',
84
+ ['install', '--id', 'Git.Git', '-e', '--source', 'winget',
85
+ '--accept-package-agreements', '--accept-source-agreements'],
86
+ { stdio: 'inherit', shell: true }
87
+ );
88
+ if (result.status !== 0) {
89
+ console.warn('[bingo] git 自动安装失败,请手动安装:https://git-scm.com');
90
+ // 不退出 —— git 不是启动必须依赖,缺少时仅降级部分功能
91
+ } else {
92
+ console.log('[bingo] git 安装完成,若 PATH 未生效请重启终端。');
93
+ }
94
+ }
95
+
96
+ if (!gitExists()) {
97
+ installGit();
98
+ }
99
+
74
100
  // Bingo Manager 入口(窗口管理控制台,不走 cli.tsx 完整启动流程)
75
101
  const entry = path.join(__dirname, '..', 'src', 'entrypoints', 'manager.tsx');
76
102
 
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- const { spawn } = require('node:child_process');
3
+ const { spawn, spawnSync } = require('node:child_process');
4
4
  const path = require('path');
5
5
  const os = require('os');
6
6
  const fs = require('fs');
@@ -33,11 +33,63 @@ process.env.NoDefaultCurrentDirectoryInExePath = '1';
33
33
  }
34
34
  })();
35
35
 
36
- // 自动定位 bun 路径
37
- const bun =
36
+ // ── 自动定位并安装 bun ──
37
+ const bunPath =
38
38
  process.env.BUN_PATH ||
39
39
  path.join(os.homedir(), '.bun', 'bin', 'bun.exe');
40
40
 
41
+ function bunExists() {
42
+ if (fs.existsSync(bunPath)) return true;
43
+ const result = spawnSync('bun', ['--version'], { stdio: 'ignore', shell: true });
44
+ return result.status === 0;
45
+ }
46
+
47
+ function installBun() {
48
+ console.log('[bingocode] bun 未检测到,正在自动安装...');
49
+ const result = spawnSync(
50
+ 'powershell',
51
+ ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command',
52
+ 'irm bun.sh/install.ps1 | iex'],
53
+ { stdio: 'inherit', shell: false }
54
+ );
55
+ if (result.status !== 0) {
56
+ console.error('[bingocode] bun 安装失败,请手动安装:https://bun.sh');
57
+ process.exit(1);
58
+ }
59
+ console.log('[bingocode] bun 安装完成,正在启动...');
60
+ }
61
+
62
+ if (!bunExists()) {
63
+ installBun();
64
+ }
65
+
66
+ const bun = fs.existsSync(bunPath) ? bunPath : 'bun';
67
+
68
+ // ── 自动检测并安装 git ──
69
+ function gitExists() {
70
+ const result = spawnSync('git', ['--version'], { stdio: 'ignore', shell: true });
71
+ return result.status === 0;
72
+ }
73
+
74
+ function installGit() {
75
+ console.log('[bingocode] git 未检测到,正在通过 winget 自动安装...');
76
+ const result = spawnSync(
77
+ 'winget',
78
+ ['install', '--id', 'Git.Git', '-e', '--source', 'winget',
79
+ '--accept-package-agreements', '--accept-source-agreements'],
80
+ { stdio: 'inherit', shell: true }
81
+ );
82
+ if (result.status !== 0) {
83
+ console.warn('[bingocode] git 自动安装失败,请手动安装:https://git-scm.com');
84
+ } else {
85
+ console.log('[bingocode] git 安装完成,若 PATH 未生效请重启终端。');
86
+ }
87
+ }
88
+
89
+ if (!gitExists()) {
90
+ installGit();
91
+ }
92
+
41
93
  // 主 CLI 入口
42
94
  const entry = path.join(__dirname, '..', 'src', 'entrypoints', 'cli.tsx');
43
95
 
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- const { spawn } = require('node:child_process');
3
+ const { spawn, spawnSync } = require('node:child_process');
4
4
  const path = require('path');
5
5
  const os = require('os');
6
6
  const fs = require('fs');
@@ -32,11 +32,63 @@ process.env.NoDefaultCurrentDirectoryInExePath = '1';
32
32
  }
33
33
  })();
34
34
 
35
- // 自动定位 bun 路径
36
- const bun =
35
+ // ── 自动定位并安装 bun ──
36
+ const bunPath =
37
37
  process.env.BUN_PATH ||
38
38
  path.join(os.homedir(), '.bun', 'bin', 'bun.exe');
39
39
 
40
+ function bunExists() {
41
+ if (fs.existsSync(bunPath)) return true;
42
+ const result = spawnSync('bun', ['--version'], { stdio: 'ignore', shell: true });
43
+ return result.status === 0;
44
+ }
45
+
46
+ function installBun() {
47
+ console.log('[claude] bun 未检测到,正在自动安装...');
48
+ const result = spawnSync(
49
+ 'powershell',
50
+ ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command',
51
+ 'irm bun.sh/install.ps1 | iex'],
52
+ { stdio: 'inherit', shell: false }
53
+ );
54
+ if (result.status !== 0) {
55
+ console.error('[claude] bun 安装失败,请手动安装:https://bun.sh');
56
+ process.exit(1);
57
+ }
58
+ console.log('[claude] bun 安装完成,正在启动...');
59
+ }
60
+
61
+ if (!bunExists()) {
62
+ installBun();
63
+ }
64
+
65
+ const bun = fs.existsSync(bunPath) ? bunPath : 'bun';
66
+
67
+ // ── 自动检测并安装 git ──
68
+ function gitExists() {
69
+ const result = spawnSync('git', ['--version'], { stdio: 'ignore', shell: true });
70
+ return result.status === 0;
71
+ }
72
+
73
+ function installGit() {
74
+ console.log('[claude] git 未检测到,正在通过 winget 自动安装...');
75
+ const result = spawnSync(
76
+ 'winget',
77
+ ['install', '--id', 'Git.Git', '-e', '--source', 'winget',
78
+ '--accept-package-agreements', '--accept-source-agreements'],
79
+ { stdio: 'inherit', shell: true }
80
+ );
81
+ if (result.status !== 0) {
82
+ console.warn('[claude] git 自动安装失败,请手动安装:https://git-scm.com');
83
+ } else {
84
+ console.log('[claude] git 安装完成,若 PATH 未生效请重启终端。');
85
+ }
86
+ }
87
+
88
+ if (!gitExists()) {
89
+ installGit();
90
+ }
91
+
40
92
  // 主 CLI 入口
41
93
  const entry = path.join(__dirname, '..', 'src', 'entrypoints', 'cli.tsx');
42
94
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bingocode",
3
- "version": "1.0.29",
3
+ "version": "1.0.31",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "claude": "bin/claude-win.cjs",
@@ -30,13 +30,15 @@ if (feature('ABLATION_BASELINE') && process.env.CLAUDE_CODE_ABLATION_BASELINE) {
30
30
  * All imports are dynamic to minimize module evaluation for fast paths.
31
31
  * Fast-path for --version has zero imports beyond this file.
32
32
  */
33
- import { CliMenuManager } from '../manager/CliMenuManager';
34
- import { render } from 'ink';
35
33
 
36
34
  async function main(): Promise<void> {
37
35
  const args = process.argv.slice(2);
38
36
  // 兼容demo参数:只渲染CLI新主菜单管理器,不影响原有逻辑
37
+ // CliMenuManager 和 ink 改为动态 import,避免顶层 import 导致模块副作用
38
+ // 抢占 stdin,使新电脑首次启动时 TrustDialog 卡死
39
39
  if (args.includes('--cli-menu-demo')) {
40
+ const { CliMenuManager } = await import('../manager/CliMenuManager');
41
+ const { render } = await import('ink');
40
42
  render(<CliMenuManager />);
41
43
  return;
42
44
  }
@@ -280,19 +280,33 @@ export const CliMenuManager: React.FC = () => {
280
280
  // 配置就绪探测(用于避免 Logo 早期读取)
281
281
  const [configReady, setConfigReady] = useState(false);
282
282
 
283
- // 启动/复用本地唯一服务,并注入 apiUrl
283
+ // 启动/复用本地唯一服务,并注入 apiUrl(含重试机制)
284
284
  useEffect(() => {
285
285
  let mounted = true;
286
286
  (async () => {
287
- try {
288
- if (apiUrl) return;
289
- const entry = path.resolve(import.meta.dir, '../server/index.ts');
290
- const handle = await ensureSingletonLocalServer({ serverEntry: entry });
291
- if (!mounted) { await handle.stopIfLast(); return; }
292
- setApiUrl(handle.baseUrl);
293
- setStopIfLast(() => handle.stopIfLast);
294
- } catch (e: any) {
295
- setBootErr(e.message || '本地服务启动失败');
287
+ if (apiUrl) return;
288
+ const entry = path.resolve(import.meta.dir, '../server/index.ts');
289
+ const MAX_RETRIES = 3;
290
+ const RETRY_DELAYS = [0, 2000, 5000]; // 首次无延迟,第2次2秒,第3次5秒
291
+ for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
292
+ if (!mounted) return;
293
+ if (attempt > 0) {
294
+ setBootErr(`第 ${attempt} 次启动失败,${RETRY_DELAYS[attempt] / 1000}秒后重试...`);
295
+ await new Promise(r => setTimeout(r, RETRY_DELAYS[attempt]));
296
+ }
297
+ if (!mounted) return;
298
+ try {
299
+ const handle = await ensureSingletonLocalServer({ serverEntry: entry });
300
+ if (!mounted) { await handle.stopIfLast(); return; }
301
+ setApiUrl(handle.baseUrl);
302
+ setStopIfLast(() => handle.stopIfLast);
303
+ setBootErr(null);
304
+ return; // 成功,退出重试
305
+ } catch (e: any) {
306
+ if (attempt === MAX_RETRIES - 1) {
307
+ setBootErr(e.message || '本地服务启动失败');
308
+ }
309
+ }
296
310
  }
297
311
  })();
298
312
  return () => { mounted = false; if (stopIfLast) stopIfLast(); };
@@ -653,7 +667,8 @@ export const CliMenuManager: React.FC = () => {
653
667
  }
654
668
 
655
669
  // 新增:会话恢复(供快捷键和右侧菜单复用)
656
- async function resumeSession(sessionId: string) {
670
+ // workDir: 会话原始工作目录,用于跨文件夹恢复(确保新进程能找到 session 文件)
671
+ async function resumeSession(sessionId: string, workDir?: string | null) {
657
672
  try {
658
673
  const fsReq = require('fs');
659
674
  const pathReq = require('path');
@@ -673,7 +688,7 @@ export const CliMenuManager: React.FC = () => {
673
688
  : ['-c', `${binName} --resume ${sessionId}`];
674
689
  const spawnEnv = await buildSpawnEnv();
675
690
  spawn(spawnCmd, spawnArgs, {
676
- cwd: process.env.CALLER_DIR || process.cwd(),
691
+ cwd: workDir || process.env.CALLER_DIR || process.cwd(),
677
692
  env: spawnEnv,
678
693
  detached: true,
679
694
  stdio: 'ignore'
@@ -749,7 +764,7 @@ export const CliMenuManager: React.FC = () => {
749
764
  toggleMarkSession(selectedHistory.id);
750
765
  break;
751
766
  case '__continue':
752
- resumeSession(selectedHistory.id);
767
+ resumeSession(selectedHistory.id, selectedHistory.workDir);
753
768
  break;
754
769
  case '__delete':
755
770
  setHistoryMenuStage('deleteConfirm');
@@ -810,7 +825,7 @@ export const CliMenuManager: React.FC = () => {
810
825
  setSelectedHistory(null);
811
826
  setMsgsPage(0);
812
827
  } else if (item.value === '__continue') {
813
- resumeSession(selectedHistory.id);
828
+ resumeSession(selectedHistory.id, selectedHistory.workDir);
814
829
  } else if (item.value === '__delete') {
815
830
  setHistoryMenuStage('deleteConfirm');
816
831
  } else if (item.value === '__toggle_mark') {
@@ -870,9 +885,17 @@ export const CliMenuManager: React.FC = () => {
870
885
  const WELCOME_W = 58;
871
886
  const leftPad = Math.max(0, Math.floor((VIEW_W - WELCOME_W) / 2));
872
887
  return (
873
- <Box flexDirection="row" width={VIEW_W} height={MID_H}>
874
- <Box width={leftPad} flexShrink={0} />
875
- <WelcomeV2 />
888
+ <Box flexDirection="column" width={VIEW_W} height={MID_H}>
889
+ <Box flexDirection="row" width={VIEW_W} flexGrow={1}>
890
+ <Box width={leftPad} flexShrink={0} />
891
+ <WelcomeV2 />
892
+ </Box>
893
+ {!apiUrl && !bootErr && (
894
+ <Text color="yellow">⏳ 服务启动中...</Text>
895
+ )}
896
+ {bootErr && (
897
+ <Text color="red">服务启动失败: {bootErr}</Text>
898
+ )}
876
899
  </Box>
877
900
  );
878
901
  }
@@ -1014,6 +1037,14 @@ export const CliMenuManager: React.FC = () => {
1014
1037
 
1015
1038
  // Provider
1016
1039
  if (page === 'provider') {
1040
+ if (!apiUrl) {
1041
+ return (
1042
+ <Box width={VIEW_W} height={MID_H} flexDirection="column">
1043
+ <Text color="yellow">{bootErr ? `服务启动失败: ${bootErr}` : '⏳ 服务启动中,请稍候...'}</Text>
1044
+ <Text dimColor>ESC 返回主菜单</Text>
1045
+ </Box>
1046
+ );
1047
+ }
1017
1048
  return (
1018
1049
  <Box width={VIEW_W} height={MID_H} flexDirection="column">
1019
1050
  <ProviderPanel apiUrl={apiUrl} onBack={() => setPage(null)} />