@parallel-cli/parallel 0.4.1 → 0.4.4

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/dist/config.js CHANGED
@@ -13,131 +13,250 @@ export function configFile() {
13
13
  }
14
14
  export const DEEPSEEK_PROVIDER = {
15
15
  name: 'DeepSeek',
16
- baseUrl: 'https://api.deepseek.com',
16
+ baseUrl: 'https://api.deepseek.com/v1',
17
17
  apiKey: '',
18
- models: ['deepseek-v4-flash', 'deepseek-v4-pro', 'deepseek-chat', 'deepseek-reasoner'],
19
- defaultModel: 'deepseek-v4-flash',
18
+ models: ['deepseek-v4-pro', 'deepseek-v4-flash', 'deepseek-chat', 'deepseek-reasoner'],
19
+ defaultModel: 'deepseek-v4-pro',
20
20
  };
21
21
  export const PROVIDER_PRESETS = [
22
+ // ── 🇺🇸 Western ──
22
23
  {
23
24
  name: 'OpenAI',
24
25
  baseUrl: 'https://api.openai.com/v1',
25
26
  apiKey: '',
26
- models: ['gpt-4o', 'gpt-4o-mini', 'o4-mini', 'gpt-4.1', 'gpt-4.1-mini'],
27
- defaultModel: 'gpt-4o',
27
+ models: ['gpt-5.5', 'gpt-5.5-pro', 'gpt-5.4', 'gpt-5.3-codex', 'gpt-4o', 'gpt-4o-mini', 'o4-mini', 'o3', 'o3-mini', 'o1', 'o1-mini'],
28
+ defaultModel: 'gpt-5.5',
29
+ category: 'western',
28
30
  },
29
- DEEPSEEK_PROVIDER,
30
31
  {
31
32
  name: 'Anthropic',
32
- baseUrl: 'https://api.anthropic.com/v1/',
33
+ baseUrl: 'https://api.anthropic.com/v1',
33
34
  apiKey: '',
34
- models: ['claude-sonnet-4-6', 'claude-opus-4-8'],
35
+ models: ['claude-opus-4-8', 'claude-opus-4-7', 'claude-sonnet-4-6', 'claude-haiku-4-5'],
35
36
  defaultModel: 'claude-sonnet-4-6',
37
+ category: 'western',
36
38
  },
37
39
  {
38
- name: 'OpenRouter',
39
- baseUrl: 'https://openrouter.ai/api/v1',
40
+ name: 'Google Gemini',
41
+ baseUrl: 'https://generativelanguage.googleapis.com/v1beta/openai',
40
42
  apiKey: '',
41
- models: ['openai/gpt-4o', 'anthropic/claude-sonnet-4', 'deepseek/deepseek-chat', 'google/gemini-pro'],
42
- defaultModel: 'openai/gpt-4o',
43
+ models: ['gemini-3.1-pro', 'gemini-3.5-flash', 'gemini-3-flash', 'gemini-3.1-flash-lite'],
44
+ defaultModel: 'gemini-3.5-flash',
45
+ category: 'western',
43
46
  },
44
47
  {
45
- name: 'Gemini',
46
- baseUrl: 'https://generativelanguage.googleapis.com/v1beta/openai/',
48
+ name: 'xAI Grok',
49
+ baseUrl: 'https://api.x.ai/v1',
47
50
  apiKey: '',
48
- models: ['gemini-3.5-flash', 'gemini-3.5-pro', 'gemini-2.5-pro'],
49
- defaultModel: 'gemini-3.5-flash',
51
+ models: ['grok-4', 'grok-4-fast-reasoning', 'grok-3', 'grok-code-fast-1'],
52
+ defaultModel: 'grok-4',
53
+ category: 'western',
50
54
  },
51
55
  {
52
56
  name: 'Mistral',
53
57
  baseUrl: 'https://api.mistral.ai/v1',
54
58
  apiKey: '',
55
- models: ['mistral-large-latest', 'mistral-small-latest', 'codestral-latest'],
56
- defaultModel: 'mistral-large-latest',
59
+ models: ['mistral-large-2', 'magistral-medium', 'codestral-latest', 'mistral-small-latest'],
60
+ defaultModel: 'mistral-large-2',
61
+ category: 'western',
57
62
  },
58
63
  {
59
- name: 'Groq',
60
- baseUrl: 'https://api.groq.com/openai/v1',
64
+ name: 'Cohere',
65
+ baseUrl: 'https://api.cohere.com/v1',
61
66
  apiKey: '',
62
- models: ['llama-3.3-70b-versatile', 'openai/gpt-oss-120b'],
63
- defaultModel: 'llama-3.3-70b-versatile',
67
+ models: ['command-a', 'command-r-plus'],
68
+ defaultModel: 'command-a',
69
+ category: 'western',
64
70
  },
65
71
  {
66
- name: 'Together',
67
- baseUrl: 'https://api.together.ai/v1',
72
+ name: 'Perplexity',
73
+ baseUrl: 'https://api.perplexity.ai',
68
74
  apiKey: '',
69
- models: ['openai/gpt-oss-120b', 'openai/gpt-oss-20b'],
70
- defaultModel: 'openai/gpt-oss-120b',
75
+ models: ['sonar-pro', 'sonar-deep-research'],
76
+ defaultModel: 'sonar-pro',
77
+ category: 'western',
71
78
  },
79
+ // ── 🇨🇳 Chinese ──
72
80
  {
73
- name: 'xAI',
74
- baseUrl: 'https://api.x.ai/v1',
81
+ name: 'DeepSeek',
82
+ baseUrl: 'https://api.deepseek.com/v1',
75
83
  apiKey: '',
76
- models: ['grok-4', 'grok-3-beta', 'grok-3-mini'],
77
- defaultModel: 'grok-3-beta',
84
+ models: ['deepseek-v4-pro', 'deepseek-v4-flash', 'deepseek-chat', 'deepseek-reasoner'],
85
+ defaultModel: 'deepseek-v4-pro',
86
+ category: 'chinese',
78
87
  },
79
88
  {
80
- name: 'Perplexity',
81
- baseUrl: 'https://api.perplexity.ai',
89
+ name: 'MiniMax',
90
+ baseUrl: 'https://api.minimax.io/v1',
82
91
  apiKey: '',
83
- models: ['sonar-pro', 'sonar', 'sonar-reasoning'],
84
- defaultModel: 'sonar-pro',
92
+ models: ['MiniMax-M3', 'MiniMax-M2.7', 'MiniMax-M2.7-highspeed'],
93
+ defaultModel: 'MiniMax-M3',
94
+ category: 'chinese',
85
95
  },
86
96
  {
87
- name: 'Cohere',
88
- baseUrl: 'https://api.cohere.com/v2',
97
+ name: 'Z.ai / GLM',
98
+ baseUrl: 'https://open.bigmodel.cn/api/paas/v4',
89
99
  apiKey: '',
90
- models: ['command-a', 'command-r-plus', 'command-r'],
91
- defaultModel: 'command-a',
100
+ models: ['glm-5.2', 'glm-5.1', 'glm-4.7', 'glm-4.7-flash', 'glm-5v-turbo'],
101
+ defaultModel: 'glm-5.2',
102
+ category: 'chinese',
92
103
  },
93
104
  {
94
- name: 'DeepInfra',
95
- baseUrl: 'https://api.deepinfra.com/v1/openai',
105
+ name: 'Alibaba / Qwen',
106
+ baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
96
107
  apiKey: '',
97
- models: ['meta-llama/llama-4-maverick', 'deepseek-ai/deepseek-chat', 'microsoft/wizardlm-2-8x22b'],
98
- defaultModel: 'meta-llama/llama-4-maverick',
108
+ models: ['qwen3.7-max', 'qwen3.6-max-preview', 'qwen3.6-plus', 'qwen3.5-coder'],
109
+ defaultModel: 'qwen3.7-max',
110
+ category: 'chinese',
99
111
  },
100
112
  {
101
- name: 'Fireworks',
102
- baseUrl: 'https://api.fireworks.ai/inference/v1',
113
+ name: 'Moonshot / Kimi',
114
+ baseUrl: 'https://api.moonshot.cn/v1',
115
+ apiKey: '',
116
+ models: ['kimi-k2.6', 'kimi-k2.7-code', 'kimi-k2.5', 'moonshot-v1-128k'],
117
+ defaultModel: 'kimi-k2.6',
118
+ category: 'chinese',
119
+ },
120
+ {
121
+ name: 'Xiaomi / MiMo',
122
+ baseUrl: 'https://api.minimaxi.com/v1',
123
+ apiKey: '',
124
+ models: ['mimo-v2-pro', 'mimo-v2-omni'],
125
+ defaultModel: 'mimo-v2-pro',
126
+ category: 'chinese',
127
+ },
128
+ {
129
+ name: 'StepFun',
130
+ baseUrl: 'https://api.stepfun.com/v1',
131
+ apiKey: '',
132
+ models: ['step-2-16k'],
133
+ defaultModel: 'step-2-16k',
134
+ category: 'chinese',
135
+ },
136
+ // ── 🌐 Gateways ──
137
+ {
138
+ name: 'OpenRouter',
139
+ baseUrl: 'https://openrouter.ai/api/v1',
140
+ apiKey: '',
141
+ models: ['openai/gpt-5.5', 'anthropic/claude-sonnet-4-6', 'google/gemini-3.5-flash', 'deepseek/deepseek-v4-pro', 'meta-llama/llama-4-maverick', 'mistralai/mistral-large-2'],
142
+ defaultModel: 'openai/gpt-5.5',
143
+ category: 'gateways',
144
+ },
145
+ {
146
+ name: 'SiliconFlow',
147
+ baseUrl: 'https://api.siliconflow.cn/v1',
103
148
  apiKey: '',
104
- models: ['llama-4-maverick', 'llama-4-scout', 'mixtral-8x22b'],
105
- defaultModel: 'llama-4-maverick',
149
+ models: ['deepseek-ai/DeepSeek-V4-Pro', 'deepseek-ai/DeepSeek-R1', 'Qwen/Qwen3-Coder-480B', 'glm-4/GLM-5.2', 'moonshotai/Kimi-K2.6'],
150
+ defaultModel: 'deepseek-ai/DeepSeek-V4-Pro',
151
+ category: 'gateways',
152
+ },
153
+ {
154
+ name: 'Atlas Cloud',
155
+ baseUrl: 'https://api.atlascloud.ai/v1',
156
+ apiKey: '',
157
+ models: ['deepseek-v4-pro', 'deepseek-r1', 'qwen3.7-max', 'glm-5.2', 'kimi-k2.6', 'llama-4-maverick'],
158
+ defaultModel: 'deepseek-v4-pro',
159
+ category: 'gateways',
160
+ },
161
+ {
162
+ name: 'Requesty',
163
+ baseUrl: 'https://api.requesty.ai/api/v1',
164
+ apiKey: '',
165
+ models: ['gpt-5.5', 'claude-sonnet-4-6', 'gemini-3.5-flash', 'deepseek-v4-pro', 'llama-4-maverick', 'mistral-large-2'],
166
+ defaultModel: 'gpt-5.5',
167
+ category: 'gateways',
168
+ },
169
+ {
170
+ name: 'Vercel AI Gateway',
171
+ baseUrl: 'https://api.vercel.ai/v1',
172
+ apiKey: '',
173
+ models: ['gpt-5.5', 'claude-sonnet-4-6', 'gemini-3.5-flash', 'deepseek-v4-pro', 'llama-4-maverick'],
174
+ defaultModel: 'gpt-5.5',
175
+ category: 'gateways',
176
+ },
177
+ // ── ⚡ Inference ──
178
+ {
179
+ name: 'Groq',
180
+ baseUrl: 'https://api.groq.com/openai/v1',
181
+ apiKey: '',
182
+ models: ['qwen-2.5-coder-32b', 'deepseek-r1-distill-llama-70b', 'kimi-k2.6', 'llama-3.3-70b-versatile'],
183
+ defaultModel: 'qwen-2.5-coder-32b',
184
+ category: 'inference',
106
185
  },
107
186
  {
108
187
  name: 'Cerebras',
109
188
  baseUrl: 'https://api.cerebras.ai/v1',
110
189
  apiKey: '',
111
- models: ['llama-3.3-70b', 'llama-3.1-8b'],
112
- defaultModel: 'llama-3.3-70b',
190
+ models: ['llama-4-maverick-17b-128e-instruct', 'qwen3-coder-480b', 'kimi-k2.6', 'llama-3.3-70b'],
191
+ defaultModel: 'llama-4-maverick-17b-128e-instruct',
192
+ category: 'inference',
193
+ },
194
+ {
195
+ name: 'Together AI',
196
+ baseUrl: 'https://api.together.xyz/v1',
197
+ apiKey: '',
198
+ models: ['meta-llama/Llama-4-Maverick-17B-128E-Instruct', 'deepseek-ai/DeepSeek-V3', 'Qwen/Qwen3-Coder-480B', 'moonshotai/Kimi-K2.6'],
199
+ defaultModel: 'meta-llama/Llama-4-Maverick-17B-128E-Instruct',
200
+ category: 'inference',
201
+ },
202
+ {
203
+ name: 'Fireworks',
204
+ baseUrl: 'https://api.fireworks.ai/inference/v1',
205
+ apiKey: '',
206
+ models: ['accounts/fireworks/models/llama4-maverick-17b', 'accounts/fireworks/models/deepseek-v3', 'accounts/fireworks/models/qwen3-coder-480b', 'accounts/fireworks/models/kimi-k2.6'],
207
+ defaultModel: 'accounts/fireworks/models/llama4-maverick-17b',
208
+ category: 'inference',
209
+ },
210
+ {
211
+ name: 'DeepInfra',
212
+ baseUrl: 'https://api.deepinfra.com/v1/openai',
213
+ apiKey: '',
214
+ models: ['meta-llama/Llama-4-Maverick-17B-128E', 'deepseek-ai/DeepSeek-V3', 'Qwen/Qwen3-Coder-480B', 'moonshotai/Kimi-K2.6'],
215
+ defaultModel: 'meta-llama/Llama-4-Maverick-17B-128E',
216
+ category: 'inference',
113
217
  },
114
218
  {
115
219
  name: 'Novita',
116
220
  baseUrl: 'https://api.novita.ai/v3/openai',
117
221
  apiKey: '',
118
- models: ['deepseek-r1', 'deepseek-v3', 'llama-3.1-70b'],
119
- defaultModel: 'deepseek-v3',
222
+ models: ['meta-llama/llama-4-maverick-17b-128e', 'deepseek/deepseek-v3', 'qwen/qwen3-coder-480b', 'moonshotai/kimi-k2.6'],
223
+ defaultModel: 'meta-llama/llama-4-maverick-17b-128e',
224
+ category: 'inference',
120
225
  },
121
226
  {
122
227
  name: 'Hyperbolic',
123
- baseUrl: 'https://api.hyperbolic.xyz/v1',
228
+ baseUrl: 'https://api.hyperbolic.ai/v1',
124
229
  apiKey: '',
125
- models: ['deepseek-v3', 'llama-4-maverick', 'qwen3-235b'],
126
- defaultModel: 'deepseek-v3',
230
+ models: ['meta-llama/Llama-4-Maverick-17B-128E', 'deepseek-ai/DeepSeek-V3', 'Qwen/Qwen3-Coder-480B', 'moonshotai/Kimi-K2.6'],
231
+ defaultModel: 'meta-llama/Llama-4-Maverick-17B-128E',
232
+ category: 'inference',
127
233
  },
128
234
  {
129
235
  name: 'SambaNova',
130
236
  baseUrl: 'https://api.sambanova.ai/v1',
131
237
  apiKey: '',
132
- models: ['llama-4-maverick', 'llama-4-scout', 'deepseek-r1'],
133
- defaultModel: 'llama-4-maverick',
238
+ models: ['Meta-Llama-4-Maverick-17B-128E-Instruct', 'DeepSeek-V3', 'Llama-3.3-70B-Instruct'],
239
+ defaultModel: 'Meta-Llama-4-Maverick-17B-128E-Instruct',
240
+ category: 'inference',
134
241
  },
242
+ // ── 🏠 Local ──
135
243
  {
136
244
  name: 'Ollama',
137
245
  baseUrl: 'http://localhost:11434/v1',
246
+ apiKey: 'ollama-local',
247
+ models: ['qwen3-coder:480b', 'glm-4.7', 'deepseek-v3', 'kimi-k2', 'llama3.2', 'mistral', 'codellama', 'gemma3'],
248
+ defaultModel: 'qwen3-coder:480b',
249
+ category: 'local',
250
+ requiresApiKey: false,
251
+ },
252
+ {
253
+ name: 'vLLM / SGLang',
254
+ baseUrl: 'http://localhost:8000/v1',
138
255
  apiKey: '',
139
- models: ['llama3', 'codellama', 'mistral'],
140
- defaultModel: 'llama3',
256
+ models: ['your-model-here'],
257
+ defaultModel: '',
258
+ category: 'local',
259
+ requiresApiKey: false,
141
260
  },
142
261
  ];
143
262
  export const DEFAULTS = {
@@ -155,6 +274,45 @@ function normalizeApprovalMode(mode) {
155
274
  return 'auto-safe';
156
275
  return 'ask';
157
276
  }
277
+ export function isLocalProvider(p) {
278
+ return /localhost|127\.0\.0\.1|0\.0\.0\.0/.test(p.baseUrl);
279
+ }
280
+ export function providerNeedsApiKey(p) {
281
+ return p.requiresApiKey !== false && !isLocalProvider(p);
282
+ }
283
+ export function isPlaceholderModel(model) {
284
+ return !model.trim() || /^your-model-here$/i.test(model.trim());
285
+ }
286
+ export function providerHasUsableModel(p) {
287
+ return !isPlaceholderModel(p.defaultModel || p.models[0] || '');
288
+ }
289
+ export function providerReady(p) {
290
+ return (!providerNeedsApiKey(p) || p.apiKey.trim().length > 0) && providerHasUsableModel(p);
291
+ }
292
+ export async function detectProviderModels(provider, timeoutMs = 2000) {
293
+ let timeout;
294
+ try {
295
+ const controller = new AbortController();
296
+ timeout = setTimeout(() => controller.abort(), timeoutMs);
297
+ const resp = await fetch(provider.baseUrl.replace(/\/+$/, '') + '/models', { signal: controller.signal });
298
+ if (!resp.ok)
299
+ return null;
300
+ const data = (await resp.json());
301
+ const models = [
302
+ ...(data.data?.map((m) => m.id || m.name).filter(Boolean) ?? []),
303
+ ...(data.models?.map((m) => m.name).filter(Boolean) ?? []),
304
+ ];
305
+ const unique = [...new Set(models.filter((m) => !isPlaceholderModel(m)))];
306
+ return unique.length > 0 ? { models: unique, defaultModel: unique[0] } : null;
307
+ }
308
+ catch {
309
+ return null;
310
+ }
311
+ finally {
312
+ if (timeout)
313
+ clearTimeout(timeout);
314
+ }
315
+ }
158
316
  export function getProvider(cfg, name) {
159
317
  const n = (name ?? cfg.defaultProvider).toLowerCase();
160
318
  return cfg.providers.find((p) => p.name.toLowerCase() === n) ?? (name ? undefined : cfg.providers[0]);
@@ -185,6 +343,20 @@ function migrate(raw, cfg) {
185
343
  cfg.providers = [p];
186
344
  cfg.defaultProvider = p.name;
187
345
  }
346
+ function normalizeConfig(cfg) {
347
+ cfg.providers = cfg.providers.filter((p) => p && typeof p.name === 'string' && typeof p.baseUrl === 'string');
348
+ for (const p of cfg.providers) {
349
+ if (!Array.isArray(p.models))
350
+ p.models = [];
351
+ if (typeof p.defaultModel !== 'string')
352
+ p.defaultModel = p.models[0] ?? '';
353
+ if (typeof p.apiKey !== 'string')
354
+ p.apiKey = '';
355
+ }
356
+ const defaultExists = cfg.providers.some((p) => p.name.toLowerCase() === cfg.defaultProvider.toLowerCase());
357
+ if (!defaultExists)
358
+ cfg.defaultProvider = cfg.providers[0]?.name ?? '';
359
+ }
188
360
  export function loadConfig() {
189
361
  let cfg = { ...DEFAULTS, providers: [] };
190
362
  try {
@@ -201,17 +373,30 @@ export function loadConfig() {
201
373
  catch {
202
374
  // ignore corrupted config
203
375
  }
204
- // Env vars: ensure a DeepSeek provider exists / override the default provider.
205
- const envKey = process.env.PARALLEL_API_KEY || process.env.DEEPSEEK_API_KEY;
206
- if (envKey && cfg.providers.length === 0) {
207
- cfg.providers = [{ ...DEEPSEEK_PROVIDER, apiKey: envKey }];
376
+ normalizeConfig(cfg);
377
+ // Env vars: PARALLEL_API_KEY targets the current default provider; DEEPSEEK_API_KEY only targets DeepSeek.
378
+ const parallelKey = process.env.PARALLEL_API_KEY;
379
+ const deepseekKey = process.env.DEEPSEEK_API_KEY;
380
+ if (parallelKey && cfg.providers.length === 0) {
381
+ cfg.providers = [{ ...DEEPSEEK_PROVIDER, apiKey: parallelKey }];
208
382
  cfg.defaultProvider = DEEPSEEK_PROVIDER.name;
209
383
  }
210
- else if (envKey) {
384
+ else if (parallelKey) {
211
385
  const p = getProvider(cfg);
212
386
  if (p)
213
- p.apiKey = envKey;
387
+ p.apiKey = parallelKey;
388
+ }
389
+ if (deepseekKey) {
390
+ const p = getProvider(cfg, DEEPSEEK_PROVIDER.name);
391
+ if (p)
392
+ p.apiKey = deepseekKey;
393
+ else {
394
+ cfg.providers.push({ ...DEEPSEEK_PROVIDER, apiKey: deepseekKey });
395
+ if (!cfg.defaultProvider)
396
+ cfg.defaultProvider = DEEPSEEK_PROVIDER.name;
397
+ }
214
398
  }
399
+ normalizeConfig(cfg);
215
400
  const p = getProvider(cfg);
216
401
  if (p) {
217
402
  if (process.env.PARALLEL_BASE_URL)
@@ -72,6 +72,7 @@ export class Controller extends EventEmitter {
72
72
  conversationFiles = new Map();
73
73
  /** The session restored at startup (source of /restore conversations). */
74
74
  loadedSession = null;
75
+ sessionOnlyProvider = null;
75
76
  constructor(config, projectRoot) {
76
77
  super();
77
78
  this.config = config;
@@ -108,21 +109,30 @@ export class Controller extends EventEmitter {
108
109
  // ---------- providers / models ----------
109
110
  /** Provider used by the current session (falls back to the global default). */
110
111
  sessionProvider() {
112
+ if (this.sessionOnlyProvider &&
113
+ this.session.providerName.toLowerCase() === this.sessionOnlyProvider.name.toLowerCase()) {
114
+ return this.sessionOnlyProvider;
115
+ }
111
116
  return getProvider(this.config, this.session.providerName || undefined);
112
117
  }
113
118
  /** Resolve "model" or "provider:model" against the configured providers. */
114
119
  resolveModel(spec) {
115
- const m = spec.match(/^([^:]+):(.+)$/);
116
- if (m) {
117
- const provider = getProvider(this.config, m[1].trim());
118
- return provider ? { provider, model: m[2].trim() } : null;
120
+ const trimmed = spec.trim();
121
+ const sep = trimmed.indexOf(':');
122
+ if (sep > 0) {
123
+ const left = trimmed.slice(0, sep).trim();
124
+ const provider = this.sessionOnlyProvider && this.sessionOnlyProvider.name.toLowerCase() === left.toLowerCase()
125
+ ? this.sessionOnlyProvider
126
+ : getProvider(this.config, left);
127
+ if (provider)
128
+ return { provider, model: trimmed.slice(sep + 1).trim() };
119
129
  }
120
130
  // bare model name: current session provider first, then any provider listing it
121
131
  const cur = this.sessionProvider();
122
132
  if (cur)
123
- return { provider: cur, model: spec.trim() };
124
- const any = this.config.providers.find((p) => p.models.includes(spec.trim()));
125
- return any ? { provider: any, model: spec.trim() } : null;
133
+ return { provider: cur, model: trimmed };
134
+ const any = this.config.providers.find((p) => p.models.includes(trimmed));
135
+ return any ? { provider: any, model: trimmed } : null;
126
136
  }
127
137
  llmFor(provider, model) {
128
138
  const key = JSON.stringify([provider.name, provider.baseUrl, provider.apiKey, model]);
@@ -229,7 +239,7 @@ export class Controller extends EventEmitter {
229
239
  return;
230
240
  const [q] = this.questions.splice(idx, 1);
231
241
  this.board.log('', 'system', auto ? t('m.qAuto', { name: q.agentName, answer }) : t('m.qAnswered', { name: q.agentName, answer }));
232
- q.resolve(answer);
242
+ q.resolve({ answer, auto });
233
243
  this.emit('update');
234
244
  }
235
245
  // ---------- agents ----------
@@ -380,7 +390,7 @@ export class Controller extends EventEmitter {
380
390
  respawnAgent(name) {
381
391
  const sa = this.loadedSession?.agents.find((a) => a.name.toLowerCase() === name.toLowerCase());
382
392
  if (!sa)
383
- return 'no-conversation';
393
+ return 'no-agent';
384
394
  if (!sa.conversation || !fs.existsSync(sa.conversation))
385
395
  return 'no-conversation';
386
396
  let history;
@@ -425,7 +435,7 @@ export class Controller extends EventEmitter {
425
435
  for (const req of this.approvals.splice(0))
426
436
  req.resolve(false);
427
437
  for (const q of this.questions.splice(0))
428
- q.resolve(q.options[q.recommended] ?? '');
438
+ q.resolve({ answer: q.options[q.recommended] ?? '', auto: true });
429
439
  }
430
440
  sendToAgent(name, content) {
431
441
  const a = this.findAgent(name);
@@ -436,6 +446,15 @@ export class Controller extends EventEmitter {
436
446
  }
437
447
  broadcast(content) {
438
448
  this.board.addNote('user', 'all', content);
449
+ let n = 0;
450
+ for (const [id, agent] of this.agents.entries()) {
451
+ const info = this.board.agents.get(id);
452
+ if (!info || ['done', 'error', 'stopped'].includes(info.state))
453
+ continue;
454
+ agent.instruct(content);
455
+ n++;
456
+ }
457
+ return n;
439
458
  }
440
459
  hasRunningAgents() {
441
460
  return [...this.board.agents.values()].some((a) => ['working', 'thinking', 'listening', 'waiting', 'paused', 'idle'].includes(a.state));
@@ -462,8 +481,10 @@ export class Controller extends EventEmitter {
462
481
  continue;
463
482
  const conflict = this.board.changes.some((c2) => c2.id > c.id && c2.path === c.path && c2.agentId !== info.id);
464
483
  try {
465
- const abs = path.resolve(this.projectRoot, c.path);
466
- if (!abs.startsWith(path.resolve(this.projectRoot)))
484
+ const root = path.resolve(this.projectRoot);
485
+ const abs = path.resolve(root, c.path);
486
+ const rel = path.relative(root, abs);
487
+ if (rel.startsWith('..') || path.isAbsolute(rel))
467
488
  return 'none';
468
489
  fs.writeFileSync(abs, c.before);
469
490
  }
@@ -628,7 +649,7 @@ export class Controller extends EventEmitter {
628
649
  return { file, data: JSON.parse(fs.readFileSync(file, 'utf8')) };
629
650
  })
630
651
  .sort((a, b) => (a.data.savedAt < b.data.savedAt ? 1 : -1))
631
- .slice(0, 8);
652
+ .slice(0, 20);
632
653
  }
633
654
  catch {
634
655
  return [];
@@ -669,11 +690,19 @@ export class Controller extends EventEmitter {
669
690
  const p = getProvider(this.config, name);
670
691
  if (!p)
671
692
  return false;
693
+ this.sessionOnlyProvider = null;
672
694
  this.session.providerName = p.name;
673
- this.session.model = p.defaultModel;
695
+ this.session.model = p.defaultModel || p.models[0] || '';
674
696
  this.emit('update');
675
697
  return true;
676
698
  }
699
+ setSessionProviderConfig(p) {
700
+ this.sessionOnlyProvider = p;
701
+ this.session.providerName = p.name;
702
+ this.session.model = p.defaultModel || p.models[0] || '';
703
+ this.llmCache.clear();
704
+ this.emit('update');
705
+ }
677
706
  setSessionApprovalMode(mode) {
678
707
  this.session.approvalMode = mode;
679
708
  this.emit('update');
@@ -685,16 +714,18 @@ export class Controller extends EventEmitter {
685
714
  // ---------- GLOBAL settings (/settings) — persisted ----------
686
715
  saveProvider(p) {
687
716
  upsertProvider(this.config, p);
717
+ if (this.sessionOnlyProvider?.name.toLowerCase() === p.name.toLowerCase())
718
+ this.sessionOnlyProvider = null;
688
719
  this.llmCache.clear();
689
720
  // if the session points at this provider, refresh its view
690
721
  if (this.session.providerName.toLowerCase() === p.name.toLowerCase()) {
691
722
  this.session.providerName = p.name;
692
723
  if (!p.models.includes(this.session.model))
693
- this.session.model = p.defaultModel;
724
+ this.session.model = p.defaultModel || p.models[0] || '';
694
725
  }
695
726
  if (!this.session.providerName) {
696
727
  this.session.providerName = p.name;
697
- this.session.model = p.defaultModel;
728
+ this.session.model = p.defaultModel || p.models[0] || '';
698
729
  }
699
730
  this.emit('update');
700
731
  }
@@ -731,7 +762,7 @@ export class Controller extends EventEmitter {
731
762
  if (this.session.providerName.toLowerCase() === name.toLowerCase()) {
732
763
  const fallback = this.config.providers[0];
733
764
  this.session.providerName = fallback?.name ?? '';
734
- this.session.model = fallback?.defaultModel ?? '';
765
+ this.session.model = fallback?.defaultModel || fallback?.models[0] || '';
735
766
  }
736
767
  saveConfig(this.config);
737
768
  this.llmCache.clear();