@hzxwonder/dsh-mcp 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/setup.js CHANGED
@@ -1,15 +1,14 @@
1
1
  import { constants } from 'node:fs';
2
2
  import { chmod, lstat, mkdir, open, readFile, rename, rm } from 'node:fs/promises';
3
3
  import { homedir } from 'node:os';
4
- import { dirname, join, resolve } from 'node:path';
4
+ import { dirname, isAbsolute, join, resolve } from 'node:path';
5
5
  import { fileURLToPath, pathToFileURL } from 'node:url';
6
- import { parseArgs, parseEnv } from 'node:util';
7
- import { createInterface } from 'node:readline';
8
- import { Writable } from 'node:stream';
6
+ import { isDeepStrictEqual, parseArgs, parseEnv } from 'node:util';
9
7
  import { randomUUID } from 'node:crypto';
10
8
  import { parse } from 'smol-toml';
11
- import { DEFAULT_BASE_URL, normalizeBaseUrl, parseProviderSettings } from './provider.js';
12
- import { createSetupForm, openSetupBrowser } from './setup-ui.js';
9
+ import { parseProviderSettings, resolveProviderSettings } from './provider.js';
10
+ import { runTerminalSetup } from './setup-terminal.js';
11
+ import { inspectRegistration } from './doctor.js';
13
12
 
14
13
  const packageRoot = fileURLToPath(new URL('../', import.meta.url));
15
14
  const defaultHome = join(homedir(), '.dsh-mcp');
@@ -39,10 +38,13 @@ function managedBlock(text, begin, end, body) {
39
38
  throw new Error(`Repair the incomplete or duplicate managed block: ${begin}`);
40
39
  }
41
40
  const block = `${begin}\n${body.trimEnd()}\n${end}\n`;
42
- if (!starts.length) return text + (text && !text.endsWith('\n') ? '\n' : '') + (text ? '\n' : '') + block;
41
+ if (!starts.length) {
42
+ return { text: text + (text && !text.endsWith('\n') ? '\n' : '') + (text ? '\n' : '') + block, previous: null };
43
+ }
44
+ const previous = text.slice(starts[0].index + starts[0][0].length, ends[0].index);
43
45
  let after = ends[0].index + ends[0][0].length;
44
46
  if (text[after] === '\n') after++;
45
- return text.slice(0, starts[0].index) + block + text.slice(after);
47
+ return { text: text.slice(0, starts[0].index) + block + text.slice(after), previous };
46
48
  }
47
49
 
48
50
  function parseConfig(text) {
@@ -50,6 +52,51 @@ function parseConfig(text) {
50
52
  catch { throw new Error('Codex config.toml is not valid TOML. Repair this file before setup.'); }
51
53
  }
52
54
 
55
+ function updateCredential(text, requestedKey) {
56
+ const before = parseEnv(text);
57
+ const key = (requestedKey ?? before.DEEPSEEK_API_KEY)?.trim();
58
+ if (!key || /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/u.test(key)) {
59
+ throw new Error('API Key 必须是非空的单行文本。');
60
+ }
61
+ if (before.DEEPSEEK_API_KEY === key) return text;
62
+ if (/[\r\n]/.test(before.DEEPSEEK_API_KEY ?? '')) {
63
+ throw new Error('旧凭据格式无法安全更新。请先备份私有 .env 文件,现有内容未改变。');
64
+ }
65
+ let replaced = false;
66
+ let next = text.replace(/^[\uFEFF \t]*(?:export[ \t]+)?DEEPSEEK_API_KEY[ \t]*=.*(?:\r?\n|$)/gm, () => {
67
+ if (replaced) return '';
68
+ replaced = true;
69
+ return `DEEPSEEK_API_KEY=${JSON.stringify(key)}\n`;
70
+ });
71
+ if (!replaced) next += `${next && !next.endsWith('\n') ? '\n' : ''}DEEPSEEK_API_KEY=${JSON.stringify(key)}\n`;
72
+ if (!isDeepStrictEqual({ ...parseEnv(next) }, { ...before, DEEPSEEK_API_KEY: key })) {
73
+ throw new Error('无法在保留其他凭据配置的同时安全更新 API Key,现有内容未改变。');
74
+ }
75
+ return next;
76
+ }
77
+
78
+ async function matchesPreviousBundledSkill(server, skillText) {
79
+ const script = server?.args?.[0];
80
+ if (typeof script !== 'string' || !isAbsolute(script)) return false;
81
+ const previousRoot = dirname(dirname(script));
82
+ if (script !== join(previousRoot, 'src', 'server.js')) return false;
83
+ try {
84
+ const [currentPackage, previousPackage, previousSkill, scriptInfo] = await Promise.all([
85
+ readExisting(join(packageRoot, 'package.json')),
86
+ readExisting(join(previousRoot, 'package.json')),
87
+ readExisting(join(previousRoot, '.agents', 'skills', 'deepseek-worker', 'SKILL.md')),
88
+ inspect(script),
89
+ ]);
90
+ const name = JSON.parse(currentPackage.text).name;
91
+ return typeof name === 'string' && name.length > 0
92
+ && JSON.parse(previousPackage.text).name === name
93
+ && scriptInfo?.isFile() === true
94
+ && previousSkill.mode !== null && previousSkill.text === skillText;
95
+ } catch {
96
+ return false;
97
+ }
98
+ }
99
+
53
100
  async function atomicWrite(path, text, mode) {
54
101
  const temp = join(dirname(path), `.dsh-mcp-${randomUUID()}.tmp`);
55
102
  try {
@@ -64,7 +111,7 @@ export async function installCodex({
64
111
  home = defaultHome,
65
112
  codexHome = process.env.CODEX_HOME || join(homedir(), '.codex'),
66
113
  skillsHome = join(homedir(), '.agents', 'skills'),
67
- apiKey, baseUrl,
114
+ apiKey, baseUrl, protocol, model,
68
115
  }) {
69
116
  if (process.platform === 'win32') throw new Error('Setup requires macOS or Linux.');
70
117
  home = resolve(home);
@@ -82,30 +129,34 @@ export async function installCodex({
82
129
  readFile(join(packageRoot, '.agents', 'skills', 'deepseek-worker', 'SKILL.md'), 'utf8'),
83
130
  ]);
84
131
  const parsed = parseConfig(config.text);
85
- if (parsed.mcp_servers?.dsh && !/^# BEGIN dsh-mcp\r?$/m.test(config.text)) {
86
- throw new Error('Codex already defines mcp_servers.dsh. Preserve or rename that server before setup.');
87
- }
88
- if (skill.mode !== null && skill.text !== bundledSkill) {
89
- throw new Error('A different deepseek-worker skill already exists. Preserve or rename that skill before setup.');
90
- }
91
132
  const args = [join(packageRoot, 'src', 'server.js'), '--dsh-home', home];
92
133
  const entry = [
93
134
  '[mcp_servers.dsh]', `command = ${JSON.stringify(process.execPath)}`, `args = ${JSON.stringify(args)}`,
135
+ 'enabled = true',
94
136
  'startup_timeout_sec = 20', 'tool_timeout_sec = 960',
95
137
  ].join('\n');
96
- const nextConfig = managedBlock(config.text, '# BEGIN dsh-mcp', '# END dsh-mcp', entry);
138
+ const { text: nextConfig, previous } = managedBlock(config.text, '# BEGIN dsh-mcp', '# END dsh-mcp', entry);
139
+ const previousServer = previous === null ? undefined : parseConfig(previous).mcp_servers?.dsh;
140
+ const ownsServer = parsed.mcp_servers?.dsh && previousServer
141
+ && isDeepStrictEqual(parsed.mcp_servers.dsh, previousServer);
142
+ if (parsed.mcp_servers?.dsh && !ownsServer) {
143
+ throw new Error('Codex already defines mcp_servers.dsh outside this installer’s managed block. Preserve or rename that server before setup.');
144
+ }
145
+ if (skill.mode !== null && skill.text !== bundledSkill
146
+ && !(ownsServer && await matchesPreviousBundledSkill(previousServer, skill.text))) {
147
+ throw new Error('A different deepseek-worker skill already exists and cannot be safely upgraded. Back up or rename that skill before setup; existing files were not changed.');
148
+ }
97
149
  parseConfig(nextConfig);
98
- const existingKey = parseEnv(credentials.text).DEEPSEEK_API_KEY;
99
150
  const providerSettings = parseProviderSettings(provider.text);
100
- const providerUrl = normalizeBaseUrl(baseUrl ?? providerSettings.baseUrl ?? DEFAULT_BASE_URL);
101
- const nextProvider = JSON.stringify({ ...providerSettings, baseUrl: providerUrl }, null, 2) + '\n';
102
- let nextCredentials = credentials.text;
103
- if (!existingKey) {
104
- const key = apiKey?.trim();
105
- if (!key || /[\r\n\0]/.test(key)) throw new Error('Provide one nonempty DeepSeek API key without line breaks.');
106
- nextCredentials += `${nextCredentials && !nextCredentials.endsWith('\n') ? '\n' : ''}DEEPSEEK_API_KEY=${JSON.stringify(key)}\n`;
107
- if (parseEnv(nextCredentials).DEEPSEEK_API_KEY !== key) throw new Error('The API key cannot be stored as a dotenv value.');
108
- }
151
+ const previousProvider = resolveProviderSettings(providerSettings, { legacy: !providerSettings.protocol && typeof providerSettings.baseUrl === 'string' });
152
+ const selected = resolveProviderSettings({
153
+ ...previousProvider,
154
+ ...(protocol === undefined ? {} : { protocol }),
155
+ ...(baseUrl === undefined ? {} : { baseUrl }),
156
+ ...(model === undefined ? {} : { model }),
157
+ });
158
+ const nextProvider = JSON.stringify({ ...providerSettings, ...selected }, null, 2) + '\n';
159
+ const nextCredentials = updateCredential(credentials.text, apiKey);
109
160
 
110
161
  // Check all user-file conflicts before creating credentials or Codex files.
111
162
  for (const directory of directories) await mkdir(directory, { recursive: true, mode: 0o700 });
@@ -115,78 +166,56 @@ export async function installCodex({
115
166
  if (nextProvider !== provider.text) await atomicWrite(providerPath, nextProvider, 0o600);
116
167
  else await chmod(providerPath, 0o600);
117
168
  if (nextConfig !== config.text) await atomicWrite(configPath, nextConfig, config.mode ?? 0o644);
118
- if (skill.mode === null) await atomicWrite(skillPath, bundledSkill, 0o644);
169
+ if (skill.text !== bundledSkill || skill.mode === null) await atomicWrite(skillPath, bundledSkill, skill.mode ?? 0o644);
119
170
  return { home, configPath, skillPath };
120
171
  }
121
172
 
122
- function question(prompt, hidden = false) {
123
- if (!process.stdin.isTTY) throw new Error('Use a terminal for prompts, or supply DEEPSEEK_API_KEY in the environment.');
124
- const output = hidden ? new Writable({ write(_chunk, _encoding, callback) { callback(); } }) : process.stdout;
125
- const input = createInterface({ input: process.stdin, output, terminal: true, historySize: 0 });
126
- if (hidden) process.stdout.write(prompt);
127
- return new Promise((accept, reject) => {
128
- input.once('SIGINT', () => { input.close(); reject(new Error('Setup cancelled.')); });
129
- input.once('close', () => reject(new Error('Setup input closed.')));
130
- input.question(hidden ? '' : prompt, (answer) => {
131
- accept(answer);
132
- input.close();
133
- if (hidden) process.stdout.write('\n');
134
- });
135
- });
136
- }
137
-
138
173
  export async function runSetup() {
139
174
  const { values } = parseArgs({ options: {
140
175
  home: { type: 'string', default: defaultHome },
141
- terminal: { type: 'boolean', default: false },
142
176
  help: { type: 'boolean', default: false },
143
177
  } });
144
178
  if (values.help) {
145
- console.log('Usage: dsh-mcp setup [--home /private/dsh-home] [--terminal]');
146
- console.log('Setup opens a local form for API Key and Base URL.');
147
- console.log('Use --terminal for terminal prompts. Secrets never enter command arguments.');
148
- console.log('Setup installs user-wide Codex MCP settings and a skill. Codex supplies each project path at call time.');
179
+ console.log('用法: dsh-mcp setup [--home /private/dsh-home]');
180
+ console.log('纯终端配置:协议 → Base URL → API Key → 探测并选择模型。');
181
+ console.log('再次运行可逐项修改现有配置;连接测试通过后才保存并注册 MCP。');
182
+ console.log('诊断 Codex 注册和工具握手: dsh-mcp doctor');
149
183
  return;
150
184
  }
185
+ if (!process.stdin.isTTY) throw new Error('配置向导需要交互式终端。请在终端运行 dsh-mcp setup。');
151
186
  const home = resolve(values.home);
152
187
  await checkDirectory(home);
153
188
  const credentials = await readExisting(join(home, '.env'));
154
- const provider = parseProviderSettings((await readExisting(join(home, 'settings.json'))).text);
155
- const stored = parseEnv(credentials.text);
156
- let apiKey = process.env.DEEPSEEK_API_KEY;
157
- let baseUrl = process.env.DEEPSEEK_BASE_URL ?? provider.baseUrl ?? DEFAULT_BASE_URL;
158
- let result;
159
- if (values.terminal) {
160
- if (!stored.DEEPSEEK_API_KEY && !apiKey) apiKey = await question('DeepSeek API key (hidden): ', true);
161
- if (process.stdin.isTTY && !process.env.DEEPSEEK_BASE_URL) {
162
- baseUrl = (await question(`Base URL [${baseUrl}]: `)).trim() || baseUrl;
163
- }
164
- result = await installCodex({ home, apiKey, baseUrl });
165
- } else {
166
- const form = await createSetupForm({
167
- baseUrl, hasSavedKey: Boolean(stored.DEEPSEEK_API_KEY || apiKey),
168
- onSubmit: (input) => installCodex({ home, apiKey: apiKey || input.apiKey, baseUrl: input.baseUrl }),
169
- });
170
- const cancel = () => form.close();
171
- process.once('SIGINT', cancel);
172
- process.once('SIGTERM', cancel);
173
- try {
174
- console.log(`Setup form: ${form.url}`);
175
- if (!await openSetupBrowser(form.url)) console.log('Open the local URL in your browser to continue.');
176
- result = await form.completed;
177
- } finally {
178
- process.removeListener('SIGINT', cancel);
179
- process.removeListener('SIGTERM', cancel);
180
- }
189
+ const saved = await readExisting(join(home, 'settings.json'));
190
+ const rawSettings = parseProviderSettings(saved.text);
191
+ const existing = saved.mode !== null || Boolean(parseEnv(credentials.text).DEEPSEEK_API_KEY);
192
+ const initial = existing ? rawSettings : {
193
+ ...(process.env.DSH_MCP_PROTOCOL ? { protocol: process.env.DSH_MCP_PROTOCOL } : {}),
194
+ ...(process.env.DEEPSEEK_BASE_URL ? { baseUrl: process.env.DEEPSEEK_BASE_URL } : {}),
195
+ ...(process.env.DSH_MCP_MODEL ? { model: process.env.DSH_MCP_MODEL } : {}),
196
+ };
197
+ const settings = resolveProviderSettings(initial, { legacy: existing && !rawSettings.protocol && typeof rawSettings.baseUrl === 'string' });
198
+ const apiKey = parseEnv(credentials.text).DEEPSEEK_API_KEY || process.env.DEEPSEEK_API_KEY || '';
199
+ const chosen = await runTerminalSetup({ settings, apiKey, existing });
200
+ const result = await installCodex({ home, ...chosen.settings, apiKey: chosen.apiKey });
201
+ console.log(`Codex 配置: ${result.configPath}`);
202
+ console.log(`用户 skill: ${result.skillPath}`);
203
+ console.log(`私有 API Key 文件: ${join(result.home, '.env')}`);
204
+ console.log(`私有服务配置: ${join(result.home, 'settings.json')}`);
205
+ console.log('正在检查 MCP 注册和工具握手...');
206
+ const health = await inspectRegistration({ configPath: result.configPath });
207
+ if (!health.ok) {
208
+ console.error('API 连接通过,配置也已保存,但 MCP 加载检查失败。');
209
+ for (const hint of health.hints ?? []) console.error(hint);
210
+ if (health.error) console.error(health.error);
211
+ throw new Error('请运行 dsh-mcp doctor 查看注册路径和启动状态;不能据此认为 Codex 已显示该工具。');
181
212
  }
182
- console.log(`MCP configuration: ${result.configPath}`);
183
- console.log(`Skill: ${result.skillPath}`);
184
- console.log(`Private API key file: ${join(result.home, '.env')}`);
185
- console.log(`Private provider settings: ${join(result.home, 'settings.json')}`);
186
- console.log('Start a new Codex session. Codex supplies the active project_root with every delegated task.');
187
- console.log('Use normal task prompts. Codex can select eligible DeepSeek tasks without an explicit tool request.');
213
+ console.log(`MCP 握手成功,工具: ${health.tools.join(', ')}`);
214
+ console.log('如果已打开的 Codex 仍未显示,请完整退出并重新打开 Codex Desktop,而不只是新开标签。');
215
+ console.log('确认 Codex 与安装器使用同一主机、用户和 CODEX_HOME。Auth unsupported 对本地 stdio 是正常状态。');
188
216
  }
189
217
 
218
+
190
219
  if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
191
220
  runSetup().catch((error) => { console.error(`Setup failed: ${error.message}`); process.exitCode = 1; });
192
221
  }
package/src/worker.js CHANGED
@@ -2,9 +2,12 @@ import { spawn, execFile } from 'node:child_process';
2
2
  import { promisify } from 'node:util';
3
3
  import { createHash } from 'node:crypto';
4
4
  import { createReadStream } from 'node:fs';
5
- import { lstat, readlink, realpath, stat } from 'node:fs/promises';
6
- import { isAbsolute, relative, resolve, sep } from 'node:path';
5
+ import { lstat, mkdtemp, readlink, realpath, rm, stat, writeFile } from 'node:fs/promises';
6
+ import { isAbsolute, join, relative, resolve, sep } from 'node:path';
7
+ import { tmpdir } from 'node:os';
7
8
  import { fileURLToPath } from 'node:url';
9
+ import { readSessionOutput } from './session-output.js';
10
+ import { buildProviderRuntime } from './provider.js';
8
11
 
9
12
  const exec = promisify(execFile);
10
13
  const dsh = fileURLToPath(new URL('./lib/bin.js', import.meta.resolve('@deepseek-ai/dsh/package.json')));
@@ -69,6 +72,7 @@ export function parseRun(stdout, exitCode) {
69
72
  let sessionId = null;
70
73
  let final = null;
71
74
  let error = null;
75
+ let turn = null;
72
76
  for (const line of stdout.split('\n')) {
73
77
  if (!line.trim()) continue;
74
78
  let event;
@@ -76,12 +80,13 @@ export function parseRun(stdout, exitCode) {
76
80
  catch { throw new Error('Invalid DSH NDJSON output; check the pinned Harness version'); }
77
81
  if (event?.type === 'session') sessionId = event.sessionId;
78
82
  if (event?.type === 'error') error = event;
83
+ if (event?.type === 'status' && event.phase === 'turn_start') turn = event.turn;
79
84
  if (event?.type === 'final') final = event;
80
85
  }
81
86
  return {
82
87
  status: exitCode === 0 && final?.reason?.kind === 'completed' ? 'completed' : 'failed',
83
88
  session_id: final?.sessionId ?? sessionId,
84
- answer: typeof final?.text === 'string' ? final.text : '',
89
+ turn,
85
90
  reason: final?.reason ?? error ?? null,
86
91
  };
87
92
  }
@@ -119,13 +124,18 @@ async function gitSnapshot(cwd) {
119
124
  return { root, files };
120
125
  }
121
126
 
122
- export function createWorker({ home, command = [process.execPath, dsh] }) {
127
+ export function createWorker({ home, command = [process.execPath, dsh], readSession = readSessionOutput, providerRuntime = buildProviderRuntime({}, process.env.DEEPSEEK_API_KEY) }) {
128
+ const allowedEfforts = new Set(providerRuntime.thinkingEfforts);
129
+ const secret = providerRuntime.env.DEEPSEEK_API_KEY;
130
+ const redact = (value) => typeof value === 'string' && secret ? value.replaceAll(secret, '[REDACTED]') : value;
131
+ const redactObject = (value) => JSON.parse(JSON.stringify(value, (_name, item) => redact(item)));
123
132
  let busy = false;
124
133
  return {
125
- async delegate({ project_root, task, cwd = '.', permission_mode = 'read-only', session_id, timeout_seconds = 300 }, { signal } = {}) {
134
+ async delegate({ project_root, task, thinking_effort, cwd = '.', permission_mode = 'read-only', session_id, timeout_seconds = 300 }, { signal } = {}) {
126
135
  if (busy) throw new Error('DeepSeek worker is busy; serialize tasks across all projects');
127
136
  busy = true;
128
137
  let before;
138
+ let pluginDirectory;
129
139
  try {
130
140
  if (typeof project_root !== 'string' || !isAbsolute(project_root)) {
131
141
  throw new Error('project_root must be an absolute directory path');
@@ -138,8 +148,23 @@ export function createWorker({ home, command = [process.execPath, dsh] }) {
138
148
  throw new Error('cwd is outside project_root');
139
149
  }
140
150
  if (!(await stat(directory)).isDirectory()) throw new Error('cwd must be a directory');
151
+ if (!allowedEfforts.has(thinking_effort)) {
152
+ throw new Error(`thinking_effort is required and must be one of: ${providerRuntime.thinkingEfforts.join(', ')} for the selected model`);
153
+ }
141
154
  before = await gitSnapshot(directory);
142
- const args = ['headless', '--patch', patch, '--json', '--permission-mode', permission_mode, '--approval-policy', 'never'];
155
+ const args = ['headless', '--patch', patch];
156
+ const plugin = providerRuntime.env.DSH_MCP_PI_PLUGIN;
157
+ if (plugin && plugin !== '@deepseek-ai/dsh-llm-pi-ai') {
158
+ pluginDirectory = await mkdtemp(join(tmpdir(), 'dsh-mcp-plugin-'));
159
+ const pluginPatch = join(pluginDirectory, 'provider.json');
160
+ // Cordis permits expressions in config, but plugin identities must be literal names.
161
+ await writeFile(pluginPatch, JSON.stringify([
162
+ { id: 'llm-pi-ai', disabled: true },
163
+ { insert: [{ id: 'dsh-mcp-off-only', name: plugin }] },
164
+ ]), { mode: 0o600 });
165
+ args.push('--patch', pluginPatch);
166
+ }
167
+ args.push('--json', '--permission-mode', permission_mode, '--approval-policy', 'never');
143
168
  if (session_id) args.push('--session-id', session_id);
144
169
  args.push('-');
145
170
  const prompt = [
@@ -154,12 +179,30 @@ export function createWorker({ home, command = [process.execPath, dsh] }) {
154
179
  ].join('\n');
155
180
  const result = await runProcess([...command, ...args], {
156
181
  cwd: directory,
157
- env: { ...process.env, DSH_HOME: home, DSH_PERMISSION_MODE: permission_mode },
182
+ env: { ...process.env, ...providerRuntime.env, DSH_HOME: home, DSH_PERMISSION_MODE: permission_mode, DSH_MCP_THINKING_EFFORT: thinking_effort },
158
183
  input: prompt, timeoutMs: timeout_seconds * 1000, signal,
159
184
  });
185
+ if (pluginDirectory) {
186
+ await rm(pluginDirectory, { recursive: true, force: true });
187
+ pluginDirectory = undefined;
188
+ }
160
189
  let run;
161
190
  try { run = parseRun(result.stdout, result.exit_code); }
162
- catch (error) { run = { status: 'failed', session_id: null, answer: '', reason: error.message }; }
191
+ catch (error) { run = { status: 'failed', session_id: null, turn: null, reason: error.message }; }
192
+ let output = null;
193
+ let sessionOutputError = null;
194
+ try {
195
+ if (!run.session_id) throw new Error('The run returned no session ID for its persisted output');
196
+ const persisted = await readSession({ home, sessionId: run.session_id, cwd: directory });
197
+ if (run.turn === null || persisted.turn !== run.turn) {
198
+ throw new Error('The persisted latest turn does not match this run; no current answer is available');
199
+ }
200
+ output = persisted;
201
+ if (run.status === 'completed' && output.terminal_reason?.kind !== 'completed') {
202
+ sessionOutputError = 'The session turn did not complete durably';
203
+ }
204
+ } catch (error) { sessionOutputError = error.message; }
205
+ if (sessionOutputError && run.status === 'completed') run.status = 'failed';
163
206
  let changedFiles = null;
164
207
  let changeWarning = before ? null : 'Not a Git repository; changed_files is unavailable.';
165
208
  try {
@@ -171,13 +214,24 @@ export function createWorker({ home, command = [process.execPath, dsh] }) {
171
214
  } catch (error) { changeWarning = `Cannot inspect changes: ${error.message}`; }
172
215
  return {
173
216
  ...run,
217
+ reason: run.reason == null ? run.reason : redactObject(run.reason),
174
218
  status: result.failure ?? run.status,
175
- project_root: root, cwd: directory, model: 'deepseek-flash', permission_mode,
219
+ answer: output?.text ?? '',
220
+ output_source: output ? 'session' : null,
221
+ session_file: output?.session_file ?? null,
222
+ assistant_seq: output?.assistant_seq ?? null,
223
+ terminal_reason: output?.terminal_reason == null ? null : redactObject(output.terminal_reason),
224
+ session_output_error: redact(sessionOutputError),
225
+ thinking_effort,
226
+ project_root: root, cwd: directory, model: providerRuntime.model, protocol: providerRuntime.protocol, permission_mode,
176
227
  exit_code: result.exit_code, signal: result.signal,
177
228
  changed_files: changedFiles, git_root: before?.root ?? null,
178
- change_warning: changeWarning, diagnostics: result.stderr,
229
+ change_warning: redact(changeWarning), diagnostics: redact(result.stderr),
179
230
  };
180
- } finally { busy = false; }
231
+ } finally {
232
+ try { if (pluginDirectory) await rm(pluginDirectory, { recursive: true, force: true }); }
233
+ finally { busy = false; }
234
+ }
181
235
  },
182
236
  };
183
237
  }
package/src/setup-ui.js DELETED
@@ -1,112 +0,0 @@
1
- import { createServer } from 'node:http';
2
- import { randomBytes } from 'node:crypto';
3
- import { spawn } from 'node:child_process';
4
-
5
- const escape = (value) => String(value).replace(/[&<>"']/g, (character) => ({
6
- '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
7
- })[character]);
8
-
9
- function page({ baseUrl, hasSavedKey, csrf, error = '', saved = false }) {
10
- return `<!doctype html>
11
- <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
12
- <title>DeepSeek MCP settings</title><style>
13
- body{font:16px/1.5 system-ui,sans-serif;background:#f4f6f8;color:#17212b;margin:0;padding:48px 20px}
14
- main{max-width:540px;margin:auto;background:white;padding:32px;border:1px solid #d9e0e7;border-radius:12px}
15
- h1{font-size:24px;margin-top:0}label{display:block;font-weight:600;margin-top:24px}
16
- input{box-sizing:border-box;width:100%;font:inherit;padding:10px;border:1px solid #9aa9b8;border-radius:6px;margin-top:6px}
17
- small{display:block;color:#4e6071;margin-top:6px}code{overflow-wrap:anywhere}button{font:inherit;background:#165bc4;color:white;border:0;padding:11px 18px;border-radius:6px;margin-top:28px;cursor:pointer}
18
- .error{color:#a21818}.scope{background:#f1f4f7;padding:12px;border-radius:6px}
19
- </style></head><body><main><h1>DeepSeek MCP settings</h1>
20
- ${saved ? '<p>Settings saved. You can close this tab.</p><p>Start a new Codex session in any project.</p>' : `
21
- <p>These settings apply to the local DeepSeek Harness worker.</p>
22
- <p class="scope">Codex supplies the project directory for each task. Setup does not bind this server to a project.</p>
23
- ${error ? `<p class="error" role="alert">${escape(error)}</p>` : ''}
24
- <form method="post" autocomplete="off">
25
- <input type="hidden" name="csrf" value="${csrf}">
26
- <label for="apiKey">API Key</label><input id="apiKey" name="apiKey" type="password" autocomplete="new-password" ${hasSavedKey ? 'disabled placeholder="An existing key will be used"' : 'required'}>
27
- <small>The key stays in your private settings file. Setup keeps an existing key unchanged.</small>
28
- <label for="baseUrl">Base URL (optional)</label><input id="baseUrl" name="baseUrl" type="url" value="${escape(baseUrl)}">
29
- <small>Use a Messages API root. Leave empty to use https://api.deepseek.com/anthropic.</small>
30
- <small>A custom endpoint receives your API key and task data.</small>
31
- <button type="submit">Save and configure Codex</button></form>`}
32
- </main></body></html>`;
33
- }
34
-
35
- export async function createSetupForm({ baseUrl, hasSavedKey = false, onSubmit }) {
36
- const path = `/${randomBytes(32).toString('hex')}`;
37
- const csrf = randomBytes(32).toString('hex');
38
- let origin;
39
- let submitting = false;
40
- let closing = false;
41
- let timer;
42
- let accept;
43
- let reject;
44
- const completed = new Promise((resolve, fail) => { accept = resolve; reject = fail; });
45
- const server = createServer(async (request, response) => {
46
- response.setHeader('Cache-Control', 'no-store');
47
- response.setHeader('Referrer-Policy', 'no-referrer');
48
- response.setHeader('X-Content-Type-Options', 'nosniff');
49
- response.setHeader('Content-Security-Policy', "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'");
50
- if (request.headers.host !== new URL(origin).host || request.url !== path) {
51
- response.writeHead(404).end('Not found.');
52
- return;
53
- }
54
- const render = (options, status = 200) => {
55
- response.writeHead(status, { 'Content-Type': 'text/html; charset=utf-8' });
56
- response.end(page({ baseUrl, hasSavedKey, csrf, ...options }));
57
- };
58
- if (request.method === 'GET') { render({}); return; }
59
- if (request.method !== 'POST') { response.writeHead(405).end('Use GET or POST.'); return; }
60
- if (request.headers.origin && ![origin, 'null'].includes(request.headers.origin)) {
61
- response.writeHead(403).end('Invalid origin.'); return;
62
- }
63
- if (request.headers['content-type']?.split(';')[0] !== 'application/x-www-form-urlencoded') {
64
- response.writeHead(415).end('Use the setup form.'); return;
65
- }
66
- if (submitting || closing) { response.writeHead(409).end('Setup is already saving.'); return; }
67
- try {
68
- const chunks = [];
69
- let bytes = 0;
70
- for await (const chunk of request) {
71
- bytes += chunk.length;
72
- if (bytes > 16384) { response.writeHead(413).end('Settings are too large.'); return; }
73
- chunks.push(chunk);
74
- }
75
- const fields = new URLSearchParams(Buffer.concat(chunks).toString('utf8'));
76
- // A separate form token also protects webviews with a null or missing Origin.
77
- if (fields.get('csrf') !== csrf) { response.writeHead(403).end('Invalid form token.'); return; }
78
- submitting = true;
79
- const result = await onSubmit({ apiKey: fields.get('apiKey')?.trim() || undefined, baseUrl: fields.get('baseUrl') });
80
- response.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
81
- response.end(page({ baseUrl, hasSavedKey, saved: true }), () => finish(null, result));
82
- } catch (error) {
83
- submitting = false;
84
- render({ error: error.message }, 400);
85
- }
86
- });
87
- server.requestTimeout = 30000;
88
- server.headersTimeout = 10000;
89
- await new Promise((resolve, fail) => {
90
- server.once('error', fail);
91
- server.listen(0, '127.0.0.1', resolve);
92
- });
93
- origin = `http://127.0.0.1:${server.address().port}`;
94
- function finish(error, result) {
95
- if (closing) return;
96
- closing = true;
97
- clearTimeout(timer);
98
- server.close(() => error ? reject(error) : accept(result));
99
- if (error) server.closeAllConnections();
100
- else server.closeIdleConnections();
101
- }
102
- timer = setTimeout(() => finish(new Error('Setup form expired. Run setup again.')), 600000);
103
- return { url: origin + path, completed, close: () => finish(new Error('Setup cancelled.')) };
104
- }
105
-
106
- export function openSetupBrowser(url) {
107
- return new Promise((resolve) => {
108
- const child = spawn(process.platform === 'darwin' ? 'open' : 'xdg-open', [url], { stdio: 'ignore', shell: false });
109
- child.once('error', () => resolve(false));
110
- child.once('exit', (code) => resolve(code === 0));
111
- });
112
- }