@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.
@@ -0,0 +1,634 @@
1
+ /**
2
+ * Pure helpers for the DeepSeek Harness (dsh) adapter.
3
+ *
4
+ * Everything here is side-effect free and unit-tested (test/deepseek-runtime.test.js)
5
+ * so the adapter itself stays a thin subprocess driver. The split mirrors
6
+ * cline-stream.js / pi-stream.js.
7
+ *
8
+ * Verified against @deepseek-ai/dsh 0.1.0-rc.6 (bin `dsh` -> lib/bin.js, ESM).
9
+ *
10
+ * Three things here exist because dsh is unlike every other CLI in this repo:
11
+ *
12
+ * 1. It is a PREVIEW release (`0.1.0-rc.6`) and upstream says compatibility may
13
+ * break between previews, so the gate is exact equality — not a floor. The
14
+ * two version comparators already in this repo (installer.js's
15
+ * `compareVersions`, pi-stream.js's `parseVersion`) both DROP the `-rc.N`
16
+ * suffix, which would make rc.5 and rc.6 indistinguishable. Hence the
17
+ * prerelease-aware parser below.
18
+ *
19
+ * 2. `dsh --profile headless "<task>"` takes the task as a POSITIONAL ARGUMENT
20
+ * and has no stdin task channel. Putting the workspace prompt there would
21
+ * publish the workspace token to /proc/<pid>/cmdline and `ps`. The adapter
22
+ * therefore writes the prompt to a private task file and passes a constant
23
+ * sentence in argv — see HEADLESS_TASK_INSTRUCTION.
24
+ *
25
+ * 3. dsh's own composition mounts an approval row whose policy is `ask`
26
+ * everywhere except `danger-full-access`. A headless run has no client that
27
+ * could answer, so the adapter ships a private patch that sets `approval:
28
+ * never` while KEEPING `sandbox: workspace-write` — non-interactive
29
+ * execution without widening the filesystem boundary. Reaching the same
30
+ * place via DSH_PERMISSION_MODE=danger-full-access would also unsandbox the
31
+ * agent, which is why that value is never used to solve this.
32
+ */
33
+
34
+ 'use strict';
35
+
36
+ const crypto = require('crypto');
37
+ const path = require('path');
38
+
39
+ /**
40
+ * The dsh release this adapter is written against.
41
+ *
42
+ * FALLBACK ONLY. The authoritative version lives in the registry
43
+ * (`install.supported_version` in deepseek.yaml / registry.json) so the catalog,
44
+ * the installer gate and the runtime gate cannot drift; the adapter reads it
45
+ * from there and only falls back here when the registry entry cannot be read at
46
+ * all. Mirrors pi-stream.js's MIN_PI_VERSION rationale.
47
+ */
48
+ const SUPPORTED_DSH_VERSION = '0.1.0-rc.6';
49
+
50
+ /**
51
+ * dsh's Node requirement, from the upstream repository's root package.json:
52
+ *
53
+ * "engines": { "node": "^22.19.0 || >=24.0.0" }
54
+ *
55
+ * None of the PUBLISHED packages (@deepseek-ai/dsh, dsh-base, dsh-headless,
56
+ * cordis) carry an `engines` field, so npm will happily install dsh onto Node 20
57
+ * and it then fails at runtime with an unrelated-looking error. The gate has to
58
+ * live here.
59
+ *
60
+ * Note the caret: `^22.19.0` does NOT include 23.x. Pi's gate is effectively
61
+ * ">= 22.19.0" and would wrongly accept Node 23, so this cannot reuse it.
62
+ */
63
+ const NODE_MIN_22 = [22, 19, 0];
64
+
65
+ /** Row ids in dsh's own composition that the private patch addresses. */
66
+ const ROW = {
67
+ AGENT_DEFAULT_MODEL: 'agent-default-model',
68
+ APPROVAL: 'approval',
69
+ PERMISSION: 'permission',
70
+ USER_QUESTIONS: 'user-questions',
71
+ };
72
+
73
+ /** Permission modes dsh's `permission-presets` row declares. */
74
+ const PERMISSION_MODES = ['read-only', 'workspace-write', 'danger-full-access'];
75
+
76
+ /**
77
+ * The ONLY thing that ever reaches argv as the headless task.
78
+ *
79
+ * The real prompt (workspace rules, bounded recap, the user's request) lives in
80
+ * the task file. Keeping this a constant is what makes "no user content and no
81
+ * secret is ever visible in the process list" an assertable property rather
82
+ * than a length-dependent hope.
83
+ */
84
+ const HEADLESS_TASK_INSTRUCTION =
85
+ 'Read the task file at %s and complete the task described in it. ' +
86
+ 'Do not ask the user any questions.';
87
+
88
+ // ---------------------------------------------------------------------------
89
+ // Version parsing (prerelease-aware)
90
+ // ---------------------------------------------------------------------------
91
+
92
+ /**
93
+ * Extract a semver-ish version, PRESERVING any prerelease suffix, from raw CLI
94
+ * output such as `dsh 0.1.0-rc.6` or a bare `0.1.0-rc.6`.
95
+ *
96
+ * Returns null when nothing version-shaped is present. Build metadata (`+sha`)
97
+ * is dropped: semver says it does not participate in precedence.
98
+ */
99
+ function parseDshVersion(raw) {
100
+ if (raw == null) return null;
101
+ const m = String(raw).match(/(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?/);
102
+ if (!m) return null;
103
+ const core = `${m[1]}.${m[2]}.${m[3]}`;
104
+ return m[4] ? `${core}-${m[4]}` : core;
105
+ }
106
+
107
+ /** Split a parsed version into { core: number[], pre: string[]|null }. */
108
+ function splitVersion(v) {
109
+ const parsed = parseDshVersion(v);
110
+ if (!parsed) return null;
111
+ const [core, pre] = parsed.split('-');
112
+ return {
113
+ core: core.split('.').map((n) => parseInt(n, 10)),
114
+ pre: pre ? pre.split('.') : null,
115
+ };
116
+ }
117
+
118
+ /** Compare two prerelease identifiers per semver rules. */
119
+ function comparePreIdentifier(a, b) {
120
+ const na = /^\d+$/.test(a);
121
+ const nb = /^\d+$/.test(b);
122
+ if (na && nb) {
123
+ const d = parseInt(a, 10) - parseInt(b, 10);
124
+ return d === 0 ? 0 : d > 0 ? 1 : -1;
125
+ }
126
+ // Numeric identifiers always have lower precedence than alphanumeric ones.
127
+ if (na) return -1;
128
+ if (nb) return 1;
129
+ if (a === b) return 0;
130
+ return a > b ? 1 : -1;
131
+ }
132
+
133
+ /**
134
+ * -1 / 0 / 1 with FULL semver precedence, including prerelease ordering:
135
+ *
136
+ * 0.1.0-rc.5 < 0.1.0-rc.6 < 0.1.0
137
+ *
138
+ * Returns null when either side cannot be parsed — callers must treat that as
139
+ * "unknown" rather than silently passing a gate.
140
+ *
141
+ * Deliberately NOT shared with installer.js's `compareVersions` or
142
+ * pi-stream.js's `parseVersion`: both of those match only `\d+(\.\d+)*` and so
143
+ * collapse every 0.1.0-rc.N to 0.1.0. Those two are load-bearing for other
144
+ * agents and are left alone; this is the dsh-specific replacement.
145
+ */
146
+ function compareDshVersions(a, b) {
147
+ const pa = splitVersion(a);
148
+ const pb = splitVersion(b);
149
+ if (!pa || !pb) return null;
150
+
151
+ for (let i = 0; i < 3; i++) {
152
+ const x = pa.core[i] || 0;
153
+ const y = pb.core[i] || 0;
154
+ if (x !== y) return x > y ? 1 : -1;
155
+ }
156
+
157
+ // A version WITH a prerelease has lower precedence than one without.
158
+ if (!pa.pre && !pb.pre) return 0;
159
+ if (!pa.pre) return 1;
160
+ if (!pb.pre) return -1;
161
+
162
+ const len = Math.max(pa.pre.length, pb.pre.length);
163
+ for (let i = 0; i < len; i++) {
164
+ const x = pa.pre[i];
165
+ const y = pb.pre[i];
166
+ if (x === undefined) return -1; // shorter prerelease sorts first
167
+ if (y === undefined) return 1;
168
+ const c = comparePreIdentifier(x, y);
169
+ if (c !== 0) return c;
170
+ }
171
+ return 0;
172
+ }
173
+
174
+ /**
175
+ * Preview-era compatibility gate: the installed dsh must be EXACTLY the version
176
+ * this adapter was written against.
177
+ *
178
+ * Returns { compatible: true|false|null, detected, supported, message }.
179
+ * `compatible: null` means the version could not be read — reported as unknown,
180
+ * never as a pass.
181
+ *
182
+ * `installCommand` is echoed into the failure message because the recovery is
183
+ * not obvious: the Launcher's update path would otherwise move the user to a
184
+ * newer preview that this adapter refuses to run.
185
+ */
186
+ function classifyDshVersion(rawVersion, supported, installCommand) {
187
+ const want = parseDshVersion(supported || SUPPORTED_DSH_VERSION);
188
+ const detected = parseDshVersion(rawVersion);
189
+ if (!detected) {
190
+ return {
191
+ compatible: null,
192
+ detected: null,
193
+ supported: want,
194
+ message:
195
+ `Could not read the DeepSeek Harness version (expected ${want}). ` +
196
+ 'Reinstall with: ' + (installCommand || defaultInstallCommand(want)),
197
+ };
198
+ }
199
+ if (compareDshVersions(detected, want) === 0) {
200
+ return { compatible: true, detected, supported: want, message: null };
201
+ }
202
+ return {
203
+ compatible: false,
204
+ detected,
205
+ supported: want,
206
+ message:
207
+ `DeepSeek Harness ${detected} is not supported — this agent is built ` +
208
+ `against ${want} exactly. DeepSeek ships the harness as a developer ` +
209
+ 'preview and its configuration can change between previews, so the ' +
210
+ 'version is pinned. Reinstall with: ' +
211
+ (installCommand || defaultInstallCommand(want)),
212
+ };
213
+ }
214
+
215
+ /** The npm command that restores the pinned preview. */
216
+ function defaultInstallCommand(version) {
217
+ return `npm install -g @deepseek-ai/dsh@${version || SUPPORTED_DSH_VERSION}`;
218
+ }
219
+
220
+ // ---------------------------------------------------------------------------
221
+ // Node version gate
222
+ // ---------------------------------------------------------------------------
223
+
224
+ /**
225
+ * Classify a `node --version` string against dsh's `^22.19.0 || >=24.0.0`.
226
+ *
227
+ * Returns { version, supported }. `version` is null when unparseable, in which
228
+ * case `supported` is null ("unknown") rather than false.
229
+ *
230
+ * The 23.x rejection is the whole reason this is not shared with Pi: Pi's gate
231
+ * is a plain floor and would accept 23.x, which dsh's caret range excludes.
232
+ */
233
+ function classifyNodeVersion(raw) {
234
+ const parsed = parseDshVersion(raw);
235
+ if (!parsed) return { version: null, supported: null };
236
+ const [major, minor, patch] = parsed.split('-')[0].split('.').map((n) => parseInt(n, 10));
237
+
238
+ if (major >= 24) return { version: parsed, supported: true };
239
+ if (major === 23) return { version: parsed, supported: false };
240
+ if (major === 22) {
241
+ const ok =
242
+ minor > NODE_MIN_22[1] ||
243
+ (minor === NODE_MIN_22[1] && patch >= NODE_MIN_22[2]);
244
+ return { version: parsed, supported: ok };
245
+ }
246
+ return { version: parsed, supported: false };
247
+ }
248
+
249
+ /** Human-readable requirement, used in the incompatibility message. */
250
+ function nodeRequirementText() {
251
+ return `Node ${NODE_MIN_22.join('.')}+ (22.x) or Node 24+; Node 23.x is not supported`;
252
+ }
253
+
254
+ // ---------------------------------------------------------------------------
255
+ // Private DSH_HOME naming
256
+ // ---------------------------------------------------------------------------
257
+
258
+ /**
259
+ * A filesystem-safe, COLLISION-FREE directory name for one agent's private
260
+ * DSH_HOME.
261
+ *
262
+ * Slugging alone is not enough: `my/agent` and `my:agent` both slug to
263
+ * `my-agent`, and two agents sharing a DSH_HOME would share sessions, settings
264
+ * and credentials. The 8-hex digest of the raw pair keeps the name readable
265
+ * while making a collision require an actual SHA-256 prefix collision.
266
+ */
267
+ function safeDshHomeName(workspaceId, agentName) {
268
+ const raw = `${workspaceId == null ? '' : workspaceId}\u0000${agentName == null ? '' : agentName}`;
269
+ const hash = crypto.createHash('sha256').update(raw).digest('hex').slice(0, 8);
270
+ const slug = (s) =>
271
+ String(s == null ? '' : s)
272
+ .toLowerCase()
273
+ .replace(/[^a-z0-9]+/g, '-')
274
+ .replace(/^-+|-+$/g, '')
275
+ .slice(0, 32) || 'agent';
276
+ return `${slug(workspaceId)}_${slug(agentName)}-${hash}`;
277
+ }
278
+
279
+ // ---------------------------------------------------------------------------
280
+ // Entry-point resolution
281
+ // ---------------------------------------------------------------------------
282
+
283
+ /**
284
+ * Candidate paths to dsh's real JS entry point, given the resolved `dsh` shim.
285
+ *
286
+ * Pure so every install layout can be asserted without a filesystem — the
287
+ * caller picks the first that exists. Order is by likelihood, not preference;
288
+ * only one of these can exist for a given install.
289
+ *
290
+ * <prefix>/node_modules/.bin/dsh(.cmd) → ../@deepseek-ai/dsh/lib/bin.js
291
+ * The Launcher's MANAGED runtime (~/.openagents/runtimes/deepseek). This
292
+ * one is load-bearing on Windows, where the shim is a `.cmd` with no
293
+ * symlink to follow, making this the ONLY route to the entry point.
294
+ * <prefix>/bin/dsh → ../lib/node_modules/… (unix global)
295
+ * <prefix>/dsh.cmd → ./node_modules/… (windows global)
296
+ *
297
+ * @param {string} binPath the resolved shim
298
+ * @param {object} [p] path implementation, so win32 layouts can be tested from
299
+ * any host platform
300
+ */
301
+ function dshEntryCandidates(binPath, p = path) {
302
+ if (!binPath) return [];
303
+ const dir = p.dirname(binPath);
304
+ const rel = ['@deepseek-ai', 'dsh', 'lib', 'bin.js'];
305
+ return [
306
+ p.resolve(dir, '..', ...rel),
307
+ p.resolve(dir, '..', 'lib', 'node_modules', ...rel),
308
+ p.resolve(dir, '..', 'node_modules', ...rel),
309
+ p.resolve(dir, 'node_modules', ...rel),
310
+ ];
311
+ }
312
+
313
+ // ---------------------------------------------------------------------------
314
+ // argv construction
315
+ // ---------------------------------------------------------------------------
316
+
317
+ /**
318
+ * Build the argument vector that follows the Node binary.
319
+ *
320
+ * Order is load-bearing: dsh's launcher parses only its own flags and hands
321
+ * everything after the first token it does not recognise to the booted profile.
322
+ * Launcher flags therefore MUST precede the positional task.
323
+ *
324
+ * The task argument is a constant sentence naming the task file — never the
325
+ * prompt itself. See HEADLESS_TASK_INSTRUCTION.
326
+ */
327
+ function buildHeadlessArgs({ jsEntry, taskFile, patchFile }) {
328
+ if (!jsEntry) throw new Error('buildHeadlessArgs: jsEntry is required');
329
+ if (!taskFile) throw new Error('buildHeadlessArgs: taskFile is required');
330
+ const args = [jsEntry, '--profile', 'headless'];
331
+ if (patchFile) args.push('--patch', patchFile);
332
+ args.push(HEADLESS_TASK_INSTRUCTION.replace('%s', taskFile));
333
+ return args;
334
+ }
335
+
336
+ /** The bootstrap probe: composes the profile without booting or calling a model. */
337
+ function buildDumpConfigArgs({ jsEntry, patchFile }) {
338
+ if (!jsEntry) throw new Error('buildDumpConfigArgs: jsEntry is required');
339
+ const args = [jsEntry, '--profile', 'headless'];
340
+ if (patchFile) args.push('--patch', patchFile);
341
+ args.push('--dump-config');
342
+ return args;
343
+ }
344
+
345
+ // ---------------------------------------------------------------------------
346
+ // Private patch file
347
+ // ---------------------------------------------------------------------------
348
+
349
+ /**
350
+ * Emit a YAML scalar safely.
351
+ *
352
+ * Always quotes and always escapes. The model id is user-supplied through the
353
+ * Launcher, and an unquoted `foo: bar` or a stray newline there would rewrite
354
+ * the patch's structure — a config-injection hole. This is stricter than
355
+ * config.js's serializeYamlValue (which quotes only when it detects a special
356
+ * character) precisely because the input is untrusted.
357
+ */
358
+ function yamlScalar(value) {
359
+ const s = String(value == null ? '' : value);
360
+ const escaped = s
361
+ .replace(/\\/g, '\\\\')
362
+ .replace(/"/g, '\\"')
363
+ .replace(/\r/g, '\\r')
364
+ .replace(/\n/g, '\\n')
365
+ .replace(/\t/g, '\\t');
366
+ return `"${escaped}"`;
367
+ }
368
+
369
+ /**
370
+ * The private `--patch` overlay this adapter ships with every dsh run.
371
+ *
372
+ * It carries three things, all of which are configuration dsh has no
373
+ * environment variable for:
374
+ *
375
+ * 1. `user-questions` disabled — a headless run has no surface on which the
376
+ * agent could ask, so leaving it mounted only creates a way to hang.
377
+ * 2. `approval` forced to `never`, and the presets rewritten to match, WITHOUT
378
+ * touching `sandbox`. dsh's own preset table pairs `approval: never` only
379
+ * with `sandbox: danger-full-access`; keeping `workspace-write` alongside
380
+ * `never` is what gives non-interactive execution that is still confined to
381
+ * the workspace for writes. (Reads are not confined in any mode.)
382
+ * 3. The model/provider override, since `agent-default-model` is a composition
383
+ * row rather than an environment variable.
384
+ *
385
+ * A patch REPLACES the targeted row's whole `config`, so each row below is
386
+ * written out complete rather than as a partial merge.
387
+ */
388
+ function buildPrivatePatch({ model, provider, permissionMode } = {}) {
389
+ const mode = normalizePermissionMode(permissionMode) || 'workspace-write';
390
+ const sandboxFor = (m) => (m === 'read-only' ? 'read-only' : m === 'danger-full-access' ? 'danger-full-access' : 'workspace-write');
391
+
392
+ const lines = [
393
+ '# Generated by OpenAgents for the DeepSeek Harness adapter.',
394
+ `# Pinned to dsh ${SUPPORTED_DSH_VERSION}. Do not edit by hand: this file is`,
395
+ '# rewritten on every run and lives inside a per-agent private DSH_HOME.',
396
+ '',
397
+ `- id: ${ROW.USER_QUESTIONS}`,
398
+ ' disabled: true',
399
+ '',
400
+ `- id: ${ROW.APPROVAL}`,
401
+ ' config:',
402
+ ' policy: never',
403
+ '',
404
+ `- id: ${ROW.PERMISSION}`,
405
+ ' config:',
406
+ ' presets:',
407
+ ];
408
+
409
+ for (const m of PERMISSION_MODES) {
410
+ lines.push(` ${m}:`);
411
+ lines.push(` sandbox: ${sandboxFor(m)}`);
412
+ lines.push(' approval: never');
413
+ }
414
+
415
+ if (model || provider) {
416
+ lines.push('');
417
+ lines.push(`- id: ${ROW.AGENT_DEFAULT_MODEL}`);
418
+ lines.push(' config:');
419
+ lines.push(` provider: ${yamlScalar(provider || 'deepseek-official')}`);
420
+ if (model) lines.push(` model: ${yamlScalar(model)}`);
421
+ }
422
+
423
+ lines.push('');
424
+ return { text: lines.join('\n'), mode };
425
+ }
426
+
427
+ // ---------------------------------------------------------------------------
428
+ // Permission mode
429
+ // ---------------------------------------------------------------------------
430
+
431
+ /**
432
+ * Validate DSH_PERMISSION_MODE strictly. Returns the mode, or null when the
433
+ * value is not one dsh declares.
434
+ *
435
+ * A silent fallback is the wrong behaviour here: a typo'd `workspace_write`
436
+ * would quietly run under whatever dsh defaults to, which is the opposite of
437
+ * what the user asked for on a permission setting.
438
+ */
439
+ function normalizePermissionMode(value) {
440
+ if (value == null || value === '') return null;
441
+ const v = String(value).trim();
442
+ return PERMISSION_MODES.includes(v) ? v : null;
443
+ }
444
+
445
+ /** The mode to run under, given the workspace mode and the user's setting. */
446
+ function resolvePermissionMode({ workspaceMode, configured }) {
447
+ // Plan mode is read-only regardless of what the agent is configured with:
448
+ // OpenAgents promises plan mode makes no changes.
449
+ if (workspaceMode === 'plan') return 'read-only';
450
+ return normalizePermissionMode(configured) || 'workspace-write';
451
+ }
452
+
453
+ // ---------------------------------------------------------------------------
454
+ // Output handling
455
+ // ---------------------------------------------------------------------------
456
+
457
+ /**
458
+ * Hard cap on buffered stdout. dsh emits nothing until the run ends and then
459
+ * writes one assistant message, so this is not a streaming budget — it is the
460
+ * "a wedged process must not OOM the daemon" backstop. Mirrors pi-stream.js's
461
+ * MAX_LINE_BYTES rationale.
462
+ */
463
+ const MAX_STDOUT_BYTES = 16 * 1024 * 1024;
464
+
465
+ /** Cap on the reply actually posted to the workspace. */
466
+ const MAX_REPLY_CHARS = 64 * 1024;
467
+
468
+ /**
469
+ * Cap on retained stderr, as a rolling TAIL rather than a prefix.
470
+ *
471
+ * dsh writes a terminal error's code and message at the END of the stream, so
472
+ * keeping the head would discard the only line that explains the failure. The
473
+ * byte form bounds what the adapter buffers while a run is in flight; the char
474
+ * form bounds what is finally reported.
475
+ \*/
476
+ const MAX_STDERR_BYTES = 8 * 1024;
477
+ const MAX_STDERR_CHARS = 8 * 1024;
478
+
479
+ /**
480
+ * Turn raw stdout into the reply to post.
481
+ *
482
+ * dsh writes the last non-empty assistant text and nothing else on success, so
483
+ * this is deliberately minimal: trim, then bound. Returns { text, truncated }.
484
+ */
485
+ function cleanStdout(raw, { maxChars = MAX_REPLY_CHARS } = {}) {
486
+ const s = String(raw == null ? '' : raw).replace(/\s+$/, '');
487
+ if (s.length <= maxChars) return { text: s, truncated: false };
488
+ return {
489
+ text: s.slice(0, maxChars) + '\n\n[output truncated by OpenAgents]',
490
+ truncated: true,
491
+ };
492
+ }
493
+
494
+ /**
495
+ * Keep the TAIL of stderr, not the head.
496
+ *
497
+ * dsh writes a terminal `error` reason's code and message at the end of the
498
+ * stream; truncating from the front would throw away the only line that
499
+ * explains the failure.
500
+ */
501
+ function tailStderr(raw, { maxChars = MAX_STDERR_CHARS } = {}) {
502
+ const s = String(raw == null ? '' : raw).trim();
503
+ return s.length <= maxChars ? s : '…' + s.slice(-maxChars);
504
+ }
505
+
506
+ /** Failure categories surfaced to the workspace. */
507
+ const FAILURE = {
508
+ AUTH: 'auth',
509
+ MODEL: 'model',
510
+ NETWORK: 'network',
511
+ CONFIG: 'config',
512
+ PERMISSION: 'permission',
513
+ VERSION: 'version',
514
+ UNKNOWN: 'unknown',
515
+ };
516
+
517
+ const FAILURE_PATTERNS = [
518
+ [FAILURE.AUTH, /\b(401|403|unauthor|forbidden|invalid[_\s-]?api[_\s-]?key|authentication|no api key|missing api key|credential)/i],
519
+ [FAILURE.MODEL, /\b(model[_\s-]?not[_\s-]?found|unknown model|unsupported model|no such model|context[_\s-]?length|token limit|quota|rate[_\s-]?limit|429)/i],
520
+ [FAILURE.NETWORK, /\b(econnrefused|enotfound|etimedout|econnreset|socket hang up|network|dns|proxy|certificate|tls|ssl|502|503|504)/i],
521
+ [FAILURE.PERMISSION, /\b(eacces|eperm|erofs|permission denied|read[_\s-]?only file system|operation not permitted)/i],
522
+ [FAILURE.VERSION, /\b(unsupported node|engine|requires node|syntaxerror.*unexpected token|err_require_esm|cannot use import statement)/i],
523
+ [FAILURE.CONFIG, /\b(profile|cordis|patch|settings\.yaml|plugin|bundle|dsh_home|composition|row)\b/i],
524
+ ];
525
+
526
+ /**
527
+ * Classify a non-zero dsh run.
528
+ *
529
+ * The `stdout` of a failed run is deliberately IGNORED by the caller: dsh only
530
+ * guarantees the final assistant text on success, so whatever partial text is
531
+ * sitting in the buffer of a failed run is not an answer.
532
+ *
533
+ * Returns { category, message } with the message already tail-bounded. The
534
+ * caller is responsible for the final redaction pass.
535
+ */
536
+ function classifyDshFailure({ code, signal, stderr } = {}) {
537
+ const detail = tailStderr(stderr);
538
+ if (signal) {
539
+ return {
540
+ category: FAILURE.UNKNOWN,
541
+ message: `DeepSeek Harness was terminated by signal ${signal}.` + (detail ? `\n\n${detail}` : ''),
542
+ };
543
+ }
544
+ for (const [category, re] of FAILURE_PATTERNS) {
545
+ if (re.test(detail)) return { category, message: detail };
546
+ }
547
+ return {
548
+ category: FAILURE.UNKNOWN,
549
+ message:
550
+ detail ||
551
+ `DeepSeek Harness exited with code ${code == null ? '?' : code} and wrote no diagnostics.`,
552
+ };
553
+ }
554
+
555
+ // ---------------------------------------------------------------------------
556
+ // Session GC
557
+ // ---------------------------------------------------------------------------
558
+
559
+ /** Keep at most this many persisted sessions per agent. */
560
+ const SESSION_KEEP_COUNT = 50;
561
+
562
+ /** Drop persisted sessions older than this. */
563
+ const SESSION_KEEP_MS = 7 * 24 * 60 * 60 * 1000;
564
+
565
+ /**
566
+ * Decide which persisted sessions to delete.
567
+ *
568
+ * Every headless run creates one fresh persisted session (dsh has no resume),
569
+ * so the sessions directory grows once per workspace message and would never
570
+ * shrink on its own.
571
+ *
572
+ * The rule is OR: an entry goes if it is older than SESSION_KEEP_MS **or** it
573
+ * falls outside the newest SESSION_KEEP_COUNT. Both bounds matter — a busy
574
+ * agent can blow past the count inside a day, and an idle one accumulates stale
575
+ * sessions that the count alone would never reach.
576
+ *
577
+ * `entries` is [{ name, mtimeMs }]. Callers must have already excluded anything
578
+ * that is not a regular file/directory (symlinks are rejected upstream) and
579
+ * must only call this when the adapter has no live dsh child: a session
580
+ * belonging to a run still in flight would otherwise look like a stale one.
581
+ */
582
+ function selectSessionsForGc(entries, { now, keepCount = SESSION_KEEP_COUNT, keepMs = SESSION_KEEP_MS } = {}) {
583
+ const list = (entries || []).filter((e) => e && e.name);
584
+ if (list.length === 0) return [];
585
+ const cutoff = (typeof now === 'number' ? now : Date.now()) - keepMs;
586
+
587
+ const byNewest = [...list].sort((a, b) => (b.mtimeMs || 0) - (a.mtimeMs || 0));
588
+ const keep = new Set(byNewest.slice(0, keepCount).map((e) => e.name));
589
+
590
+ return list
591
+ .filter((e) => !keep.has(e.name) || (e.mtimeMs || 0) < cutoff)
592
+ .map((e) => e.name);
593
+ }
594
+
595
+ module.exports = {
596
+ SUPPORTED_DSH_VERSION,
597
+ NODE_MIN_22,
598
+ ROW,
599
+ PERMISSION_MODES,
600
+ HEADLESS_TASK_INSTRUCTION,
601
+
602
+ parseDshVersion,
603
+ compareDshVersions,
604
+ classifyDshVersion,
605
+ defaultInstallCommand,
606
+
607
+ classifyNodeVersion,
608
+ nodeRequirementText,
609
+
610
+ safeDshHomeName,
611
+
612
+ dshEntryCandidates,
613
+ buildHeadlessArgs,
614
+ buildDumpConfigArgs,
615
+
616
+ yamlScalar,
617
+ buildPrivatePatch,
618
+
619
+ normalizePermissionMode,
620
+ resolvePermissionMode,
621
+
622
+ MAX_STDOUT_BYTES,
623
+ MAX_REPLY_CHARS,
624
+ MAX_STDERR_BYTES,
625
+ MAX_STDERR_CHARS,
626
+ FAILURE,
627
+ cleanStdout,
628
+ tailStderr,
629
+ classifyDshFailure,
630
+
631
+ SESSION_KEEP_COUNT,
632
+ SESSION_KEEP_MS,
633
+ selectSessionsForGc,
634
+ };