@openagents-org/agent-launcher 0.2.168 → 0.2.170
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/icons/deepseek.svg +1 -0
- package/package.json +2 -2
- package/registry.json +93 -2
- package/src/adapters/deepseek-runtime.js +634 -0
- package/src/adapters/deepseek.js +1190 -0
- package/src/adapters/index.js +4 -1
- package/src/adapters/utils.js +24 -4
- package/src/adapters/workspace-prompt.js +63 -0
- package/src/daemon.js +21 -6
- package/src/install-preflight.js +233 -0
- package/src/installer.js +199 -12
- package/src/node-shims.js +103 -0
|
@@ -0,0 +1,1190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DeepSeek Harness adapter for OpenAgents workspace.
|
|
3
|
+
*
|
|
4
|
+
* Bridges DeepSeek's open-source agent harness (https://github.com/deepseek-ai/
|
|
5
|
+
* deepseek-harness, npm `@deepseek-ai/dsh`) by driving its headless profile:
|
|
6
|
+
*
|
|
7
|
+
* dsh --profile headless --patch <private.yml> "<constant instruction>"
|
|
8
|
+
*
|
|
9
|
+
* Like every other agent here it drives its own CLI subprocess; no SDK is
|
|
10
|
+
* introduced. Mirrors mini.js's one-process-per-message control flow and
|
|
11
|
+
* aider.js's plain-text result handling.
|
|
12
|
+
*
|
|
13
|
+
* Four things are unlike the other adapters, and each has a reason:
|
|
14
|
+
*
|
|
15
|
+
* 1. THE PROMPT IS NEVER IN argv. dsh takes its task as a positional argument
|
|
16
|
+
* and has no stdin task channel, so putting the workspace prompt there
|
|
17
|
+
* would publish the workspace token to /proc/<pid>/cmdline and `ps`. The
|
|
18
|
+
* prompt is written to a private task file and argv carries only a constant
|
|
19
|
+
* sentence naming it (deepseek-runtime's HEADLESS_TASK_INSTRUCTION). If the
|
|
20
|
+
* task file cannot be written the run FAILS — it never falls back to argv,
|
|
21
|
+
* because a fallback would reintroduce exactly the exposure the design
|
|
22
|
+
* exists to prevent.
|
|
23
|
+
*
|
|
24
|
+
* 2. NO SESSION RESUME. headless creates one fresh persisted session per run
|
|
25
|
+
* and upstream documents "one submitted task only". Continuity therefore
|
|
26
|
+
* comes from a bounded workspace recap injected into the task file, not
|
|
27
|
+
* from anything dsh remembers. That also means the sessions directory grows
|
|
28
|
+
* once per message, so this adapter garbage-collects it.
|
|
29
|
+
*
|
|
30
|
+
* 3. NO STREAMING. headless prints the last assistant message at the end and
|
|
31
|
+
* nothing before it. A conventional idle timeout would therefore kill every
|
|
32
|
+
* healthy long task; the guard here is a TOTAL run timeout instead. For the
|
|
33
|
+
* same reason the child is awaited on 'close' rather than 'exit': the whole
|
|
34
|
+
* answer is stdout, and 'exit' can fire with the tail unflushed.
|
|
35
|
+
*
|
|
36
|
+
* 4. NON-INTERACTIVE APPROVAL WITHOUT UNSANDBOXING. dsh's composition sets
|
|
37
|
+
* approval `ask` everywhere except `danger-full-access`, and a headless run
|
|
38
|
+
* has no client to answer. The private patch sets approval `never` while
|
|
39
|
+
* leaving the sandbox at workspace-write, so tool use is non-interactive
|
|
40
|
+
* but writes stay confined. Reaching the same place via
|
|
41
|
+
* DSH_PERMISSION_MODE=danger-full-access would also remove the sandbox,
|
|
42
|
+
* which is why that value is never used to solve this.
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
'use strict';
|
|
46
|
+
|
|
47
|
+
const fs = require('fs');
|
|
48
|
+
const os = require('os');
|
|
49
|
+
const path = require('path');
|
|
50
|
+
const { execFileSync, spawn, execSync } = require('child_process');
|
|
51
|
+
|
|
52
|
+
const BaseAdapter = require('./base');
|
|
53
|
+
const { whichBinary, getEnhancedEnv } = require('../paths');
|
|
54
|
+
const { formatAttachmentsForPrompt } = require('./utils');
|
|
55
|
+
const { buildDeepSeekTaskFile } = require('./workspace-prompt');
|
|
56
|
+
const { sampleRecap } = require('./decision-log');
|
|
57
|
+
const {
|
|
58
|
+
REASON,
|
|
59
|
+
classifySpawnError,
|
|
60
|
+
redactDiagnostic,
|
|
61
|
+
} = require('./health-status');
|
|
62
|
+
const {
|
|
63
|
+
SUPPORTED_DSH_VERSION,
|
|
64
|
+
dshEntryCandidates,
|
|
65
|
+
buildHeadlessArgs,
|
|
66
|
+
buildDumpConfigArgs,
|
|
67
|
+
buildPrivatePatch,
|
|
68
|
+
classifyDshFailure,
|
|
69
|
+
classifyDshVersion,
|
|
70
|
+
classifyNodeVersion,
|
|
71
|
+
cleanStdout,
|
|
72
|
+
defaultInstallCommand,
|
|
73
|
+
nodeRequirementText,
|
|
74
|
+
resolvePermissionMode,
|
|
75
|
+
safeDshHomeName,
|
|
76
|
+
selectSessionsForGc,
|
|
77
|
+
MAX_STDOUT_BYTES,
|
|
78
|
+
MAX_STDERR_BYTES,
|
|
79
|
+
} = require('./deepseek-runtime');
|
|
80
|
+
|
|
81
|
+
const IS_WINDOWS = process.platform === 'win32';
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* TOTAL wall-clock budget for one run — NOT an idle timeout.
|
|
85
|
+
*
|
|
86
|
+
* dsh emits nothing on stdout until the task finishes, so the "no output for N
|
|
87
|
+
* minutes" guard every other adapter uses would terminate perfectly healthy
|
|
88
|
+
* long tasks. 60 minutes matches pi.js's long-task scale.
|
|
89
|
+
*/
|
|
90
|
+
const RUN_TIMEOUT_MS = 60 * 60 * 1000;
|
|
91
|
+
|
|
92
|
+
/** Grace period between SIGTERM and SIGKILL when stopping a run. */
|
|
93
|
+
const STOP_GRACE_MS = 5000;
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Budget for composing the headless profile.
|
|
97
|
+
*
|
|
98
|
+
* `--dump-config` calls no model and does no work beyond reading and merging
|
|
99
|
+
* the profile's own files, so it is fast or it is broken. The generous ceiling
|
|
100
|
+
* covers a cold first run that still has to materialise the profile from the
|
|
101
|
+
* shipped templates.
|
|
102
|
+
*/
|
|
103
|
+
const BOOTSTRAP_TIMEOUT_MS = 3 * 60 * 1000;
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* The key bootstrap's child is tracked under in `_channelProcesses`.
|
|
107
|
+
*
|
|
108
|
+
* The NUL prefix makes it unrepresentable as a real channel name, so a user
|
|
109
|
+
* `/stop` can reach the bootstrap process through the same registry the
|
|
110
|
+
* per-channel runs use, with no chance of colliding with one of them.
|
|
111
|
+
*/
|
|
112
|
+
const BOOTSTRAP_KEY = '\u0000bootstrap';
|
|
113
|
+
|
|
114
|
+
/** How many messages of channel history to pull for the recap. */
|
|
115
|
+
const RECAP_HEAD = 30;
|
|
116
|
+
const RECAP_TAIL = 60;
|
|
117
|
+
|
|
118
|
+
/** The environment variable the task file tells the agent to read. */
|
|
119
|
+
const TOKEN_ENV = 'OPENAGENTS_WORKSPACE_TOKEN';
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* The status that ends a run in the workspace UI.
|
|
123
|
+
*
|
|
124
|
+
* The client clears its "stopping" state only on a status matching
|
|
125
|
+
* /stopped|stopping failed/i (chat-view.tsx). Any other final status leaves the
|
|
126
|
+
* session marked as working forever — and a cancelled run produces nothing
|
|
127
|
+
* later that could correct it. The wording is therefore load-bearing, and the
|
|
128
|
+
* tests assert it against the client's own regex.
|
|
129
|
+
*/
|
|
130
|
+
const STOPPED_STATUS = 'Execution stopped by user';
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* The status for a stop that could NOT be confirmed.
|
|
134
|
+
*
|
|
135
|
+
* Contains "stopping failed" because that is the other half of the client's
|
|
136
|
+
* terminal-status regex. Reporting a clean stop when the child may still be
|
|
137
|
+
* editing files is worse than reporting nothing: it clears the UI and tells the
|
|
138
|
+
* user the work is over while it is not.
|
|
139
|
+
*/
|
|
140
|
+
const STOP_FAILED_STATUS =
|
|
141
|
+
'Stopping failed — the DeepSeek Harness process did not exit and may still be '
|
|
142
|
+
+ 'running. It may continue to change files in the working directory.';
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
/** nodeBin -> `node --version`, or null when it could not be read. */
|
|
146
|
+
const _nodeVersionCache = new Map();
|
|
147
|
+
|
|
148
|
+
function dshInstallHint() {
|
|
149
|
+
return defaultInstallCommand(SUPPORTED_DSH_VERSION);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
class DeepSeekAdapter extends BaseAdapter {
|
|
153
|
+
constructor(opts) {
|
|
154
|
+
super(opts);
|
|
155
|
+
this.disabledModules = opts.disabledModules || new Set();
|
|
156
|
+
|
|
157
|
+
// channel -> running child process (for stop / cleanup)
|
|
158
|
+
this._channelProcesses = {};
|
|
159
|
+
// channels the user explicitly stopped (suppress "no response" noise)
|
|
160
|
+
this._stoppingChannels = new Set();
|
|
161
|
+
|
|
162
|
+
// Channels currently inside _handleMessage, whether or not a child exists
|
|
163
|
+
// yet. A run spends real time before it spawns anything — bootstrap, the
|
|
164
|
+
// status post, the recap fetch, the browser-setting lookup — and a `/stop`
|
|
165
|
+
// arriving in that window has no process to kill. Without this set the stop
|
|
166
|
+
// would be silently dropped and the task would start anyway.
|
|
167
|
+
this._busyChannels = new Set();
|
|
168
|
+
// Channels cancelled while busy. Checked at every await boundary and once
|
|
169
|
+
// more immediately before spawning.
|
|
170
|
+
this._cancelledChannels = new Set();
|
|
171
|
+
// channel -> resolve() that releases a waiter parked on the SHARED
|
|
172
|
+
// bootstrap. Stopping one conversation must let it go without tearing down
|
|
173
|
+
// the compose that the other channels are still waiting on.
|
|
174
|
+
this._cancelWaiters = new Map();
|
|
175
|
+
this._taskCounter = 0;
|
|
176
|
+
this._bootstrapPromise = null;
|
|
177
|
+
// Overridable so the timeout paths are testable without 60-minute and
|
|
178
|
+
// 3-minute waits.
|
|
179
|
+
this._runTimeoutMs = opts.runTimeoutMs || RUN_TIMEOUT_MS;
|
|
180
|
+
this._bootstrapTimeoutMs = opts.bootstrapTimeoutMs || BOOTSTRAP_TIMEOUT_MS;
|
|
181
|
+
|
|
182
|
+
// A PRIVATE harness home per agent instance. The user's own ~/.dsh is never
|
|
183
|
+
// read or written: sharing it would leak their profiles, patches and saved
|
|
184
|
+
// credentials into an agent, and would make two OpenAgents agents share
|
|
185
|
+
// sessions and settings. The name carries a hash so two agents whose ids
|
|
186
|
+
// slug identically still get separate homes.
|
|
187
|
+
this._dshHome = path.join(
|
|
188
|
+
os.homedir(), '.openagents', 'dsh-homes',
|
|
189
|
+
safeDshHomeName(this.workspaceId, this.agentName),
|
|
190
|
+
);
|
|
191
|
+
this._patchFile = path.join(this._dshHome, 'openagents.patch.yml');
|
|
192
|
+
this._tasksDir = path.join(this._dshHome, 'tasks');
|
|
193
|
+
|
|
194
|
+
const resolved = this._resolveDshCommand();
|
|
195
|
+
this._nodeBin = resolved ? resolved[0] : null;
|
|
196
|
+
this._jsEntry = resolved ? resolved[1] : null;
|
|
197
|
+
if (this._jsEntry) {
|
|
198
|
+
this._log(`Using DeepSeek Harness: ${this._jsEntry} (node: ${this._nodeBin})`);
|
|
199
|
+
} else {
|
|
200
|
+
this._log(`Warning: DeepSeek Harness not found — install with: ${dshInstallHint()}`);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// ------------------------------------------------------------------
|
|
205
|
+
// Binary resolution
|
|
206
|
+
// ------------------------------------------------------------------
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Every Node this adapter could launch dsh with, best candidate first.
|
|
210
|
+
*
|
|
211
|
+
* Bare 'node' is never among them: a packaged daemon's PATH may not have one.
|
|
212
|
+
*/
|
|
213
|
+
_nodeCandidates() {
|
|
214
|
+
const home = os.homedir();
|
|
215
|
+
const managed = IS_WINDOWS
|
|
216
|
+
? [path.join(home, '.openagents', 'nodejs', 'node.exe')]
|
|
217
|
+
: [path.join(home, '.openagents', 'nodejs', 'node'),
|
|
218
|
+
path.join(home, '.openagents', 'nodejs', 'bin', 'node')];
|
|
219
|
+
const out = [];
|
|
220
|
+
for (const c of managed) {
|
|
221
|
+
try { if (fs.existsSync(c)) out.push(c); } catch { /* unreadable */ }
|
|
222
|
+
}
|
|
223
|
+
if (!out.includes(process.execPath)) out.push(process.execPath);
|
|
224
|
+
return out;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Pick a Node that can actually run dsh.
|
|
229
|
+
*
|
|
230
|
+
* Preferring the launcher's managed runtime is right — it is the one version
|
|
231
|
+
* the product controls — but preferring it *unconditionally* is not: a
|
|
232
|
+
* managed runtime older than dsh's floor shadows a perfectly good system
|
|
233
|
+
* Node, and the agent then refuses to start on a machine that could run it.
|
|
234
|
+
* Observed on Windows with a managed 22.14.0 next to a system 24.15.0.
|
|
235
|
+
*
|
|
236
|
+
* So: first candidate that satisfies the version gate wins. When none does,
|
|
237
|
+
* return the first that exists so preflight can name it in the error rather
|
|
238
|
+
* than reporting something vague.
|
|
239
|
+
*/
|
|
240
|
+
_findNodeBin() {
|
|
241
|
+
const candidates = this._nodeCandidates();
|
|
242
|
+
for (const c of candidates) {
|
|
243
|
+
if (classifyNodeVersion(this._readNodeVersionOf(c)).supported) return c;
|
|
244
|
+
}
|
|
245
|
+
return candidates[0];
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Resolve the `dsh` shim to [nodeBin, jsEntry] so the ESM entry point runs
|
|
250
|
+
* under a Node we control and whose version we have gated.
|
|
251
|
+
*
|
|
252
|
+
* dsh is `"type": "module"` with bin `lib/bin.js`. Running the shim instead
|
|
253
|
+
* would (a) use whatever Node is first on PATH, which may be a version dsh
|
|
254
|
+
* cannot run on, and (b) on Windows wrap a `.cmd` in cmd.exe. Returns null
|
|
255
|
+
* when nothing resolves.
|
|
256
|
+
*/
|
|
257
|
+
_resolveDshCommand() {
|
|
258
|
+
const bin = whichBinary('dsh');
|
|
259
|
+
if (!bin) return null;
|
|
260
|
+
const nodeBin = this._findNodeBin();
|
|
261
|
+
|
|
262
|
+
// A resolved path that is already the JS entry point (or a symlink to it).
|
|
263
|
+
try {
|
|
264
|
+
let target = bin;
|
|
265
|
+
if (!IS_WINDOWS && fs.lstatSync(bin).isSymbolicLink()) {
|
|
266
|
+
target = path.resolve(path.dirname(bin), fs.readlinkSync(bin));
|
|
267
|
+
}
|
|
268
|
+
if (/\.(js|mjs)$/i.test(target) && fs.existsSync(target)) return [nodeBin, target];
|
|
269
|
+
} catch { /* fall through to the package lookup */ }
|
|
270
|
+
|
|
271
|
+
// Otherwise locate lib/bin.js inside the installed package. The candidate
|
|
272
|
+
// layouts are a pure function so every install shape is unit-testable
|
|
273
|
+
// without a filesystem — see dshEntryCandidates.
|
|
274
|
+
const guesses = dshEntryCandidates(bin);
|
|
275
|
+
for (const g of guesses) if (fs.existsSync(g)) return [nodeBin, g];
|
|
276
|
+
return null;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** `dsh -V` output, or null. Synchronous: preflight() is a sync contract. */
|
|
280
|
+
_readDshVersion() {
|
|
281
|
+
if (!this._jsEntry) return null;
|
|
282
|
+
try {
|
|
283
|
+
return execFileSync(this._nodeBin, [this._jsEntry, '-V'], {
|
|
284
|
+
encoding: 'utf-8',
|
|
285
|
+
timeout: 15000,
|
|
286
|
+
env: getEnhancedEnv(this.agentEnv),
|
|
287
|
+
}).trim();
|
|
288
|
+
} catch {
|
|
289
|
+
return null;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* `node --version` for a specific binary, memoised.
|
|
295
|
+
*
|
|
296
|
+
* Node selection probes every candidate, and this runs on the constructor
|
|
297
|
+
* path, so the results are cached process-wide — a Node binary does not
|
|
298
|
+
* change version underneath us.
|
|
299
|
+
*/
|
|
300
|
+
_readNodeVersionOf(bin) {
|
|
301
|
+
if (!bin) return null;
|
|
302
|
+
if (bin === process.execPath) return process.version;
|
|
303
|
+
if (_nodeVersionCache.has(bin)) return _nodeVersionCache.get(bin);
|
|
304
|
+
let version = null;
|
|
305
|
+
try {
|
|
306
|
+
version = execFileSync(bin, ['--version'], {
|
|
307
|
+
encoding: 'utf-8',
|
|
308
|
+
timeout: 10000,
|
|
309
|
+
}).trim();
|
|
310
|
+
} catch { /* unreadable or not a Node */ }
|
|
311
|
+
_nodeVersionCache.set(bin, version);
|
|
312
|
+
return version;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** `node --version` for the Node we will actually launch dsh with. */
|
|
316
|
+
_readNodeVersion() {
|
|
317
|
+
return this._readNodeVersionOf(this._nodeBin);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// ------------------------------------------------------------------
|
|
321
|
+
// Preflight (run by the daemon BEFORE joining the workspace)
|
|
322
|
+
// ------------------------------------------------------------------
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Gate everything that can be known without starting a task. The daemon skips
|
|
326
|
+
* the workspace join when this fails, so an unusable runtime never spins a
|
|
327
|
+
* poll loop it can do nothing with.
|
|
328
|
+
*
|
|
329
|
+
* Deliberately ordered cheapest-first: a missing binary makes the version
|
|
330
|
+
* probes meaningless, and both version probes must pass before bootstrap is
|
|
331
|
+
* ever allowed to start a dsh process.
|
|
332
|
+
*/
|
|
333
|
+
preflight() {
|
|
334
|
+
if (!this._jsEntry) {
|
|
335
|
+
const resolved = this._resolveDshCommand();
|
|
336
|
+
if (resolved) { this._nodeBin = resolved[0]; this._jsEntry = resolved[1]; }
|
|
337
|
+
}
|
|
338
|
+
if (!this._jsEntry) {
|
|
339
|
+
return {
|
|
340
|
+
ok: false,
|
|
341
|
+
reason: REASON.RUNTIME_MISSING,
|
|
342
|
+
message: `DeepSeek Harness (dsh) not found — install with: ${dshInstallHint()}`,
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const node = classifyNodeVersion(this._readNodeVersion());
|
|
347
|
+
if (node.supported === false) {
|
|
348
|
+
// Name the binary: the usual cause is a stale managed runtime under
|
|
349
|
+
// ~/.openagents/nodejs shadowing a newer system Node, and without the
|
|
350
|
+
// path there is no way to tell which one to upgrade.
|
|
351
|
+
return {
|
|
352
|
+
ok: false,
|
|
353
|
+
reason: REASON.VERSION_INCOMPATIBLE,
|
|
354
|
+
message:
|
|
355
|
+
`DeepSeek Harness cannot run on Node ${node.version} `
|
|
356
|
+
+ `(${this._nodeBin}) — it requires ${nodeRequirementText()}. `
|
|
357
|
+
+ 'No compatible Node was found; upgrade the runtime at that path, '
|
|
358
|
+
+ 'or install a supported Node.',
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
const entry = this._registryEntry();
|
|
363
|
+
const supported = (entry && entry.install && entry.install.supported_version)
|
|
364
|
+
|| SUPPORTED_DSH_VERSION;
|
|
365
|
+
const installCmd = (entry && entry.install && entry.install[this._platformKey()])
|
|
366
|
+
|| defaultInstallCommand(supported);
|
|
367
|
+
const gate = classifyDshVersion(this._readDshVersion(), supported, installCmd);
|
|
368
|
+
if (gate.compatible === false || gate.compatible === null) {
|
|
369
|
+
return { ok: false, reason: REASON.VERSION_INCOMPATIBLE, message: gate.message };
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
if (!this._apiKey()) {
|
|
373
|
+
return {
|
|
374
|
+
ok: false,
|
|
375
|
+
reason: REASON.LOGIN_REQUIRED,
|
|
376
|
+
message:
|
|
377
|
+
'DeepSeek Harness is not configured — set DEEPSEEK_API_KEY. The agent '
|
|
378
|
+
+ 'runs with a private, empty harness home, so there is no saved dsh '
|
|
379
|
+
+ 'login to fall back on.',
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
this._log(`DeepSeek Harness ${gate.detected} resolved (node ${node.version})`);
|
|
384
|
+
return { ok: true };
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
_platformKey() {
|
|
388
|
+
if (IS_WINDOWS) return 'windows';
|
|
389
|
+
return process.platform === 'darwin' ? 'macos' : 'linux';
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/** The bundled registry entry for this agent type, or null. */
|
|
393
|
+
_registryEntry() {
|
|
394
|
+
try {
|
|
395
|
+
// eslint-disable-next-line global-require
|
|
396
|
+
const entries = require('../../registry.json');
|
|
397
|
+
return entries.find((e) => e && e.name === 'deepseek') || null;
|
|
398
|
+
} catch {
|
|
399
|
+
return null;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
_apiKey() {
|
|
404
|
+
return String(this.agentEnv.DEEPSEEK_API_KEY || this.agentEnv.LLM_API_KEY || '').trim();
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
_baseUrl() {
|
|
408
|
+
return String(this.agentEnv.DEEPSEEK_BASE_URL || this.agentEnv.LLM_BASE_URL || '').trim();
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
_model() {
|
|
412
|
+
return String(this.agentEnv.DEEPSEEK_MODEL || this.agentEnv.LLM_MODEL || '').trim();
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// ------------------------------------------------------------------
|
|
416
|
+
// Bootstrap + private patch
|
|
417
|
+
// ------------------------------------------------------------------
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* Compose the headless profile once, before any channel runs a task.
|
|
421
|
+
*
|
|
422
|
+
* A fresh DSH_HOME auto-initialises its headless profile from bundled
|
|
423
|
+
* templates on first use. BaseAdapter dispatches channels concurrently, so
|
|
424
|
+
* without this several dsh processes would race to initialise the same
|
|
425
|
+
* profile directory. `--dump-config` composes and prints the tree without
|
|
426
|
+
* booting it: no model call, no API key needed, no task run.
|
|
427
|
+
*
|
|
428
|
+
* The promise is CLEARED on failure. Caching a rejection would make one bad
|
|
429
|
+
* first run permanent for the lifetime of the daemon process.
|
|
430
|
+
*/
|
|
431
|
+
_ensureBootstrap() {
|
|
432
|
+
if (!this._bootstrapPromise) {
|
|
433
|
+
this._bootstrapPromise = this._bootstrap().catch((e) => {
|
|
434
|
+
this._bootstrapPromise = null;
|
|
435
|
+
throw e;
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
return this._bootstrapPromise;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* Wait for the SHARED bootstrap, but let THIS channel give up on its own.
|
|
443
|
+
*
|
|
444
|
+
* A scoped stop deliberately leaves the bootstrap child running, because
|
|
445
|
+
* every other channel is waiting on the same compose. Without a per-waiter
|
|
446
|
+
* escape the stopped channel stayed parked on that promise anyway — the user
|
|
447
|
+
* was told the run had stopped while the channel stayed busy, and the next
|
|
448
|
+
* message queued behind it for up to the full bootstrap timeout.
|
|
449
|
+
*
|
|
450
|
+
* Returns true when bootstrap completed, false when this channel was
|
|
451
|
+
* cancelled while waiting. Rethrows a genuine bootstrap failure.
|
|
452
|
+
*/
|
|
453
|
+
async _awaitBootstrap(channel) {
|
|
454
|
+
const shared = this._ensureBootstrap();
|
|
455
|
+
// This waiter may lose the race; without a handler its rejection would
|
|
456
|
+
// surface as an unhandled promise rejection.
|
|
457
|
+
shared.catch(() => {});
|
|
458
|
+
|
|
459
|
+
const cancelled = new Promise((resolve) => {
|
|
460
|
+
this._cancelWaiters.set(channel, resolve);
|
|
461
|
+
});
|
|
462
|
+
try {
|
|
463
|
+
return await Promise.race([
|
|
464
|
+
shared.then(() => true),
|
|
465
|
+
cancelled.then(() => false),
|
|
466
|
+
]);
|
|
467
|
+
} finally {
|
|
468
|
+
this._cancelWaiters.delete(channel);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
async _bootstrap() {
|
|
473
|
+
fs.mkdirSync(this._dshHome, { recursive: true, mode: 0o700 });
|
|
474
|
+
fs.mkdirSync(this._tasksDir, { recursive: true, mode: 0o700 });
|
|
475
|
+
try { fs.chmodSync(this._dshHome, 0o700); } catch { /* best effort (Windows) */ }
|
|
476
|
+
this._writePrivatePatch();
|
|
477
|
+
|
|
478
|
+
const args = buildDumpConfigArgs({ jsEntry: this._jsEntry, patchFile: this._patchFile });
|
|
479
|
+
await new Promise((resolve, reject) => {
|
|
480
|
+
const proc = spawn(this._nodeBin, args, {
|
|
481
|
+
stdio: ['ignore', 'ignore', 'pipe'],
|
|
482
|
+
env: this._buildSubprocessEnv('workspace-write'),
|
|
483
|
+
cwd: this.workingDir,
|
|
484
|
+
detached: !IS_WINDOWS,
|
|
485
|
+
windowsHide: true,
|
|
486
|
+
});
|
|
487
|
+
// Bootstrap runs BEFORE any per-run timeout exists and every waiting
|
|
488
|
+
// channel is blocked on it, so a wedged `--dump-config` would hang the
|
|
489
|
+
// whole agent with nothing able to interrupt it. Tracking it under a
|
|
490
|
+
// reserved key makes it reachable from /stop; the timeout is the backstop
|
|
491
|
+
// for a user who never presses it.
|
|
492
|
+
this._channelProcesses[BOOTSTRAP_KEY] = proc;
|
|
493
|
+
|
|
494
|
+
let err = '';
|
|
495
|
+
let settled = false;
|
|
496
|
+
let bootTimedOut = false;
|
|
497
|
+
const bootTimeoutError = () => new Error(
|
|
498
|
+
`dsh profile bootstrap timed out after ${this._bootstrapTimeoutMs / 1000}s. `
|
|
499
|
+
+ 'The harness composes its profile without calling a model, so this '
|
|
500
|
+
+ 'usually means the install is incomplete — reinstall with: '
|
|
501
|
+
+ dshInstallHint(),
|
|
502
|
+
);
|
|
503
|
+
const finish = (fn, value) => {
|
|
504
|
+
if (settled) return;
|
|
505
|
+
settled = true;
|
|
506
|
+
clearTimeout(timer);
|
|
507
|
+
delete this._channelProcesses[BOOTSTRAP_KEY];
|
|
508
|
+
fn(value);
|
|
509
|
+
};
|
|
510
|
+
const timer = setTimeout(async () => {
|
|
511
|
+
// Mark BEFORE killing: the kill produces a `close` with a null exit
|
|
512
|
+
// code, and without this flag that generic "exit null" would win the
|
|
513
|
+
// race and hide the actual reason from the user.
|
|
514
|
+
bootTimedOut = true;
|
|
515
|
+
this._log(`DeepSeek Harness profile bootstrap exceeded ${this._bootstrapTimeoutMs / 1000}s — terminating`);
|
|
516
|
+
await this._stopProcess(proc).catch(() => false);
|
|
517
|
+
finish(reject, bootTimeoutError());
|
|
518
|
+
}, this._bootstrapTimeoutMs);
|
|
519
|
+
|
|
520
|
+
proc.stderr.on('data', (d) => {
|
|
521
|
+
if (err.length < MAX_STDERR_BYTES) err += d.toString();
|
|
522
|
+
});
|
|
523
|
+
proc.stderr.on('error', () => {});
|
|
524
|
+
proc.on('error', (e) => finish(reject, bootTimedOut ? bootTimeoutError() : e));
|
|
525
|
+
proc.on('close', (code) => {
|
|
526
|
+
if (bootTimedOut) return finish(reject, bootTimeoutError());
|
|
527
|
+
if (code === 0) return finish(resolve, undefined);
|
|
528
|
+
finish(reject, new Error(
|
|
529
|
+
`dsh profile bootstrap failed (exit ${code}): ${redactDiagnostic(err)}`,
|
|
530
|
+
));
|
|
531
|
+
});
|
|
532
|
+
});
|
|
533
|
+
this._log(`DeepSeek Harness profile ready in ${this._dshHome}`);
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/**
|
|
537
|
+
* (Re)write the private `--patch` overlay.
|
|
538
|
+
*
|
|
539
|
+
* Rewritten on every run so a changed model setting takes effect without the
|
|
540
|
+
* user having to recreate the agent, and written atomically so a run that
|
|
541
|
+
* starts mid-write can never read a half-file.
|
|
542
|
+
*/
|
|
543
|
+
_writePrivatePatch() {
|
|
544
|
+
const { text } = buildPrivatePatch({ model: this._model() });
|
|
545
|
+
const tmp = `${this._patchFile}.${process.pid}.tmp`;
|
|
546
|
+
fs.writeFileSync(tmp, text, { encoding: 'utf-8', mode: 0o600 });
|
|
547
|
+
fs.renameSync(tmp, this._patchFile);
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
// ------------------------------------------------------------------
|
|
551
|
+
// Subprocess environment
|
|
552
|
+
// ------------------------------------------------------------------
|
|
553
|
+
|
|
554
|
+
/**
|
|
555
|
+
* The child environment is the ONLY channel for secrets: nothing below ever
|
|
556
|
+
* reaches argv, the task file or a log.
|
|
557
|
+
*
|
|
558
|
+
* Note that the daemon merges process.env into the agent environment, so this
|
|
559
|
+
* is "the effective child environment is authoritative", not "the Launcher is
|
|
560
|
+
* the only possible source". What matters for isolation is DSH_HOME: the
|
|
561
|
+
* agent never reads the user's shared harness credentials.
|
|
562
|
+
*/
|
|
563
|
+
_buildSubprocessEnv(permissionMode) {
|
|
564
|
+
const env = getEnhancedEnv(this.agentEnv);
|
|
565
|
+
if (env.NO_COLOR === undefined) env.NO_COLOR = '1';
|
|
566
|
+
|
|
567
|
+
env.DSH_HOME = this._dshHome;
|
|
568
|
+
env.DSH_PERMISSION_MODE = permissionMode;
|
|
569
|
+
// Telemetry uploads mirror session-log records with no redaction rule, so it
|
|
570
|
+
// is off unconditionally. dsh treats ANY non-empty value as opt-out.
|
|
571
|
+
env.DSH_TELEMETRY_DISABLED = '1';
|
|
572
|
+
|
|
573
|
+
const key = this._apiKey();
|
|
574
|
+
if (key) env.DEEPSEEK_API_KEY = key;
|
|
575
|
+
const base = this._baseUrl();
|
|
576
|
+
if (base) env.DEEPSEEK_BASE_URL = base;
|
|
577
|
+
|
|
578
|
+
// The task file references this by NAME; the value never appears in it.
|
|
579
|
+
env[TOKEN_ENV] = this.token;
|
|
580
|
+
return env;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/** The shell expression the task file uses to reference the token. */
|
|
584
|
+
_tokenExpr() {
|
|
585
|
+
return IS_WINDOWS ? `$env:${TOKEN_ENV}` : `$${TOKEN_ENV}`;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
// ------------------------------------------------------------------
|
|
589
|
+
// Control actions (stop)
|
|
590
|
+
// ------------------------------------------------------------------
|
|
591
|
+
|
|
592
|
+
async _onControlAction(action, payload) {
|
|
593
|
+
if (action === 'stop') {
|
|
594
|
+
// The workspace sends the conversation it wants stopped. Honouring it is
|
|
595
|
+
// what keeps one user's stop from tearing down every other concurrent
|
|
596
|
+
// run — claude.js already scopes its stop this way; mini/amp do not, and
|
|
597
|
+
// following them here was the mistake. A stop with no channel still
|
|
598
|
+
// means "everything".
|
|
599
|
+
const target = (payload && typeof payload === 'object' && payload.channel)
|
|
600
|
+
? payload.channel
|
|
601
|
+
: null;
|
|
602
|
+
await this._stopChannels(target);
|
|
603
|
+
return;
|
|
604
|
+
}
|
|
605
|
+
await super._onControlAction(action, payload);
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
/**
|
|
609
|
+
* Stop one channel, or every channel when `target` is null.
|
|
610
|
+
*
|
|
611
|
+
* Each affected channel is told exactly once what happened — and told the
|
|
612
|
+
* TRUTH: a kill that could not be confirmed reports a failed stop rather than
|
|
613
|
+
* a clean one, because the child may still be writing to the workspace.
|
|
614
|
+
*/
|
|
615
|
+
async _stopChannels(target) {
|
|
616
|
+
// channel -> was termination confirmed
|
|
617
|
+
const outcome = new Map();
|
|
618
|
+
|
|
619
|
+
const busy = target
|
|
620
|
+
? (this._busyChannels.has(target) ? [target] : [])
|
|
621
|
+
: [...this._busyChannels];
|
|
622
|
+
for (const channel of busy) {
|
|
623
|
+
this._cancelledChannels.add(channel);
|
|
624
|
+
this._stoppingChannels.add(channel);
|
|
625
|
+
// Release a waiter parked on the shared bootstrap. Marking alone is not
|
|
626
|
+
// enough: the flag is only read between awaits, and this one can last
|
|
627
|
+
// the whole bootstrap timeout.
|
|
628
|
+
const release = this._cancelWaiters.get(channel);
|
|
629
|
+
if (release) release();
|
|
630
|
+
// Nothing has spawned yet, so cancellation is complete by definition.
|
|
631
|
+
outcome.set(channel, true);
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
for (const [channel, proc] of Object.entries(this._channelProcesses)) {
|
|
635
|
+
if (channel === BOOTSTRAP_KEY) {
|
|
636
|
+
// Bootstrap is SHARED: every channel waits on the same profile
|
|
637
|
+
// compose. Tearing it down because one conversation was stopped would
|
|
638
|
+
// fail the others too, so a scoped stop leaves it alone.
|
|
639
|
+
if (target) continue;
|
|
640
|
+
} else if (target && channel !== target) {
|
|
641
|
+
continue;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
// Untrack only once the kill has been attempted, so the child is never
|
|
645
|
+
// missing from the registry while it may still be running.
|
|
646
|
+
const ended = await this._stopProcess(proc);
|
|
647
|
+
delete this._channelProcesses[channel];
|
|
648
|
+
|
|
649
|
+
if (channel === BOOTSTRAP_KEY) continue;
|
|
650
|
+
this._stoppingChannels.add(channel);
|
|
651
|
+
outcome.set(channel, ended);
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
// The client clears its stopping state only on a terminal status, and a
|
|
655
|
+
// cancelled run produces nothing later that could supply one.
|
|
656
|
+
for (const [channel, ended] of outcome) {
|
|
657
|
+
try {
|
|
658
|
+
await this.sendStatus(channel, ended ? STOPPED_STATUS : STOP_FAILED_STATUS);
|
|
659
|
+
} catch { /* the run is already over; a failed post must not throw here */ }
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
/** True when a child has ended, by exit code OR by signal. */
|
|
664
|
+
_hasExited(proc) {
|
|
665
|
+
return !!proc && (proc.exitCode !== null || proc.signalCode !== null);
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
/**
|
|
669
|
+
* Terminate a child, reporting whether its exit was observed.
|
|
670
|
+
*
|
|
671
|
+
* Best-effort, exactly like every other adapter here (mini, amp, aider,
|
|
672
|
+
* goose): SIGTERM the process group, escalate to SIGKILL after a grace
|
|
673
|
+
* period, give up after that. This adapter briefly carried extra machinery —
|
|
674
|
+
* process-group polling and a registry of children whose kill could not be
|
|
675
|
+
* confirmed — to retry failed kills and hold off session GC. It was removed:
|
|
676
|
+
* it caused several regressions of its own, and the two risks it covered are
|
|
677
|
+
* remote (a session in flight is the NEWEST by mtime, so the GC's
|
|
678
|
+
* keep-newest-50 and keep-7-days bounds already spare it, and re-sending
|
|
679
|
+
* SIGKILL to something SIGKILL did not end rarely helps).
|
|
680
|
+
*
|
|
681
|
+
* The boolean is still worth having: it decides whether the user is told the
|
|
682
|
+
* run stopped or that stopping FAILED. It reflects the leader — on POSIX a
|
|
683
|
+
* descendant of the group could in principle outlive it — so it is a claim
|
|
684
|
+
* about the process we started, not a proof that every grandchild is gone.
|
|
685
|
+
*/
|
|
686
|
+
async _stopProcess(proc) {
|
|
687
|
+
if (!proc) return true;
|
|
688
|
+
if (this._hasExited(proc)) return true;
|
|
689
|
+
try {
|
|
690
|
+
if (IS_WINDOWS) {
|
|
691
|
+
// /T takes the whole tree, so there is no separate group to signal.
|
|
692
|
+
try {
|
|
693
|
+
execSync(`taskkill /F /T /PID ${proc.pid}`, { timeout: 5000 });
|
|
694
|
+
return true;
|
|
695
|
+
} catch {
|
|
696
|
+
return this._hasExited(proc);
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
// POSIX: signal the whole process group (the child is detached) so the
|
|
700
|
+
// shell commands dsh spawned for its bash tool are reaped too.
|
|
701
|
+
try { process.kill(-proc.pid, 'SIGTERM'); } catch { proc.kill('SIGTERM'); }
|
|
702
|
+
return await new Promise((resolve) => {
|
|
703
|
+
const timer = setTimeout(() => {
|
|
704
|
+
try { process.kill(-proc.pid, 'SIGKILL'); } catch { proc.kill('SIGKILL'); }
|
|
705
|
+
setTimeout(() => resolve(this._hasExited(proc)), 250);
|
|
706
|
+
}, STOP_GRACE_MS);
|
|
707
|
+
proc.on('exit', () => { clearTimeout(timer); resolve(true); });
|
|
708
|
+
});
|
|
709
|
+
} catch {
|
|
710
|
+
return false;
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
/** True when an error is a child_process spawn failure (vs. a runtime error). */
|
|
715
|
+
_isSpawnError(e) {
|
|
716
|
+
if (!e) return false;
|
|
717
|
+
const code = e.code || e.errno;
|
|
718
|
+
return (
|
|
719
|
+
e.syscall === 'spawn'
|
|
720
|
+
|| String(e.syscall || '').startsWith('spawn ')
|
|
721
|
+
|| code === 'ENOENT'
|
|
722
|
+
|| code === 'EACCES'
|
|
723
|
+
|| code === 'EPERM'
|
|
724
|
+
);
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
// ------------------------------------------------------------------
|
|
728
|
+
// Channel recap
|
|
729
|
+
// ------------------------------------------------------------------
|
|
730
|
+
|
|
731
|
+
/**
|
|
732
|
+
* Bounded channel history for the task file.
|
|
733
|
+
*
|
|
734
|
+
* Two queries, matching claude.js: an ascending window (the opening messages
|
|
735
|
+
* — what the channel is *for*) and a descending one (the most recent
|
|
736
|
+
* messages — what just happened).
|
|
737
|
+
*
|
|
738
|
+
* BOTH arrive in chronological order. `getRecentMessages` reverses a `desc`
|
|
739
|
+
* window itself so every caller gets the same ordering, so reversing the tail
|
|
740
|
+
* again here would hand `sampleRecap` a newest-first list, and its
|
|
741
|
+
* `tail.slice(-tailKeep)` would then keep the OLDEST entries and discard the
|
|
742
|
+
* newest context — the exact opposite of the intent.
|
|
743
|
+
*
|
|
744
|
+
* The current message is excluded BY ID. `sampleRecap`'s own guard compares
|
|
745
|
+
* message TEXT, which silently drops an older message that happens to repeat
|
|
746
|
+
* the current one verbatim — common for short instructions like "continue".
|
|
747
|
+
* Filtering by id first makes that impossible; the text guard stays as a
|
|
748
|
+
* fallback for events that carry no id.
|
|
749
|
+
*/
|
|
750
|
+
async _buildRecap(channel, msg) {
|
|
751
|
+
try {
|
|
752
|
+
const [head, tail] = await Promise.all([
|
|
753
|
+
this.client.getRecentMessages(this.workspaceId, channel, this.token, RECAP_HEAD, { sort: 'asc' }),
|
|
754
|
+
this.client.getRecentMessages(this.workspaceId, channel, this.token, RECAP_TAIL),
|
|
755
|
+
]);
|
|
756
|
+
const currentId = msg && (msg.messageId || msg.eventId);
|
|
757
|
+
const drop = (list) => (list || []).filter(
|
|
758
|
+
(m) => !(currentId && (m.messageId === currentId || m.eventId === currentId)),
|
|
759
|
+
);
|
|
760
|
+
// Both windows are already chronological — see the note above.
|
|
761
|
+
const tailAsc = drop(tail);
|
|
762
|
+
// When the current event carries an id it has already been removed above,
|
|
763
|
+
// and passing its TEXT as well would make sampleRecap drop any older
|
|
764
|
+
// message that happens to repeat it verbatim. The text guard is only a
|
|
765
|
+
// fallback for events with no id.
|
|
766
|
+
// sampleRecap returns an ARRAY of formatted lines.
|
|
767
|
+
const lines = sampleRecap(
|
|
768
|
+
drop(head), tailAsc, currentId ? '' : ((msg && msg.content) || ''),
|
|
769
|
+
);
|
|
770
|
+
return (lines || []).join('\n');
|
|
771
|
+
} catch (e) {
|
|
772
|
+
this._log(`Could not build channel recap: ${redactDiagnostic(e && e.message)}`);
|
|
773
|
+
return '';
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
// ------------------------------------------------------------------
|
|
778
|
+
// Message handler
|
|
779
|
+
// ------------------------------------------------------------------
|
|
780
|
+
|
|
781
|
+
/**
|
|
782
|
+
* True when the user stopped this channel since the handler started.
|
|
783
|
+
*
|
|
784
|
+
* Called after every await in the pre-spawn window. Each of those awaits —
|
|
785
|
+
* bootstrap, the status post, the recap fetch, the browser lookup — can take
|
|
786
|
+
* long enough for a `/stop` to land, and before this existed the stop was
|
|
787
|
+
* dropped and the task ran to completion anyway.
|
|
788
|
+
*/
|
|
789
|
+
_cancelled(channel) {
|
|
790
|
+
return this._cancelledChannels.has(channel);
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
async _handleMessage(msg) {
|
|
794
|
+
const content = (msg.content || '').trim();
|
|
795
|
+
const attachments = msg.attachments || [];
|
|
796
|
+
if (!content && attachments.length === 0) return;
|
|
797
|
+
|
|
798
|
+
const msgChannel = msg.sessionId || this.channelName;
|
|
799
|
+
const sender = msg.senderName || msg.senderType || 'user';
|
|
800
|
+
this._log(`Processing message from ${sender} in ${msgChannel}: ${content.length} chars`);
|
|
801
|
+
|
|
802
|
+
// A stop belongs to the run that was in flight when it was pressed, so the
|
|
803
|
+
// flag is cleared here — before the first await — and never again.
|
|
804
|
+
this._cancelledChannels.delete(msgChannel);
|
|
805
|
+
this._busyChannels.add(msgChannel);
|
|
806
|
+
try {
|
|
807
|
+
await this._handleMessageInner(msg, msgChannel, content, attachments);
|
|
808
|
+
} finally {
|
|
809
|
+
this._busyChannels.delete(msgChannel);
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
async _handleMessageInner(msg, msgChannel, content, attachments) {
|
|
814
|
+
|
|
815
|
+
if (!this._jsEntry) {
|
|
816
|
+
const resolved = this._resolveDshCommand();
|
|
817
|
+
if (resolved) { this._nodeBin = resolved[0]; this._jsEntry = resolved[1]; }
|
|
818
|
+
}
|
|
819
|
+
if (!this._jsEntry) {
|
|
820
|
+
const message = `DeepSeek Harness (dsh) not found — install with: ${dshInstallHint()}`;
|
|
821
|
+
this._reportStatus(REASON.RUNTIME_MISSING, message);
|
|
822
|
+
await this.sendError(msgChannel, message);
|
|
823
|
+
return;
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
await this._autoTitleChannel(msgChannel, content);
|
|
827
|
+
this._stoppingChannels.delete(msgChannel);
|
|
828
|
+
if (this._cancelled(msgChannel)) return;
|
|
829
|
+
|
|
830
|
+
try {
|
|
831
|
+
// Returns false when this channel was stopped while waiting; the shared
|
|
832
|
+
// bootstrap keeps running for whoever else is queued behind it.
|
|
833
|
+
if (!await this._awaitBootstrap(msgChannel)) return;
|
|
834
|
+
} catch (e) {
|
|
835
|
+
if (this._cancelled(msgChannel)) return;
|
|
836
|
+
const message = `DeepSeek Harness could not initialise its profile: ${redactDiagnostic(e && e.message)}`;
|
|
837
|
+
this._reportStatus(REASON.SPAWN_FAILED, message);
|
|
838
|
+
await this.sendError(msgChannel, message);
|
|
839
|
+
return;
|
|
840
|
+
}
|
|
841
|
+
// Bootstrap is the longest pre-spawn wait — a cold first run composes the
|
|
842
|
+
// whole profile — so it is the likeliest place for a stop to land.
|
|
843
|
+
if (this._cancelled(msgChannel)) return;
|
|
844
|
+
|
|
845
|
+
// No streaming: this is the only progress the workspace will see until the
|
|
846
|
+
// run finishes.
|
|
847
|
+
await this.sendStatus(msgChannel, 'DeepSeek Harness is working...');
|
|
848
|
+
if (this._cancelled(msgChannel)) return;
|
|
849
|
+
|
|
850
|
+
let result;
|
|
851
|
+
try {
|
|
852
|
+
result = await this._runDsh({ content, attachments, msgChannel, msg });
|
|
853
|
+
} catch (e) {
|
|
854
|
+
if (this._isSpawnError(e)) {
|
|
855
|
+
const { reason, message } = classifySpawnError(e, {
|
|
856
|
+
label: 'DeepSeek Harness',
|
|
857
|
+
bin: this._jsEntry,
|
|
858
|
+
});
|
|
859
|
+
this._log(message);
|
|
860
|
+
this._reportStatus(reason, message);
|
|
861
|
+
await this.sendError(msgChannel, message);
|
|
862
|
+
} else {
|
|
863
|
+
const message = `Error processing message: ${redactDiagnostic(e && e.message)}`;
|
|
864
|
+
this._log(message);
|
|
865
|
+
await this.sendError(msgChannel, message);
|
|
866
|
+
}
|
|
867
|
+
return;
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
if (this._stoppingChannels.has(msgChannel)) {
|
|
871
|
+
this._stoppingChannels.delete(msgChannel);
|
|
872
|
+
return;
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
const { text, error } = result;
|
|
876
|
+
if (error) {
|
|
877
|
+
await this.sendError(msgChannel, error);
|
|
878
|
+
return;
|
|
879
|
+
}
|
|
880
|
+
this._reportStatus(null);
|
|
881
|
+
if (text) {
|
|
882
|
+
await this.sendResponse(msgChannel, text);
|
|
883
|
+
} else {
|
|
884
|
+
await this.sendResponse(
|
|
885
|
+
msgChannel,
|
|
886
|
+
'DeepSeek Harness finished with no textual output (any file changes were '
|
|
887
|
+
+ 'applied to the workspace directory).',
|
|
888
|
+
);
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
// ------------------------------------------------------------------
|
|
893
|
+
// Subprocess execution
|
|
894
|
+
// ------------------------------------------------------------------
|
|
895
|
+
|
|
896
|
+
async _runDsh({ content, attachments, msgChannel, msg }) {
|
|
897
|
+
const permissionMode = resolvePermissionMode({
|
|
898
|
+
workspaceMode: this._mode,
|
|
899
|
+
configured: this.agentEnv.DSH_PERMISSION_MODE,
|
|
900
|
+
});
|
|
901
|
+
if (this.agentEnv.DSH_PERMISSION_MODE
|
|
902
|
+
&& permissionMode !== this.agentEnv.DSH_PERMISSION_MODE
|
|
903
|
+
&& this._mode !== 'plan') {
|
|
904
|
+
// A typo in a permission setting must not silently downgrade to a
|
|
905
|
+
// default: the user asked for a specific boundary.
|
|
906
|
+
return {
|
|
907
|
+
text: '',
|
|
908
|
+
error:
|
|
909
|
+
`Invalid DSH_PERMISSION_MODE "${this.agentEnv.DSH_PERMISSION_MODE}". `
|
|
910
|
+
+ 'Valid values are read-only, workspace-write, danger-full-access.',
|
|
911
|
+
};
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
// Keep the patch current (model changes) before every run. A failure here
|
|
915
|
+
// is reported as what it is: an ENOENT from this write would otherwise
|
|
916
|
+
// reach the caller's spawn-error classifier and be announced as
|
|
917
|
+
// "executable not found", sending the user after a binary that is present.
|
|
918
|
+
try {
|
|
919
|
+
this._writePrivatePatch();
|
|
920
|
+
} catch (e) {
|
|
921
|
+
return {
|
|
922
|
+
text: '',
|
|
923
|
+
error:
|
|
924
|
+
'DeepSeek Harness could not write its private configuration '
|
|
925
|
+
+ `(${redactDiagnostic(e && e.message)}). The harness home is `
|
|
926
|
+
+ `${this._dshHome}.`,
|
|
927
|
+
};
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
const recap = await this._buildRecap(msgChannel, msg);
|
|
931
|
+
if (this._cancelled(msgChannel)) return { text: '', error: null };
|
|
932
|
+
|
|
933
|
+
const attachmentText = formatAttachmentsForPrompt(
|
|
934
|
+
attachments, 'skills', IS_WINDOWS,
|
|
935
|
+
{ tokenExpr: this._tokenExpr(), endpoint: this.endpoint },
|
|
936
|
+
) || '';
|
|
937
|
+
|
|
938
|
+
// `_browserEnabledCache` starts null and is populated lazily by this call.
|
|
939
|
+
// Reading the field directly (as claude.js does, where another code path
|
|
940
|
+
// has already warmed it) would leave the browser directive permanently off
|
|
941
|
+
// here, because nothing else in this adapter ever loads it.
|
|
942
|
+
let browserEnabled = false;
|
|
943
|
+
try { browserEnabled = await this.getBrowserEnabled(); } catch { /* default off */ }
|
|
944
|
+
if (this._cancelled(msgChannel)) return { text: '', error: null };
|
|
945
|
+
|
|
946
|
+
const taskText = buildDeepSeekTaskFile({
|
|
947
|
+
agentName: this.agentName,
|
|
948
|
+
workspaceId: this.workspaceId,
|
|
949
|
+
channelName: msgChannel,
|
|
950
|
+
endpoint: this.endpoint,
|
|
951
|
+
tokenExpr: this._tokenExpr(),
|
|
952
|
+
mode: this._mode,
|
|
953
|
+
disabledModules: this.disabledModules,
|
|
954
|
+
browserEnabled,
|
|
955
|
+
recap,
|
|
956
|
+
request: content,
|
|
957
|
+
attachments: attachmentText,
|
|
958
|
+
});
|
|
959
|
+
|
|
960
|
+
this._taskCounter += 1;
|
|
961
|
+
const taskFile = path.join(
|
|
962
|
+
this._tasksDir, `dsh-task-${process.pid}-${this._taskCounter}.md`,
|
|
963
|
+
);
|
|
964
|
+
|
|
965
|
+
try {
|
|
966
|
+
fs.mkdirSync(this._tasksDir, { recursive: true, mode: 0o700 });
|
|
967
|
+
// 0o600 is meaningful on POSIX only; on Windows the protection comes from
|
|
968
|
+
// the per-user profile directory's ACL, not from this bit.
|
|
969
|
+
fs.writeFileSync(taskFile, taskText, { encoding: 'utf-8', mode: 0o600 });
|
|
970
|
+
} catch (e) {
|
|
971
|
+
// FAIL CLOSED. The alternative — passing the prompt on the command line —
|
|
972
|
+
// would put the workspace token in the process list, which is the exact
|
|
973
|
+
// exposure the task file exists to avoid.
|
|
974
|
+
return {
|
|
975
|
+
text: '',
|
|
976
|
+
error:
|
|
977
|
+
'DeepSeek Harness could not stage its task file '
|
|
978
|
+
+ `(${redactDiagnostic(e && e.message)}). The run was not started: the `
|
|
979
|
+
+ 'prompt is never passed on the command line.',
|
|
980
|
+
};
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
try {
|
|
984
|
+
// Last gate before anything is started. Everything above this line is
|
|
985
|
+
// reversible; a process is not.
|
|
986
|
+
if (this._cancelled(msgChannel)) return { text: '', error: null };
|
|
987
|
+
return await this._spawnDsh({ taskFile, msgChannel, permissionMode });
|
|
988
|
+
} finally {
|
|
989
|
+
try { fs.rmSync(taskFile, { force: true }); } catch { /* best effort */ }
|
|
990
|
+
this._gcSessions();
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
_spawnDsh({ taskFile, msgChannel, permissionMode }) {
|
|
995
|
+
return new Promise((resolve, reject) => {
|
|
996
|
+
const args = buildHeadlessArgs({
|
|
997
|
+
jsEntry: this._jsEntry,
|
|
998
|
+
taskFile,
|
|
999
|
+
patchFile: this._patchFile,
|
|
1000
|
+
});
|
|
1001
|
+
|
|
1002
|
+
const proc = spawn(this._nodeBin, args, {
|
|
1003
|
+
// stdin is /dev/null: dsh has no stdin task channel, and a prompt that
|
|
1004
|
+
// ever waited on it would hang the run instead of failing.
|
|
1005
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
1006
|
+
env: this._buildSubprocessEnv(permissionMode),
|
|
1007
|
+
cwd: this.workingDir,
|
|
1008
|
+
detached: !IS_WINDOWS,
|
|
1009
|
+
windowsHide: true,
|
|
1010
|
+
});
|
|
1011
|
+
this._channelProcesses[msgChannel] = proc;
|
|
1012
|
+
|
|
1013
|
+
// Log the invocation WITHOUT the task: argv's task element is a constant
|
|
1014
|
+
// sentence, but the file it names holds the user's content.
|
|
1015
|
+
this._log(
|
|
1016
|
+
`Running DeepSeek Harness: --profile headless --patch <private> `
|
|
1017
|
+
+ `${path.basename(taskFile)} (${fs.statSync(taskFile).size} bytes, mode=${permissionMode})`,
|
|
1018
|
+
);
|
|
1019
|
+
|
|
1020
|
+
// Buffers, not strings: the caps below are BYTE budgets, and a string's
|
|
1021
|
+
// .length counts UTF-16 code units, which for multi-byte output
|
|
1022
|
+
// understates the memory actually held.
|
|
1023
|
+
const stdoutChunks = [];
|
|
1024
|
+
let stdoutBytes = 0;
|
|
1025
|
+
let stderrTail = Buffer.alloc(0);
|
|
1026
|
+
let overflow = false;
|
|
1027
|
+
let settled = false;
|
|
1028
|
+
let timedOut = false;
|
|
1029
|
+
let timer = null;
|
|
1030
|
+
let exitBackstop = null;
|
|
1031
|
+
|
|
1032
|
+
const timeoutMessage = (suffix) =>
|
|
1033
|
+
`DeepSeek Harness run timed out after ${this._runTimeoutMs / 60000} minutes `
|
|
1034
|
+
+ 'and was terminated. The harness does not report progress while a task '
|
|
1035
|
+
+ 'is running, so no partial result is available.' + (suffix || '');
|
|
1036
|
+
|
|
1037
|
+
// Four routes can end this run — timeout, stop, spawn error and close.
|
|
1038
|
+
// Exactly one of them may settle the promise.
|
|
1039
|
+
//
|
|
1040
|
+
// Untracking happens HERE and only here, and every route reaches it after
|
|
1041
|
+
// the child is known to be gone (or after the backstop below gives up on
|
|
1042
|
+
// it). Untracking while the child may still be alive would hand the run's
|
|
1043
|
+
// `finally` a green light to garbage-collect sessions under a live
|
|
1044
|
+
// process, and would drop the handle `/stop` needs to try again.
|
|
1045
|
+
const settle = (fn, value) => {
|
|
1046
|
+
if (settled) return;
|
|
1047
|
+
settled = true;
|
|
1048
|
+
if (timer) clearTimeout(timer);
|
|
1049
|
+
if (exitBackstop) clearTimeout(exitBackstop);
|
|
1050
|
+
delete this._channelProcesses[msgChannel];
|
|
1051
|
+
fn(value);
|
|
1052
|
+
};
|
|
1053
|
+
|
|
1054
|
+
proc.stdout.on('data', (d) => {
|
|
1055
|
+
const buf = Buffer.isBuffer(d) ? d : Buffer.from(d);
|
|
1056
|
+
if (stdoutBytes >= MAX_STDOUT_BYTES) { overflow = true; return; }
|
|
1057
|
+
// Trim the chunk to the remaining budget rather than testing before
|
|
1058
|
+
// appending: a single oversized chunk would otherwise sail past the cap.
|
|
1059
|
+
const room = MAX_STDOUT_BYTES - stdoutBytes;
|
|
1060
|
+
if (buf.length > room) {
|
|
1061
|
+
stdoutChunks.push(buf.subarray(0, room));
|
|
1062
|
+
stdoutBytes = MAX_STDOUT_BYTES;
|
|
1063
|
+
overflow = true;
|
|
1064
|
+
} else {
|
|
1065
|
+
stdoutChunks.push(buf);
|
|
1066
|
+
stdoutBytes += buf.length;
|
|
1067
|
+
}
|
|
1068
|
+
});
|
|
1069
|
+
proc.stderr.on('data', (d) => {
|
|
1070
|
+
const buf = Buffer.isBuffer(d) ? d : Buffer.from(d);
|
|
1071
|
+
// A rolling TAIL, so memory is bounded no matter how much a wedged CLI
|
|
1072
|
+
// writes, and the end — where dsh puts the terminal error — survives.
|
|
1073
|
+
stderrTail = Buffer.concat([stderrTail, buf]);
|
|
1074
|
+
if (stderrTail.length > MAX_STDERR_BYTES) {
|
|
1075
|
+
stderrTail = stderrTail.subarray(stderrTail.length - MAX_STDERR_BYTES);
|
|
1076
|
+
}
|
|
1077
|
+
});
|
|
1078
|
+
// A killed child can emit a benign EPIPE/ECONNRESET on its stdio a tick
|
|
1079
|
+
// later; without these listeners that becomes an uncaughtException.
|
|
1080
|
+
proc.stdout.on('error', () => {});
|
|
1081
|
+
proc.stderr.on('error', () => {});
|
|
1082
|
+
|
|
1083
|
+
proc.on('error', (e) => settle(reject, e));
|
|
1084
|
+
|
|
1085
|
+
timer = setTimeout(async () => {
|
|
1086
|
+
timedOut = true;
|
|
1087
|
+
this._log(`DeepSeek Harness exceeded ${this._runTimeoutMs / 60000}min — terminating`);
|
|
1088
|
+
// Terminate first and let 'close' settle, so the child stays tracked
|
|
1089
|
+
// (and therefore stoppable, and visible to the session GC guard) until
|
|
1090
|
+
// it is actually gone. `timedOut` is what stops the close handler from
|
|
1091
|
+
// reporting this as a generic "killed by SIGTERM" failure.
|
|
1092
|
+
await this._stopProcess(proc).catch(() => {});
|
|
1093
|
+
// If even SIGKILL did not produce a 'close', settle anyway rather than
|
|
1094
|
+
// wedging the channel forever — and say plainly that a process may have
|
|
1095
|
+
// survived, instead of implying a clean termination.
|
|
1096
|
+
exitBackstop = setTimeout(() => {
|
|
1097
|
+
this._log('DeepSeek Harness did not exit after SIGKILL — releasing the channel');
|
|
1098
|
+
settle(resolve, {
|
|
1099
|
+
text: '',
|
|
1100
|
+
error: timeoutMessage(
|
|
1101
|
+
' The process did not exit after being killed and may still be running.',
|
|
1102
|
+
),
|
|
1103
|
+
});
|
|
1104
|
+
}, STOP_GRACE_MS);
|
|
1105
|
+
}, this._runTimeoutMs);
|
|
1106
|
+
|
|
1107
|
+
// 'close' rather than 'exit': the entire answer is stdout, and 'exit' can
|
|
1108
|
+
// fire before the pipe has been fully drained.
|
|
1109
|
+
proc.on('close', (code, signal) => {
|
|
1110
|
+
const stdout = Buffer.concat(stdoutChunks).toString('utf-8');
|
|
1111
|
+
const stderr = stderrTail.toString('utf-8');
|
|
1112
|
+
if (timedOut) {
|
|
1113
|
+
settle(resolve, { text: '', error: timeoutMessage() });
|
|
1114
|
+
return;
|
|
1115
|
+
}
|
|
1116
|
+
if (this._stoppingChannels.has(msgChannel)) {
|
|
1117
|
+
settle(resolve, { text: '', error: null });
|
|
1118
|
+
return;
|
|
1119
|
+
}
|
|
1120
|
+
if (code === 0) {
|
|
1121
|
+
const { text, truncated } = cleanStdout(stdout);
|
|
1122
|
+
if (truncated || overflow) {
|
|
1123
|
+
this._log('DeepSeek Harness output exceeded the reply cap and was truncated');
|
|
1124
|
+
}
|
|
1125
|
+
settle(resolve, { text, error: null });
|
|
1126
|
+
return;
|
|
1127
|
+
}
|
|
1128
|
+
// A failed run's stdout is NOT an answer: dsh only guarantees the final
|
|
1129
|
+
// assistant text on success.
|
|
1130
|
+
const { category, message } = classifyDshFailure({ code, signal, stderr });
|
|
1131
|
+
this._log(`DeepSeek Harness failed (${category}, exit ${code})`);
|
|
1132
|
+
settle(resolve, {
|
|
1133
|
+
text: '',
|
|
1134
|
+
error: `DeepSeek Harness failed (${category}): ${redactDiagnostic(message, 2000)}`,
|
|
1135
|
+
});
|
|
1136
|
+
});
|
|
1137
|
+
});
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
// ------------------------------------------------------------------
|
|
1141
|
+
// Session garbage collection
|
|
1142
|
+
// ------------------------------------------------------------------
|
|
1143
|
+
|
|
1144
|
+
/**
|
|
1145
|
+
* Trim the private harness home's persisted sessions.
|
|
1146
|
+
*
|
|
1147
|
+
* dsh writes one session per run — `sessions/<workspace-slug>/session-<uuid>/`
|
|
1148
|
+
* holding a compressed jsonl (~20 KB) — and has no resume, so the directory
|
|
1149
|
+
* grows once per workspace message and nothing ever reclaims it.
|
|
1150
|
+
*
|
|
1151
|
+
* Only runs when this adapter has no live child: a session belonging to a run
|
|
1152
|
+
* still in flight is indistinguishable from a stale one by mtime alone.
|
|
1153
|
+
* Nothing outside `sessions/` is touched — profiles, settings.yaml and
|
|
1154
|
+
* .credentials.yaml are not ours to delete.
|
|
1155
|
+
*/
|
|
1156
|
+
_gcSessions() {
|
|
1157
|
+
if (Object.keys(this._channelProcesses).length > 0) return;
|
|
1158
|
+
const root = path.join(this._dshHome, 'sessions');
|
|
1159
|
+
try {
|
|
1160
|
+
if (!fs.existsSync(root)) return;
|
|
1161
|
+
for (const scope of fs.readdirSync(root)) {
|
|
1162
|
+
const scopeDir = path.join(root, scope);
|
|
1163
|
+
// Reject symlinks outright rather than following them.
|
|
1164
|
+
if (!fs.lstatSync(scopeDir).isDirectory()) continue;
|
|
1165
|
+
|
|
1166
|
+
const entries = [];
|
|
1167
|
+
for (const name of fs.readdirSync(scopeDir)) {
|
|
1168
|
+
const full = path.join(scopeDir, name);
|
|
1169
|
+
const st = fs.lstatSync(full);
|
|
1170
|
+
if (!st.isDirectory()) continue;
|
|
1171
|
+
entries.push({ name, mtimeMs: st.mtimeMs });
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
for (const name of selectSessionsForGc(entries)) {
|
|
1175
|
+
const victim = path.join(scopeDir, name);
|
|
1176
|
+
// Belt and braces: never delete anything whose real path escaped the
|
|
1177
|
+
// private sessions root.
|
|
1178
|
+
const real = fs.realpathSync(victim);
|
|
1179
|
+
if (!real.startsWith(fs.realpathSync(root) + path.sep)) continue;
|
|
1180
|
+
fs.rmSync(victim, { recursive: true, force: true });
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
} catch (e) {
|
|
1184
|
+
// GC is housekeeping: a failure must never affect the reply.
|
|
1185
|
+
this._log(`Session cleanup skipped: ${redactDiagnostic(e && e.message)}`);
|
|
1186
|
+
}
|
|
1187
|
+
}
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
module.exports = DeepSeekAdapter;
|