imtoagent 0.3.22 → 0.3.24

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/index.ts CHANGED
@@ -19,6 +19,8 @@ import {
19
19
  import { parseToBlocks } from './modules/capabilities';
20
20
  import { resolveCapabilities } from './modules/prompt-builder';
21
21
  import { getDataDir } from './modules/utils/paths';
22
+ import { WorkspaceManager, createWorkspaceManager } from './modules/utils/workspace-manager';
23
+ import { migrateWorkspaces } from './modules/utils/migrate-workspaces';
22
24
  import { FeishuIMModule } from './modules/im/feishu';
23
25
  import { TelegramAdapter } from './modules/im/telegram';
24
26
  import { WeComIMModule } from './modules/im/wecom';
@@ -109,15 +111,17 @@ interface ChatSession extends Session {
109
111
  // ================================================================
110
112
  class CustomSessionManager {
111
113
  sessions: Map<string, ChatSession>;
112
- botName: string;
114
+ botKey: string;
115
+ sessionsDir: string;
113
116
 
114
- constructor(botName: string, sessions: Map<string, ChatSession>) {
115
- this.botName = botName;
117
+ constructor(botKey: string, workspacePath: string, sessions: Map<string, ChatSession>) {
118
+ this.botKey = botKey;
116
119
  this.sessions = sessions;
120
+ this.sessionsDir = path.join(workspacePath, 'sessions');
117
121
  }
118
122
 
119
123
  private _sessionPath(chatId: string): string {
120
- return path.join(getSessionsDir(), this.botName, `${chatId}.memory.json`);
124
+ return path.join(this.sessionsDir, `${chatId}.memory.json`);
121
125
  }
122
126
 
123
127
  async getOrCreate(chatId: string, userId: string): Promise<ChatSession> {
@@ -128,6 +132,10 @@ class CustomSessionManager {
128
132
  }
129
133
 
130
134
  // 从文件加载(兼容旧格式)
135
+ // 确保 sessions 目录存在
136
+ if (!fs.existsSync(this.sessionsDir)) {
137
+ fs.mkdirSync(this.sessionsDir, { recursive: true });
138
+ }
131
139
  const fp = this._sessionPath(chatId);
132
140
  let session: ChatSession;
133
141
 
@@ -300,6 +308,7 @@ class Bot {
300
308
  client: Lark.Client;
301
309
  im: IMModule;
302
310
  config: any;
311
+ workspaceManager: WorkspaceManager;
303
312
 
304
313
  // SDK
305
314
  runtime: AgentRuntime;
@@ -310,7 +319,7 @@ class Bot {
310
319
  /** 正在执行的任务的取消信号(chatId → AbortController) */
311
320
  activeControllers: Map<string, AbortController> = new Map();
312
321
 
313
- constructor(cfg: BotConfig, globalConfig: any) {
322
+ constructor(cfg: BotConfig, globalConfig: any, workspaceManager: WorkspaceManager) {
314
323
  this.id = cfg.id || cfg.name; // 后向兼容:无 id 时用 name
315
324
  this.name = cfg.name;
316
325
  this.backend = cfg.backend;
@@ -318,6 +327,11 @@ class Bot {
318
327
  this.appSecret = cfg.appSecret;
319
328
  this.defaultCwd = cfg.cwd || globalConfig.system?.defaultProjectDir || path.join(os.homedir(), 'Projects');
320
329
  this.config = globalConfig;
330
+ this.workspaceManager = workspaceManager;
331
+
332
+ // 确保工作空间目录存在
333
+ const botKey = this.id;
334
+ this.workspaceManager.ensureWorkspace(botKey);
321
335
 
322
336
  // Bot 级模型配置
323
337
  const botCfg = this._loadBotConfig();
@@ -352,7 +366,8 @@ class Bot {
352
366
  this.im = imFactory.create(cfg);
353
367
 
354
368
  // ===== SDK 集成 =====
355
- this.sessionManager = new CustomSessionManager(this.id, this.sessions);
369
+ const workspacePath = this.workspaceManager.getWorkspacePath(this.id);
370
+ this.sessionManager = new CustomSessionManager(this.id, workspacePath, this.sessions);
356
371
 
357
372
  const adapterCtx = {
358
373
  imModule: this.im,
@@ -387,7 +402,9 @@ class Bot {
387
402
  }
388
403
 
389
404
  // ===== 灵魂管理 =====
390
- _soulDir() { return getSoulDir(this.id); }
405
+ _soulDir() {
406
+ return this.workspaceManager.getSoulPath(this.id);
407
+ }
391
408
 
392
409
  _initSoul() {
393
410
  const dir = this._soulDir();
@@ -614,9 +631,19 @@ class Bot {
614
631
 
615
632
  cmd('/dir', ({ args, session }) => {
616
633
  const dir = args.trim();
617
- if (!dir) return `📁 ${session?.cwd || this.defaultCwd}`;
618
- if (session) session.cwd = dir;
619
- return `📁 Switched: ${dir}`;
634
+ const currentCwd = session?.cwd || this.defaultCwd;
635
+ if (!dir) {
636
+ const mode = this.workspaceManager.getMode();
637
+ return `📁 ${currentCwd}\n🏷 Workspace mode: ${mode}`;
638
+ }
639
+
640
+ const resolved = this.workspaceManager.resolveAndValidatePath(this.id, dir, currentCwd);
641
+ if (resolved === null) {
642
+ return `❌ Path not allowed: ${dir}\n💡 In sandbox mode, you can only access paths within your workspace`;
643
+ }
644
+
645
+ if (session) session.cwd = resolved;
646
+ return `📁 Switched: ${resolved}`;
620
647
  });
621
648
 
622
649
  cmd('/mode', ({ args, session }) => {
@@ -950,6 +977,16 @@ async function main() {
950
977
  return;
951
978
  }
952
979
 
980
+ // ===== Workspace Migration =====
981
+ // 老用户升级:自动迁移旧 sessions/ + soul/ 到新的 workspace 结构
982
+ const migrationResult = migrateWorkspaces();
983
+ if (migrationResult.botsMigrated.length > 0) {
984
+ console.log(` 🔄 Workspace migration: ${migrationResult.botsMigrated.length} bot(s) migrated`);
985
+ }
986
+
987
+ // 创建 WorkspaceManager(所有 Bot 共享)
988
+ const workspaceManager = createWorkspaceManager(config);
989
+
953
990
  const bots: Bot[] = [];
954
991
  for (const c of botCfgs) {
955
992
  const appId = c.appId || c.feishu?.appId || '';
@@ -958,7 +995,7 @@ async function main() {
958
995
 
959
996
  // wechat 不需要 appId/appSecret,首次启动会触发 QR 扫码绑定
960
997
  if (imType === 'wechat') {
961
- bots.push(new Bot({ ...c, appId: appId || 'wechat-bot', appSecret }, config));
998
+ bots.push(new Bot({ ...c, appId: appId || 'wechat-bot', appSecret }, config, workspaceManager));
962
999
  continue;
963
1000
  }
964
1001
 
@@ -968,7 +1005,7 @@ async function main() {
968
1005
  console.log(`[Config] ⚠️ Bot "${c.name}" has placeholder credentials, skipping`);
969
1006
  continue;
970
1007
  }
971
- bots.push(new Bot({ ...c, appId, appSecret }, config));
1008
+ bots.push(new Bot({ ...c, appId, appSecret }, config, workspaceManager));
972
1009
  }
973
1010
 
974
1011
  if (bots.length === 0) {
@@ -976,6 +1013,7 @@ async function main() {
976
1013
  return;
977
1014
  }
978
1015
 
1016
+ console.log(` Workspace: ${workspaceManager.getConfigSummary()}`);
979
1017
  _allBots = bots;
980
1018
  console.log(`\n🚀 CC Routing v4 — Multi-Bot Architecture (Full SDK Integration)`);
981
1019
  console.log(` Anthropic: http://localhost:${proxyPort}`);
@@ -12,7 +12,7 @@
12
12
  import * as fs from 'fs';
13
13
  import * as os from 'os';
14
14
  import * as path from 'path';
15
- import { getDataDir, getPkgDir, getTemplatePath, getSoulDir, getBotKey } from '../utils/paths';
15
+ import { getDataDir, getPkgDir, getTemplatePath, getSoulDir } from '../utils/paths';
16
16
  import { randomUUID } from 'crypto';
17
17
  import { checkAllBackends, formatBackendStatus } from '../utils/backend-check';
18
18
 
@@ -91,10 +91,14 @@ async function selectMenu(title: string, options: string[]): Promise<number> {
91
91
  }
92
92
 
93
93
  // ================================================================
94
- // Text input (Enter confirm, ESC returns -1)
94
+ // Text input (Enter confirm, ESC returns null)
95
95
  // ================================================================
96
96
 
97
- async function promptText(label: string, defaultValue = ''): Promise<string> {
97
+ /**
98
+ * Prompt the user for text input.
99
+ * @returns The entered string, or `null` if user pressed ESC.
100
+ */
101
+ async function promptText(label: string, defaultValue = ''): Promise<string | null> {
98
102
  const buf: string[] = [];
99
103
  const defaultHint = defaultValue ? ` [${defaultValue}]` : '';
100
104
 
@@ -110,7 +114,7 @@ async function promptText(label: string, defaultValue = ''): Promise<string> {
110
114
  break;
111
115
  } else if (key === KEY.ESC) {
112
116
  process.stdout.write('\x1B[0K\n');
113
- return -1 as unknown as string; // Special return value for ESC
117
+ return null;
114
118
  } else if (key === KEY.BACKSPACE) {
115
119
  if (buf.length > 0) {
116
120
  buf.pop();
@@ -290,7 +294,11 @@ const IM_FIELDS: Record<string, { key: string; label: string; required: boolean
290
294
  // Main flow
291
295
  // ================================================================
292
296
 
293
- export async function runSetupWizard(): Promise<void> {
297
+ export interface SetupOptions {
298
+ quick?: boolean;
299
+ }
300
+
301
+ export async function runSetupWizard(options?: SetupOptions): Promise<void> {
294
302
  // Guard: refuse to run in non-TTY environment
295
303
  if (!process.stdin.isTTY || !process.stdout.isTTY) {
296
304
  console.error('');
@@ -311,6 +319,11 @@ export async function runSetupWizard(): Promise<void> {
311
319
  console.log(`\nData directory: ${dataDir}`);
312
320
  console.log(`Controls: ↑↓/Space navigate | Enter confirm | ESC go back\n`);
313
321
 
322
+ const isQuick = options?.quick || false;
323
+ if (isQuick) {
324
+ console.log('⚡ Quick mode: workspace defaults to sandbox, skip workspace step\n');
325
+ }
326
+
314
327
  // ===== Step 1: Detect existing configuration =====
315
328
  let existingConfig: any = null;
316
329
  let mergeMode = false;
@@ -368,8 +381,18 @@ export async function runSetupWizard(): Promise<void> {
368
381
  // 3b: Auto-generate Bot name, customizable
369
382
  const defaultName = IM_PLATFORMS[imIdx].label + 'Bot';
370
383
  const nameInput = await promptText('Bot name', defaultName);
371
- if ((nameInput as any) === -1) { if (bots.length === 0) return; break; } // ESC
372
- const botName = nameInput || defaultName; // Use default if empty
384
+ if (nameInput === null) { if (bots.length === 0) return; break; } // ESC
385
+ const botName = nameInput || defaultName;
386
+
387
+ // Validate Bot name — no whitespace-only, no dangerous characters
388
+ if (!botName || !/^\S/.test(botName)) {
389
+ console.log('⚠️ Bot name must not be empty or whitespace-only. Using default.');
390
+ }
391
+ // Sanitize: remove characters that would break directory names
392
+ const sanitized = botName.replace(/[^\w\s.-]/g, '');
393
+ if (sanitized !== botName) {
394
+ console.log(`⚠️ Bot name sanitized: "${botName}" → "${sanitized}"`);
395
+ }
373
396
 
374
397
  // 3c: Select backend
375
398
  const backendLabels = backendStatus.map(b =>
@@ -410,14 +433,24 @@ export async function runSetupWizard(): Promise<void> {
410
433
 
411
434
  for (const field of fields) {
412
435
  const val = await promptText(field.label + (field.required ? '' : ' (optional)'));
413
- if ((val as any) === -1) { credentials._escaped = 'true'; break; } // ESC
436
+ if (val === null) { credentials._escaped = 'true'; break; } // ESC
414
437
  credentials[field.key] = val;
415
438
  }
416
439
  if (credentials._escaped) continue; // ESC go back and re-select backend
417
440
 
418
- // 3e: Working directory
419
- const cwd = await promptText('Working directory', os.homedir());
420
- if ((cwd as any) === -1) continue;
441
+ // 3e: Working directory (validated)
442
+ let cwd = await promptText('Working directory', os.homedir());
443
+ if (cwd === null) continue;
444
+ cwd = cwd.trim() || os.homedir();
445
+
446
+ // Validate path — reject obviously invalid / dangerous paths
447
+ const badPaths = ['/dev/null', '/dev/zero', '/dev/random', '/etc/passwd', '/etc/shadow', '/System'];
448
+ if (badPaths.some(bp => cwd === bp || cwd.startsWith(bp + '/'))) {
449
+ console.log(`⚠️ Invalid path "${cwd}". Using home directory instead.`);
450
+ cwd = os.homedir();
451
+ }
452
+ // Resolve to absolute path
453
+ cwd = path.resolve(cwd);
421
454
 
422
455
  // Generate unique ID (UUID, for directory isolation, renaming doesn't affect it)
423
456
  const botId = randomUUID();
@@ -458,15 +491,20 @@ export async function runSetupWizard(): Promise<void> {
458
491
  bot.im = imType;
459
492
  }
460
493
 
461
- // Check for duplicate name
462
- const existingIdx = bots.findIndex(b => b.name === botName);
463
- if (existingIdx >= 0) {
464
- bots[existingIdx] = bot;
465
- console.log(`✅ Replaced: ${botName}`);
466
- } else {
467
- bots.push(bot);
468
- console.log(`✅ Added: ${botName}`);
494
+ // Check for duplicate name — auto-rename with counter
495
+ let finalName = botName;
496
+ let counter = 1;
497
+ while (bots.find(b => b.name === finalName)) {
498
+ finalName = `${botName} (${counter})`;
499
+ counter++;
500
+ }
501
+ bot.name = finalName;
502
+
503
+ if (finalName !== botName) {
504
+ console.log(`⚠️ "${botName}" already exists, renamed to "${finalName}"`);
469
505
  }
506
+ bots.push(bot);
507
+ console.log(`✅ Added: ${finalName}`);
470
508
 
471
509
  // Whether to continue adding
472
510
  const r = await confirm('Add another Bot?', true);
@@ -482,8 +520,51 @@ export async function runSetupWizard(): Promise<void> {
482
520
  return;
483
521
  }
484
522
 
485
- // ===== Step 4: Configure model providers =====
486
- console.log('\n📌 Step 4: Configure model providers\n');
523
+ // ===== Step 4: Configure workspace =====
524
+ let workspaceMode: 'sandbox' | 'global' = 'sandbox';
525
+ let workspaceGlobalPath: string | null = null;
526
+
527
+ if (!isQuick) {
528
+ console.log('\n📌 Step 4: Configure workspace\n');
529
+ console.log('Each Bot gets its own isolated file workspace:');
530
+ console.log(' sandbox — isolated per Bot, no cross-Bot file access (recommended)');
531
+ console.log(' global — shared root directory, multiple Bots collaborate on same codebase');
532
+ console.log('');
533
+
534
+ const wsLabels = ['Sandbox mode (isolated per Bot)', 'Global mode (shared root path)'];
535
+ const wsIdx = await selectMenu('Select workspace mode', wsLabels);
536
+ if (wsIdx === -1) { console.log('\n👋 Cancelled'); process.exit(0); }
537
+ workspaceMode = wsIdx === 1 ? 'global' : 'sandbox';
538
+
539
+ if (workspaceMode === 'global') {
540
+ const defaultGlobal = os.homedir() + '/imtoagent-workspace';
541
+ const gpInput = await promptText('Global workspace root path', defaultGlobal);
542
+ if (gpInput === null) { console.log('\n👋 Cancelled'); process.exit(0); }
543
+ let resolved = (gpInput || defaultGlobal).trim();
544
+
545
+ // Validate path
546
+ const badPaths = ['/dev/null', '/dev/zero', '/dev/random', '/etc', '/System', '/usr'];
547
+ if (badPaths.some(bp => resolved === bp || resolved.startsWith(bp + '/'))) {
548
+ console.log(`⚠️ Invalid path "${resolved}". Using default instead.`);
549
+ resolved = defaultGlobal;
550
+ }
551
+ workspaceGlobalPath = path.resolve(resolved);
552
+ }
553
+
554
+ const home = process.env.HOME || process.env.USERPROFILE?.replace(/\\/g, '/') || '';
555
+ console.log('');
556
+ console.log('Workspace layout:');
557
+ if (workspaceMode === 'sandbox') {
558
+ console.log(` ${home}/.imtoagent/workspaces/<UUID>/ (each Bot gets a unique directory)`);
559
+ } else {
560
+ console.log(` ${workspaceGlobalPath}/ (all Bots share this root)`);
561
+ console.log(` soul files in ${workspaceGlobalPath}/.imtoagent/soul/<botId>/`);
562
+ }
563
+ console.log('');
564
+ }
565
+
566
+ // ===== Step 5: Configure model providers =====
567
+ console.log('\n📌 Step 5: Configure model providers\n');
487
568
 
488
569
  const providers: Record<string, any> = {};
489
570
  if (mergeMode && existingConfig?.providers) {
@@ -524,19 +605,27 @@ export async function runSetupWizard(): Promise<void> {
524
605
  console.log(` Format: ${preset.format}`);
525
606
  console.log(` Models: ${preset.models.join(', ')}\n`);
526
607
 
527
- // Confirm/edit short name
608
+ // Confirm/edit short name (validate — must be safe JSON key)
528
609
  const nameEdit = await promptText('Provider name (leave blank to confirm)', provName);
529
- if ((nameEdit as any) === -1) continue;
610
+ if (nameEdit === null) continue;
530
611
  provName = nameEdit || provName;
612
+ // Sanitize provider name: only alphanumeric, hyphens, underscores
613
+ const sanitizedProv = provName.replace(/[^a-zA-Z0-9_-]/g, '');
614
+ if (!sanitizedProv) {
615
+ console.log('⚠️ Invalid provider name. Using original.');
616
+ } else if (sanitizedProv !== provName) {
617
+ console.log(`⚠️ Provider name sanitized: "${provName}" → "${sanitizedProv}"`);
618
+ provName = sanitizedProv;
619
+ }
531
620
 
532
621
  // Confirm/edit Base URL
533
622
  const urlEdit = await promptText('Base URL', baseUrl);
534
- if ((urlEdit as any) === -1) continue;
623
+ if (urlEdit === null) continue;
535
624
  baseUrl = urlEdit || baseUrl;
536
625
 
537
626
  // Confirm/edit model list
538
627
  const modelsEdit = await promptText('Model list (comma-separated)', models.join(', '));
539
- if ((modelsEdit as any) === -1) continue;
628
+ if (modelsEdit === null) continue;
540
629
  if (modelsEdit) models = modelsEdit.split(',').map(s => s.trim()).filter(Boolean);
541
630
 
542
631
  if (providers[provName]) {
@@ -544,17 +633,18 @@ export async function runSetupWizard(): Promise<void> {
544
633
  }
545
634
  } else {
546
635
  // Custom
547
- provName = await promptText('Provider name (e.g. deepseek, dashscope)');
548
- if ((provName as any) === -1) { addingProviders = false; continue; }
636
+ let customName = await promptText('Provider name (e.g. deepseek, dashscope)');
637
+ if (customName === null) { addingProviders = false; continue; }
638
+ provName = customName.trim().toLowerCase().replace(/[^a-zA-Z0-9_-]/g, '');
549
639
  if (!provName) { addingProviders = false; continue; }
550
640
  if (providers[provName]) {
551
641
  console.log(`⚠️ Provider "${provName}" already exists, will overwrite\n`);
552
642
  }
553
643
 
554
644
  baseUrl = await promptText('Base URL (e.g. https://api.deepseek.com/v1)');
555
- if ((baseUrl as any) === -1) continue;
645
+ if (baseUrl === null) continue;
556
646
  const modelsStr = await promptText('Model list (comma-separated)');
557
- if ((modelsStr as any) === -1) continue;
647
+ if (modelsStr === null) continue;
558
648
  models = (modelsStr || '').split(',').map(s => s.trim()).filter(Boolean);
559
649
 
560
650
  const formatIdx = await selectMenu('API format', ['openai', 'anthropic']);
@@ -564,14 +654,14 @@ export async function runSetupWizard(): Promise<void> {
564
654
 
565
655
  // API Key (required for all providers)
566
656
  const apiKey = await promptText('API Key');
567
- if ((apiKey as any) === -1) continue;
657
+ if (apiKey === null) continue;
568
658
  if (!apiKey) {
569
659
  console.log('⚠️ API Key is empty, this provider will be temporarily unavailable\n');
570
660
  }
571
661
 
572
662
  // Pricing (optional)
573
663
  const priceInput = await promptText('Pricing (in/out per million tokens, e.g. 0.55,2.19, leave blank to skip)');
574
- if ((priceInput as any) === -1) continue;
664
+ if (priceInput === null) continue;
575
665
 
576
666
  const pricing: any = {};
577
667
  if (priceInput) {
@@ -600,8 +690,8 @@ export async function runSetupWizard(): Promise<void> {
600
690
  step4Loop = false; // Has providers or user explicitly skipped
601
691
  }
602
692
 
603
- // ===== Step 5: Select default model =====
604
- console.log('\n📌 Step 5: Select default model\n');
693
+ // ===== Step 6: Select default model =====
694
+ console.log('\n📌 Step 6: Select default model\n');
605
695
 
606
696
  const allModels: string[] = [];
607
697
  for (const [provName, prov] of Object.entries(providers)) {
@@ -614,17 +704,29 @@ export async function runSetupWizard(): Promise<void> {
614
704
  if (allModels.length > 0) {
615
705
  const existingDefault = existingConfig?.defaultModel || allModels[0];
616
706
  const val = await promptText('Default model', existingDefault);
617
- defaultModel = (val as any) === -1 ? existingDefault : (val || existingDefault);
707
+ defaultModel = val === null ? existingDefault : (val || existingDefault);
618
708
  } else {
619
- defaultModel = await promptText('Default model (provider/model)') || 'deepseek/deepseek-v4-pro';
620
- if ((defaultModel as any) === -1) defaultModel = 'deepseek/deepseek-v4-pro';
709
+ const val = await promptText('Default model (provider/model)');
710
+ defaultModel = val === null ? 'deepseek/deepseek-v4-pro' : (val || 'deepseek/deepseek-v4-pro');
621
711
  }
622
712
 
623
- // ===== Step 6: Generate soul files =====
624
- console.log('\n📌 Step 6: Generate soul files\n');
713
+ // ===== Step 7: Generate soul files =====
714
+ console.log('\n📌 Step 7: Generate soul files\n');
715
+
716
+ // Compute workspace soul dirs based on configured workspace mode
717
+ const home = process.env.HOME || process.env.USERPROFILE?.replace(/\\/g, '/') || '';
625
718
 
626
719
  for (const bot of bots) {
627
- const botSoulDir = getSoulDir(getBotKey(bot));
720
+ let botSoulDir: string;
721
+
722
+ if (workspaceMode === 'sandbox') {
723
+ // Sandbox: soul in workspaces/<UUID>/soul/
724
+ botSoulDir = path.join(home, '.imtoagent', 'workspaces', bot.id, 'soul');
725
+ } else {
726
+ // Global: soul in <globalPath>/.imtoagent/soul/<botId>/
727
+ botSoulDir = path.join(workspaceGlobalPath || path.join(home, 'imtoagent-workspace'), '.imtoagent', 'soul', bot.id);
728
+ }
729
+
628
730
  const templateSoulDir = path.join(getPkgDir(), 'templates', 'soul.template');
629
731
 
630
732
  if (fs.existsSync(botSoulDir)) {
@@ -654,18 +756,23 @@ export async function runSetupWizard(): Promise<void> {
654
756
  }
655
757
  console.log(`✅ ${bot.name}: soul files → ${botSoulDir}`);
656
758
  }
657
-
658
- // ===== Step 7: Write configuration files =====
659
- console.log('\n📌 Step 7: Write configuration files\n');
759
+ console.log('\n📌 Step 8: Write configuration files\n');
660
760
 
661
761
  fs.mkdirSync(dataDir, { recursive: true });
662
762
 
763
+ // Atomic config write: write to temp file first, then rename.
764
+ // If the write fails, the original config (if any) is preserved.
663
765
  const config: any = {
664
766
  system: existingConfig?.system || {
665
767
  defaultProjectDir: os.homedir(),
666
768
  idleTimeoutMinutes: 30,
667
769
  maxReplyLength: 140000,
668
770
  },
771
+ workspace: {
772
+ mode: workspaceMode,
773
+ globalPath: workspaceGlobalPath,
774
+ botOverrides: {},
775
+ },
669
776
  providers,
670
777
  defaultModel,
671
778
  modelAliases: existingConfig?.modelAliases || buildDefaultAliases(defaultModel),
@@ -694,19 +801,42 @@ export async function runSetupWizard(): Promise<void> {
694
801
  bots,
695
802
  };
696
803
 
697
- fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n');
698
- console.log(`✅ ${configPath}`);
804
+ // Write to temp file first for atomicity
805
+ const configTmpPath = configPath + '.tmp';
806
+ const providersTmpPath = path.join(dataDir, 'providers.json.tmp');
807
+ let writeOk = true;
808
+
809
+ try {
810
+ fs.writeFileSync(configTmpPath, JSON.stringify(config, null, 2) + '\n');
811
+ fs.renameSync(configTmpPath, configPath);
812
+ console.log(`✅ ${configPath}`);
813
+ } catch (e: any) {
814
+ console.error(`❌ Failed to write config.json: ${e.message}`);
815
+ writeOk = false;
816
+ }
699
817
 
700
- const providersFile: any = { providers, defaultModel, modelAliases: config.modelAliases };
701
- const providersPath = path.join(dataDir, 'providers.json');
702
- fs.writeFileSync(providersPath, JSON.stringify(providersFile, null, 2) + '\n');
703
- console.log(`✅ ${providersPath}`);
818
+ if (writeOk) {
819
+ try {
820
+ const providersFile: any = { providers, defaultModel, modelAliases: config.modelAliases };
821
+ const providersPath = path.join(dataDir, 'providers.json');
822
+ fs.writeFileSync(providersTmpPath, JSON.stringify(providersFile, null, 2) + '\n');
823
+ fs.renameSync(providersTmpPath, providersPath);
824
+ console.log(`✅ ${providersPath}`);
825
+ } catch (e: any) {
826
+ console.error(`❌ Failed to write providers.json: ${e.message}`);
827
+ console.error('⚠️ config.json was written successfully, but providers.json failed.');
828
+ console.error(' Please re-run "imtoagent setup" to fix.');
829
+ writeOk = false;
830
+ }
831
+ }
704
832
 
705
- const opencodePath = path.join(dataDir, 'opencode.json');
706
- const opencodeTemplate = getTemplatePath('opencode.template.json');
707
- if (fs.existsSync(opencodeTemplate)) {
708
- fs.writeFileSync(opencodePath, fs.readFileSync(opencodeTemplate, 'utf-8'));
709
- console.log(`✅ ${opencodePath}`);
833
+ if (writeOk) {
834
+ const opencodePath = path.join(dataDir, 'opencode.json');
835
+ const opencodeTemplate = getTemplatePath('opencode.template.json');
836
+ if (fs.existsSync(opencodeTemplate)) {
837
+ fs.writeFileSync(opencodePath, fs.readFileSync(opencodeTemplate, 'utf-8'));
838
+ console.log(`✅ ${opencodePath}`);
839
+ }
710
840
  }
711
841
 
712
842
  fs.mkdirSync(path.join(dataDir, 'sessions'), { recursive: true });
@@ -717,9 +847,10 @@ export async function runSetupWizard(): Promise<void> {
717
847
  console.log('\n╔══════════════════════════════════════════════╗');
718
848
  console.log('║ ✅ Configuration complete! ║');
719
849
  console.log('╚══════════════════════════════════════════════╝\n');
720
- console.log(`Bot: ${bots.map(b => b.name).join(', ')}`);
850
+ console.log(`Bots: ${bots.map(b => b.name).join(', ')}`);
721
851
  console.log(`Default model: ${defaultModel}`);
722
852
  console.log(`Providers: ${Object.keys(providers).join(', ') || 'None'}`);
853
+ console.log(`Workspace: ${workspaceMode === 'sandbox' ? 'sandbox (isolated)' : `global (${workspaceGlobalPath})`}`);
723
854
  console.log(`\nNext steps:`);
724
855
  console.log(` imtoagent start Start the gateway`);
725
856
  console.log(` imtoagent status Check status\n`);