wtt-connect 0.2.15 → 0.2.16

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/README.md CHANGED
@@ -100,10 +100,23 @@ Important settings:
100
100
  - `WTT_CONNECT_ENABLE_SHELL=1` exposes an interactive agent-side Terminal from wtt-web for claimed online agents
101
101
  - `WTT_CONNECT_SHELL_MODE=unsafe|readonly|off`; default `unsafe` runs one-shot shell commands without command filtering. The interactive Terminal always opens a real PTY shell on the agent host.
102
102
  - `WTT_CONNECT_SHELL_TIMEOUT_SECONDS` and `WTT_CONNECT_SHELL_MAX_OUTPUT_CHARS` only bound legacy one-shot command execution and returned output
103
+ - `WTT_CONNECT_MODEL` is only a fallback default. Per-message `metadata.model_config.model` from WTT Web overrides it for that run.
104
+
105
+ Optional model/provider switcher:
106
+
107
+ - `WTT_CONNECT_MODEL_SWITCH_MODE=off|cc-switch|command|custom`
108
+ - `WTT_CONNECT_MODEL_SWITCH_COMMAND=cc-switch`
109
+ - `WTT_CONNECT_MODEL_SWITCH_ARGS='["use","{adapter}","{provider}","--model","{model}"]'`
110
+ - `WTT_CONNECT_MODEL_SWITCH_TEMPLATE='cc-switch use {adapter} {provider} --model {model}'`
111
+ - `WTT_CONNECT_MODEL_SWITCH_STRICT=0|1`; when false, switcher failures are logged and the adapter still runs
112
+ - `WTT_CONNECT_MODEL_SWITCH_TIMEOUT_MS=15000`
113
+ - `WTT_CONNECT_MODEL_PROVIDER_MAP='{"openai-codex/*":{"adapter":"codex","provider":"openai"},"deepseek/*":{"adapter":"claude-code","provider":"deepseek"}}'`
114
+
115
+ This hook is for Cloud Agent hosts that use `cc-switch` or a compatible local provider/config switcher. WTT Web still only sends per-message `metadata.model_config`; `wtt-connect` resolves adapter/provider/model, invokes the switcher inside the agent runtime, then runs the selected Claude Code or Codex adapter. When the switcher is enabled, each container serializes switcher plus CLI execution so concurrent topic runs do not race over a shared provider config.
103
116
 
104
117
  Session continuity:
105
118
 
106
- - `wtt-connect` uses the WTT topic/task id as the local session key, for example `wtt:topic:<topic_id>`.
119
+ - `wtt-connect` uses the WTT topic/task id plus adapter and model as the local session key, for example `wtt:topic:<topic_id>:codex:gpt-5.5:high`.
107
120
  - Codex stores `codexThreadId` and resumes with `codex exec resume`.
108
121
  - Claude Code stores `claudeSessionId` and resumes the same topic with `claude --resume <session_id> -p ...`.
109
122
  - Keep `WTT_CONNECT_STATE_DIR`, `WTT_CONNECT_STORE_FILE`, and `WTT_CONNECT_WORKDIR` stable per claimed agent. If they are changed or deleted, the adapter cannot resume previous local sessions.
@@ -311,7 +324,7 @@ Discussion/collaborative topics are mention-gated: `wtt-connect` only replies wh
311
324
 
312
325
  In discussion/collaborative topics, `wtt-connect` also injects a silent collaboration standard into each agent prompt. Agents should treat the topic as a shared workboard, preserve shared state, split non-trivial work into owned tasks, state concrete done criteria, report execution evidence, challenge weak assumptions, and only @mention the next specific agent when handoff is required.
313
326
 
314
- Adapter routing can be explicit through task fields such as `exec_mode=gemini`, or through message mentions such as `@codex`, `@claude`, `@cursor`, `@gemini`, `@qoder`, `@opencode`, `@iflow`, `@kimi`, `@pi`, `@acp`, or `@devin` when those adapters are enabled.
327
+ Adapter routing can be explicit through task fields such as `exec_mode=gemini`, through message `metadata.model_config.adapter`, or through message mentions such as `@codex`, `@claude`, `@cursor`, `@gemini`, `@qoder`, `@opencode`, `@iflow`, `@kimi`, `@pi`, `@acp`, or `@devin` when those adapters are enabled. If `metadata.model_config.model` is present, OpenAI/OpenAI-Codex model IDs route to Codex and Claude/DeepSeek/Kimi-style model IDs route to Claude Code. When the optional model switcher is enabled, the same resolved adapter/provider/model is passed to the configured `cc-switch`-compatible command before the adapter starts.
315
328
 
316
329
  OpenClaw is deliberately not part of the default `wtt-connect` adapter set because WTT already has the first-class `wtt-plugin` for OpenClaw.
317
330
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wtt-connect",
3
- "version": "0.2.15",
3
+ "version": "0.2.16",
4
4
  "private": false,
5
5
  "description": "WTT-native connector daemon for Codex, Claude Code, Cursor, Gemini, ACP, and other coding agent surfaces.",
6
6
  "type": "module",
@@ -24,8 +24,11 @@ export class AdapterRegistry {
24
24
  }
25
25
 
26
26
  select(input = {}) {
27
- const explicit = String(input.adapter || input.exec_mode || input.execMode || '').trim().toLowerCase();
27
+ const modelConfig = modelConfigFromInput(input);
28
+ const explicit = String(modelConfig.adapter || input.adapter || input.exec_mode || input.execMode || '').trim().toLowerCase();
28
29
  if (explicit && this.adapters.has(normalizeAdapterName(explicit))) return this.get(normalizeAdapterName(explicit));
30
+ const modelAdapter = adapterForModel(modelConfig.model);
31
+ if (modelAdapter && this.adapters.has(modelAdapter)) return this.get(modelAdapter);
29
32
  const type = String(input.task_type || input.taskType || '').trim().toLowerCase();
30
33
  if (type && this.config.adapterPolicy?.[type]) return this.get(this.config.adapterPolicy[type]);
31
34
  const text = `${input.title || ''}\n${input.description || ''}\n${input.content || ''}`.toLowerCase();
@@ -40,6 +43,34 @@ export class AdapterRegistry {
40
43
  }
41
44
  }
42
45
 
46
+ function modelConfigFromInput(input = {}) {
47
+ const direct = input.model_config || input.modelConfig;
48
+ if (direct && typeof direct === 'object') return direct;
49
+ const meta = parseMetadata(input.metadata || input.msg_metadata || input.meta);
50
+ const cfg = meta?.model_config || meta?.modelConfig;
51
+ return cfg && typeof cfg === 'object' ? cfg : {};
52
+ }
53
+
54
+ function parseMetadata(metadata) {
55
+ if (!metadata) return null;
56
+ if (typeof metadata === 'object') return metadata;
57
+ if (typeof metadata !== 'string') return null;
58
+ try {
59
+ const parsed = JSON.parse(metadata);
60
+ return parsed && typeof parsed === 'object' ? parsed : null;
61
+ } catch {
62
+ return null;
63
+ }
64
+ }
65
+
66
+ function adapterForModel(model) {
67
+ const value = String(model || '').trim().toLowerCase();
68
+ if (!value) return '';
69
+ if (value.startsWith('openai-codex/') || value.startsWith('openai/') || value.startsWith('gpt-') || value.startsWith('o3') || value.startsWith('o4')) return 'codex';
70
+ if (value.startsWith('anthropic/') || value.startsWith('claude') || value.startsWith('deepseek/') || value.startsWith('deepseek') || value.startsWith('moonshot/') || value.startsWith('kimi')) return 'claude-code';
71
+ return '';
72
+ }
73
+
43
74
  function adapterMentionAliases() {
44
75
  return [
45
76
  'codex',
@@ -14,8 +14,9 @@ export class ClaudeCodeAdapter {
14
14
  const sessionKey = context.sessionKey || 'default';
15
15
  const stored = this.store?.getSession(sessionKey) || {};
16
16
  const sessionId = this.sessionByKey.get(sessionKey) || stored.claudeSessionId;
17
- const args = this.buildArgs(prompt, sessionId);
18
- log('info', 'claude-code launch', { sessionKey, resume: Boolean(sessionId) });
17
+ const args = this.buildArgs(prompt, sessionId, context);
18
+ const runtimeModel = modelForClaude(this.config, context);
19
+ log('info', 'claude-code launch', { sessionKey, resume: Boolean(sessionId), model: runtimeModel || this.config.model || process.env.ANTHROPIC_MODEL || '' });
19
20
  let result;
20
21
  try {
21
22
  result = await runClaude(this.config.claudeBin, args, this.config.workDir, this.config.taskTimeoutSeconds * 1000, context.onProgress);
@@ -24,7 +25,7 @@ export class ClaudeCodeAdapter {
24
25
  log('warn', 'claude-code resume failed; retrying with fresh session', { sessionKey, sessionId, error: err.message });
25
26
  this.sessionByKey.delete(sessionKey);
26
27
  this.store?.patchSession(sessionKey, { claudeSessionId: '', adapter: this.name });
27
- result = await runClaude(this.config.claudeBin, this.buildArgs(prompt, ''), this.config.workDir, this.config.taskTimeoutSeconds * 1000, context.onProgress);
28
+ result = await runClaude(this.config.claudeBin, this.buildArgs(prompt, '', context), this.config.workDir, this.config.taskTimeoutSeconds * 1000, context.onProgress);
28
29
  }
29
30
  if (result.sessionId) {
30
31
  this.sessionByKey.set(sessionKey, result.sessionId);
@@ -33,17 +34,26 @@ export class ClaudeCodeAdapter {
33
34
  return result.text.trim();
34
35
  }
35
36
 
36
- buildArgs(prompt, sessionId = '') {
37
+ buildArgs(prompt, sessionId = '', context = {}) {
37
38
  // Claude Code 2.x requires --verbose when stream-json is used with --print/-p.
38
39
  const args = [];
39
40
  if (sessionId) args.push('--resume', sessionId);
40
41
  args.push('-p', prompt, '--output-format', 'stream-json', '--verbose');
41
42
  if (['full-auto', 'yolo'].includes(this.config.mode)) args.push('--dangerously-skip-permissions', '--permission-mode', 'bypassPermissions');
42
- if (this.config.model) args.push('--model', this.config.model);
43
+ const model = modelForClaude(this.config, context);
44
+ if (model) args.push('--model', model);
43
45
  return args;
44
46
  }
45
47
  }
46
48
 
49
+ function modelForClaude(config, context = {}) {
50
+ const cfg = context.modelConfig || {};
51
+ const model = String(cfg.model || cfg.model_id || cfg.modelId || config.model || process.env.ANTHROPIC_MODEL || '').trim();
52
+ if (!model) return '';
53
+ if (model.startsWith('anthropic/')) return model.slice('anthropic/'.length);
54
+ return model;
55
+ }
56
+
47
57
  function shouldRetryFreshSession(err) {
48
58
  const message = String(err?.message || err || '').toLowerCase();
49
59
  return [
@@ -16,7 +16,8 @@ export class CodexAdapter {
16
16
  const stored = this.store?.getSession(sessionKey) || {};
17
17
  const threadId = this.threadBySession.get(sessionKey) || stored.codexThreadId;
18
18
  const args = this.buildArgs(threadId, context);
19
- log('info', 'codex launch', { sessionKey, resume: Boolean(threadId), mode: this.config.mode });
19
+ const runtimeModel = modelForCodex(this.config, context);
20
+ log('info', 'codex launch', { sessionKey, resume: Boolean(threadId), mode: this.config.mode, model: runtimeModel || this.config.model || '' });
20
21
  let result;
21
22
  try {
22
23
  result = await this.runWithArgs(args, prompt, context, sessionKey);
@@ -48,14 +49,38 @@ export class CodexAdapter {
48
49
  buildArgs(threadId, context = {}) {
49
50
  const options = ['--skip-git-repo-check', '--json'];
50
51
  options.push(...(this.permissions?.codexArgs() || []));
51
- if (this.config.model) options.push('--model', this.config.model);
52
+ const model = modelForCodex(this.config, context);
53
+ if (model) options.push('--model', model);
52
54
  for (const img of context.images || []) options.push('--image', img);
53
- if (this.config.reasoningEffort) options.push('-c', `model_reasoning_effort=${JSON.stringify(this.config.reasoningEffort)}`);
55
+ const reasoningEffort = reasoningEffortForRun(this.config, context);
56
+ if (reasoningEffort && reasoningEffort !== 'off') options.push('-c', `model_reasoning_effort=${JSON.stringify(reasoningEffort)}`);
54
57
  if (threadId) return ['exec', 'resume', ...options, threadId, '-'];
55
58
  return ['exec', ...options, '--cd', this.config.workDir, '-'];
56
59
  }
57
60
  }
58
61
 
62
+ function modelForCodex(config, context = {}) {
63
+ return normalizeCodexModel(modelFromContext(config, context));
64
+ }
65
+
66
+ function modelFromContext(config, context = {}) {
67
+ const cfg = context.modelConfig || {};
68
+ return String(cfg.model || cfg.model_id || cfg.modelId || config.model || process.env.OPENAI_MODEL || process.env.CODEX_MODEL || '').trim();
69
+ }
70
+
71
+ function reasoningEffortForRun(config, context = {}) {
72
+ const cfg = context.modelConfig || {};
73
+ return String(cfg.reasoning_effort || cfg.reasoningEffort || config.reasoningEffort || '').trim().toLowerCase();
74
+ }
75
+
76
+ function normalizeCodexModel(model) {
77
+ const value = String(model || '').trim();
78
+ if (!value) return '';
79
+ if (value.startsWith('openai-codex/')) return value.slice('openai-codex/'.length);
80
+ if (value.startsWith('openai/')) return value.slice('openai/'.length);
81
+ return value;
82
+ }
83
+
59
84
  function shouldRetryFreshThread(err) {
60
85
  const message = String(err?.message || err || '').toLowerCase();
61
86
  return [
package/src/config.js CHANGED
@@ -41,6 +41,15 @@ export function loadConfig(argv = {}) {
41
41
  adapter,
42
42
  adapters,
43
43
  adapterPolicy: fileConfig.adapterPolicy || parseJsonEnv(process.env.WTT_CONNECT_ADAPTER_POLICY, {}),
44
+ modelSwitch: {
45
+ mode: process.env.WTT_CONNECT_MODEL_SWITCH_MODE || fileConfig.modelSwitch?.mode || 'off',
46
+ command: process.env.WTT_CONNECT_MODEL_SWITCH_COMMAND || fileConfig.modelSwitch?.command || '',
47
+ args: parseJsonOrListEnv(process.env.WTT_CONNECT_MODEL_SWITCH_ARGS || fileConfig.modelSwitch?.args || ''),
48
+ template: process.env.WTT_CONNECT_MODEL_SWITCH_TEMPLATE || fileConfig.modelSwitch?.template || '',
49
+ strict: parseBool(process.env.WTT_CONNECT_MODEL_SWITCH_STRICT, fileConfig.modelSwitch?.strict ?? false),
50
+ timeoutMs: parseIntEnv(process.env.WTT_CONNECT_MODEL_SWITCH_TIMEOUT_MS, fileConfig.modelSwitch?.timeoutMs || 15000),
51
+ providerMap: fileConfig.modelSwitch?.providerMap || parseJsonEnv(process.env.WTT_CONNECT_MODEL_PROVIDER_MAP, {}),
52
+ },
44
53
  workDir,
45
54
  model: process.env.WTT_CONNECT_MODEL || fileConfig.model || '',
46
55
  mode: process.env.WTT_CONNECT_MODE || fileConfig.mode || 'full-auto',
@@ -147,3 +156,18 @@ function parseListEnv(value) {
147
156
  }
148
157
  return raw.split(',').map((x) => x.trim()).filter(Boolean);
149
158
  }
159
+
160
+ function parseJsonOrListEnv(value) {
161
+ if (!value) return [];
162
+ if (Array.isArray(value)) return value.map((x) => String(x));
163
+ if (typeof value !== 'string') return [];
164
+ const raw = value.trim();
165
+ if (!raw) return [];
166
+ if (raw.startsWith('[')) {
167
+ try {
168
+ const parsed = JSON.parse(raw);
169
+ if (Array.isArray(parsed)) return parsed.map((x) => String(x));
170
+ } catch {}
171
+ }
172
+ return parseListEnv(raw);
173
+ }
@@ -0,0 +1,137 @@
1
+ import { execFile, exec } from 'node:child_process';
2
+ import { promisify } from 'node:util';
3
+ import { log } from './logger.js';
4
+
5
+ const execFileAsync = promisify(execFile);
6
+ const execAsync = promisify(exec);
7
+
8
+ export function isModelSwitcherEnabled(config) {
9
+ const mode = String(config.modelSwitch?.mode || '').trim().toLowerCase();
10
+ return ['cc-switch', 'ccswitch', 'command', 'custom'].includes(mode);
11
+ }
12
+
13
+ export async function switchModelProvider(config, input = {}) {
14
+ if (!isModelSwitcherEnabled(config)) return resolveSwitchTarget(config, input);
15
+
16
+ const target = resolveSwitchTarget(config, input);
17
+ const switcher = config.modelSwitch || {};
18
+ const command = String(switcher.command || '').trim();
19
+ const template = String(switcher.template || '').trim();
20
+
21
+ if (!command && !template) {
22
+ const message = 'model switcher enabled but no command/template configured';
23
+ if (switcher.strict) throw new Error(message);
24
+ log('warn', message, { target });
25
+ return target;
26
+ }
27
+
28
+ try {
29
+ if (template) {
30
+ const rendered = renderShellTemplate(template, target);
31
+ await execAsync(rendered, { timeout: switcher.timeoutMs || 15000 });
32
+ log('info', 'model switcher template completed', { adapter: target.adapter, provider: target.provider, model: target.model });
33
+ return target;
34
+ }
35
+
36
+ const args = (switcher.args?.length ? switcher.args : defaultCcSwitchArgs(target))
37
+ .map((arg) => renderArgTemplate(arg, target));
38
+ await execFileAsync(command, args, { timeout: switcher.timeoutMs || 15000 });
39
+ log('info', 'model switcher command completed', { command, adapter: target.adapter, provider: target.provider, model: target.model });
40
+ return target;
41
+ } catch (err) {
42
+ const message = `model switcher failed: ${err.message}`;
43
+ if (switcher.strict) throw new Error(message);
44
+ log('warn', message, { adapter: target.adapter, provider: target.provider, model: target.model });
45
+ return target;
46
+ }
47
+ }
48
+
49
+ export function resolveSwitchTarget(config, input = {}) {
50
+ const modelConfig = input.modelConfig || {};
51
+ const rawModel = String(modelConfig.model || modelConfig.model_id || modelConfig.modelId || config.model || '').trim();
52
+ const mapped = modelMapEntry(config, rawModel);
53
+ const adapter = normalizeAdapter(modelConfig.adapter || modelConfig.agent_type || modelConfig.agentType || mapped.adapter || input.adapter || '');
54
+ const provider = String(modelConfig.provider || mapped.provider || providerFromModel(rawModel, adapter) || '').trim();
55
+ const model = String(mapped.model || modelForAdapter(rawModel, adapter) || rawModel || '').trim();
56
+ const reasoningEffort = String(modelConfig.reasoning_effort || modelConfig.reasoningEffort || '').trim().toLowerCase();
57
+ return {
58
+ adapter,
59
+ provider,
60
+ model,
61
+ raw_model: rawModel,
62
+ rawModel,
63
+ reasoning_effort: reasoningEffort,
64
+ reasoningEffort,
65
+ session_key: String(input.sessionKey || ''),
66
+ sessionKey: String(input.sessionKey || ''),
67
+ };
68
+ }
69
+
70
+ function modelMapEntry(config, rawModel) {
71
+ const map = config.modelSwitch?.providerMap || {};
72
+ const exact = map[rawModel];
73
+ if (exact && typeof exact === 'object') return exact;
74
+ const lower = String(rawModel || '').toLowerCase();
75
+ for (const [key, value] of Object.entries(map)) {
76
+ const pattern = String(key || '').toLowerCase();
77
+ if (!pattern.endsWith('*')) continue;
78
+ if (lower.startsWith(pattern.slice(0, -1)) && value && typeof value === 'object') return value;
79
+ }
80
+ return {};
81
+ }
82
+
83
+ function defaultCcSwitchArgs(target) {
84
+ const args = ['use'];
85
+ if (target.adapter) args.push('{adapter}');
86
+ if (target.provider) args.push('{provider}');
87
+ if (target.model) args.push('--model', '{model}');
88
+ return args;
89
+ }
90
+
91
+ function renderArgTemplate(value, target) {
92
+ return String(value).replace(/\{([a-zA-Z_][a-zA-Z0-9_]*)\}/g, (_all, key) => safeText(target[key] ?? ''));
93
+ }
94
+
95
+ function renderShellTemplate(value, target) {
96
+ return String(value).replace(/\{([a-zA-Z_][a-zA-Z0-9_]*)\}/g, (_all, key) => shellQuote(target[key] ?? ''));
97
+ }
98
+
99
+ function safeText(value) {
100
+ return String(value ?? '').replace(/[\r\n]/g, ' ').trim();
101
+ }
102
+
103
+ function shellQuote(value) {
104
+ const text = safeText(value);
105
+ if (!text) return "''";
106
+ return `'${text.replace(/'/g, `'\\''`)}'`;
107
+ }
108
+
109
+ function normalizeAdapter(value) {
110
+ const adapter = String(value || '').trim().toLowerCase();
111
+ if (adapter === 'claude') return 'claude-code';
112
+ if (adapter === 'cc-connect') return 'codex';
113
+ return adapter || '';
114
+ }
115
+
116
+ function providerFromModel(rawModel, adapter) {
117
+ const value = String(rawModel || '').trim().toLowerCase();
118
+ if (value.startsWith('openai-codex/')) return 'openai';
119
+ if (value.includes('/')) return value.split('/')[0];
120
+ if (value.startsWith('gpt-') || value.startsWith('o3') || value.startsWith('o4')) return 'openai';
121
+ if (value.startsWith('claude')) return 'anthropic';
122
+ if (value.startsWith('deepseek')) return 'deepseek';
123
+ if (value.startsWith('kimi')) return 'moonshot';
124
+ if (adapter === 'codex') return 'openai';
125
+ return '';
126
+ }
127
+
128
+ function modelForAdapter(rawModel, adapter) {
129
+ const value = String(rawModel || '').trim();
130
+ if (!value) return '';
131
+ if (adapter === 'codex') {
132
+ if (value.startsWith('openai-codex/')) return value.slice('openai-codex/'.length);
133
+ if (value.startsWith('openai/')) return value.slice('openai/'.length);
134
+ }
135
+ if (adapter === 'claude-code' && value.startsWith('anthropic/')) return value.slice('anthropic/'.length);
136
+ return value.includes('/') ? value.split('/').slice(1).join('/') : value;
137
+ }
package/src/runner.js CHANGED
@@ -18,6 +18,7 @@ import { log } from './logger.js';
18
18
  import { buildRuntimeInfo } from './runtime-info.js';
19
19
  import { runShellCommand } from './shell-runner.js';
20
20
  import { TerminalSessionManager } from './terminal-session.js';
21
+ import { isModelSwitcherEnabled, switchModelProvider } from './model-switcher.js';
21
22
 
22
23
  const TERMINAL_STATUSES = new Set(['review', 'done', 'approved', 'cancelled']);
23
24
  const GENERATED_FILE_EXTENSIONS = new Set([
@@ -47,6 +48,7 @@ export class Runner {
47
48
  this.runningTasks = new Set();
48
49
  this.doneTasks = new Map();
49
50
  this.agentProfileCache = { value: null, expiresAt: 0 };
51
+ this.modelRunTail = Promise.resolve();
50
52
  }
51
53
 
52
54
  async start() {
@@ -167,8 +169,9 @@ export class Runner {
167
169
  await this.wtt.typing(topicId, 'start', { statusText: 'Agent 已接收消息,正在准备执行', statusKind: 'queued', ttlMs: 30000 });
168
170
  try {
169
171
  const transcripts = await this.transcribeAttachments(staged.files);
170
- const adapter = this.registry.select({ ...m, content });
171
- await this.wtt.typing(topicId, 'start', { statusText: `${adapterDisplayName(adapter.name)} 正在执行`, statusKind: 'running', adapter: adapter.name, ttlMs: 30000 });
172
+ const modelConfig = modelConfigFromMessage(m);
173
+ const adapter = this.registry.select({ ...m, content, model_config: modelConfig });
174
+ await this.wtt.typing(topicId, 'start', { statusText: `${adapterDisplayName(adapter.name)} 正在执行${modelConfig.model ? ` · ${modelConfig.model}` : ''}`, statusKind: 'running', adapter: adapter.name, model: modelConfig.model || undefined, ttlMs: 30000 });
172
175
  const agentProfile = await this.getAgentProfile();
173
176
  const agentSoul = renderAgentSoulContext(m.metadata, agentProfile?.role_template);
174
177
  const discussionRouting = renderDiscussionRoutingInstruction(m, this.config);
@@ -195,9 +198,10 @@ export class Runner {
195
198
  renderGeneratedFileArtifactInstruction(this.config, topicId),
196
199
  'Reply naturally and concisely unless the user asks for detail.',
197
200
  ].filter(Boolean).join('\n');
198
- const output = await adapter.run(prompt, {
199
- sessionKey: `wtt:topic:${topicId}`,
201
+ const output = await this.runAdapter(adapter, prompt, {
202
+ sessionKey: `wtt:topic:${topicId}:${adapter.name}:${sessionModelKey(modelConfig)}`,
200
203
  topicId,
204
+ modelConfig,
201
205
  files: staged.files,
202
206
  images: staged.images,
203
207
  onProgress: (event) => this.maybePublishProgress(topicId, event, adapter.name),
@@ -224,15 +228,17 @@ export class Runner {
224
228
  await this.api.patchTask(taskId, { status: 'doing', progress: 0, runner_agent_id: this.config.agentId });
225
229
  const staged = await this.attachments.stageMessage(task);
226
230
  const transcripts = await this.transcribeAttachments(staged.files);
227
- const adapter = this.registry.select(task);
231
+ const modelConfig = modelConfigFromMessage(task);
232
+ const adapter = this.registry.select({ ...task, model_config: modelConfig });
228
233
  const agentProfile = await this.getAgentProfile();
229
- if (topicId) await this.wtt.typing(topicId, 'start', { statusText: `${adapterDisplayName(adapter.name)} 正在执行任务`, statusKind: 'running', adapter: adapter.name, ttlMs: 30000 });
234
+ if (topicId) await this.wtt.typing(topicId, 'start', { statusText: `${adapterDisplayName(adapter.name)} 正在执行任务${modelConfig.model ? ` · ${modelConfig.model}` : ''}`, statusKind: 'running', adapter: adapter.name, model: modelConfig.model || undefined, ttlMs: 30000 });
230
235
  const prompt = buildTaskPrompt(task, this.config, staged, transcripts, agentProfile);
231
236
  try {
232
- const output = await adapter.run(prompt, {
233
- sessionKey: `wtt:task:${taskId}`,
237
+ const output = await this.runAdapter(adapter, prompt, {
238
+ sessionKey: `wtt:task:${taskId}:${adapter.name}:${sessionModelKey(modelConfig)}`,
234
239
  taskId,
235
240
  topicId,
241
+ modelConfig,
236
242
  files: staged.files,
237
243
  images: staged.images,
238
244
  onProgress: (ev) => topicId ? this.maybePublishProgress(topicId, ev, adapter.name) : Promise.resolve(),
@@ -268,6 +274,31 @@ export class Runner {
268
274
  return value;
269
275
  }
270
276
 
277
+ async runAdapter(adapter, prompt, context = {}) {
278
+ const execute = async () => {
279
+ await switchModelProvider(this.config, {
280
+ adapter: adapter.name,
281
+ modelConfig: context.modelConfig,
282
+ sessionKey: context.sessionKey,
283
+ });
284
+ return adapter.run(prompt, context);
285
+ };
286
+
287
+ if (!isModelSwitcherEnabled(this.config)) return execute();
288
+
289
+ const previous = this.modelRunTail.catch(() => {});
290
+ let release = () => {};
291
+ this.modelRunTail = new Promise((resolve) => {
292
+ release = resolve;
293
+ });
294
+ await previous;
295
+ try {
296
+ return await execute();
297
+ } finally {
298
+ release();
299
+ }
300
+ }
301
+
271
302
  async transcribeAttachments(files) {
272
303
  try {
273
304
  return await this.stt.transcribeAll(files);
@@ -677,6 +708,28 @@ function metadataValue(metadata, key) {
677
708
  return obj && typeof obj === 'object' ? obj[key] : '';
678
709
  }
679
710
 
711
+ function modelConfigFromMessage(message = {}) {
712
+ const direct = message.model_config || message.modelConfig;
713
+ const meta = parseMetadata(message.metadata || message.msg_metadata || message.meta);
714
+ const cfg = (direct && typeof direct === 'object') ? direct : (meta?.model_config || meta?.modelConfig);
715
+ if (!cfg || typeof cfg !== 'object') return {};
716
+ const model = String(cfg.model || cfg.model_id || cfg.modelId || '').trim();
717
+ const reasoningRaw = String(cfg.reasoning_effort || cfg.reasoningEffort || '').trim().toLowerCase();
718
+ const reasoningEffort = ['off', 'low', 'medium', 'high', 'xhigh'].includes(reasoningRaw) ? reasoningRaw : '';
719
+ const adapter = String(cfg.adapter || cfg.agent_type || cfg.agentType || '').trim().toLowerCase();
720
+ return {
721
+ ...(model ? { model } : {}),
722
+ ...(reasoningEffort ? { reasoning_effort: reasoningEffort, reasoningEffort } : {}),
723
+ ...(adapter ? { adapter } : {}),
724
+ };
725
+ }
726
+
727
+ function sessionModelKey(modelConfig = {}) {
728
+ const model = String(modelConfig.model || 'default').trim().toLowerCase();
729
+ const effort = String(modelConfig.reasoning_effort || modelConfig.reasoningEffort || '').trim().toLowerCase();
730
+ return `${model || 'default'}:${effort || 'default'}`.replace(/[^a-z0-9._:-]+/g, '-').slice(0, 96);
731
+ }
732
+
680
733
  function parseMetadata(metadata) {
681
734
  if (!metadata) return null;
682
735
  let obj = metadata;
@@ -6,11 +6,14 @@ import { execFileSync } from 'node:child_process';
6
6
  export function buildRuntimeInfo(config) {
7
7
  const workdir = resolveWorkDir(config.workDir);
8
8
  const git = gitInfo(workdir);
9
+ const model = runtimeModel(config);
9
10
  return {
10
11
  kind: 'wtt-connect',
11
12
  agent_id: config.agentId,
12
13
  adapter: config.adapter,
13
14
  adapters: config.adapters,
15
+ ...(model ? { model, current_model: model } : {}),
16
+ ...(config.reasoningEffort ? { reasoning_effort: String(config.reasoningEffort) } : {}),
14
17
  workdir,
15
18
  workdir_name: path.basename(workdir || ''),
16
19
  cwd: safeRealpath(process.cwd()),
@@ -25,6 +28,20 @@ export function buildRuntimeInfo(config) {
25
28
  };
26
29
  }
27
30
 
31
+ function runtimeModel(config) {
32
+ const explicit = String(config.model || '').trim();
33
+ if (explicit) return explicit;
34
+
35
+ const adapter = String(config.adapter || '').trim().toLowerCase();
36
+ if (adapter === 'codex') {
37
+ return String(process.env.OPENAI_MODEL || process.env.CODEX_MODEL || '').trim();
38
+ }
39
+ if (adapter === 'claude-code' || adapter === 'claude' || adapter === 'claude_code') {
40
+ return String(process.env.ANTHROPIC_MODEL || '').trim();
41
+ }
42
+ return String(process.env.WTT_CONNECT_MODEL || '').trim();
43
+ }
44
+
28
45
  function resolveWorkDir(workDir) {
29
46
  const raw = String(workDir || process.cwd()).trim() || process.cwd();
30
47
  return safeRealpath(path.resolve(raw));