@openagents-org/agent-launcher 0.2.168 → 0.2.169

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.
@@ -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;