@banbolee/dsh-fish-shell 0.4.1 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/tool.js CHANGED
@@ -9,64 +9,110 @@
9
9
  * the standalone-spawn version this module used to carry was removed because
10
10
  * it bypassed all of that.
11
11
  *
12
- * The tool description teaches fish syntax because models default to bash
13
- * idioms; without it, every `export`/`$(...)`/`if then fi` would fail under
14
- * fish.
12
+ * The functional surface mirrors the official `@deepseek-ai/dsh-tool-bash`
13
+ * (the harness's model-facing `bash` tool) with shell-specific wording
14
+ * swapped to fish: background execution through `ctx.jobs` (the process's
15
+ * non-consuming stream readers as the registry's pull sources, with the
16
+ * sandbox facts joined to the job outcome), same-turn sandbox escalation through
17
+ * `ctx.approval` (strictly-wider modes only, fail-closed), canonical
18
+ * foreground results carrying the complete sandbox facts
19
+ * (`enforcement`/`runnerFailed`), terminal/generic UI presentation, and the
20
+ * `tool:fish` exit-code prompt section. On top of that it keeps this
21
+ * bundle's fish-specific teaching: the description translates bash idioms,
22
+ * and failed runs carry a `[fish syntax]` hint naming the exact fix.
15
23
  *
16
24
  * @module @banbolee/dsh-fish-shell/tool
17
25
  */
18
26
 
27
+ /// <reference path="./job-kind.d.ts" />
28
+
19
29
  import { isAbsolute, resolve } from 'node:path'
20
- import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
21
- import { resolveDshHome } from '@deepseek-ai/dsh-home-paths'
30
+ import { TOOL_ABORTED } from '@deepseek-ai/dsh-tools'
31
+ import { HarnessError } from '@deepseek-ai/dsh-llm'
32
+ import {
33
+ ESCALATION_TARGETS,
34
+ approveEscalation,
35
+ escalationHintMarker,
36
+ sandboxDenialMarker,
37
+ validateEscalationArgs,
38
+ } from '@deepseek-ai/dsh-sandbox'
39
+ import { DSH_ENV_PREFIX, parseExitStatus } from '@deepseek-ai/dsh-shell'
22
40
 
23
- export const name = 'fish-tool'
24
- export const inject = ['tools', 'shell', 'shellEnv']
41
+ /**
42
+ * Type-only reference so `@deepseek-ai/dsh-shell-env`'s module augmentation
43
+ * (`ctx.shellEnv`, the service `inject` above requires) joins this checkJs
44
+ * program: the bundle patch mounts the package at runtime, and this module has
45
+ * no reason to import it as a value.
46
+ * @typedef {import('@deepseek-ai/dsh-shell-env').ShellEnvRegistry} ShellEnvRegistry
47
+ */
25
48
 
26
49
  /**
27
- * Bundled fish agent preset: a copy of the shipped `standard` preset with
28
- * the shell section removed (the `fish` tool is mounted host-globally by
29
- * this plugin, so the preset needs no shell row).
50
+ * One captured stream with its truncation facts.
51
+ * @typedef {object} FishOutputStream
52
+ * @property {string} text
53
+ * @property {boolean} truncated
54
+ * @property {string} [spillPath]
30
55
  */
31
- const BUNDLED_PRESET_URL = new URL('./presets/fish/agent.cordis.yml', import.meta.url)
32
56
 
33
57
  /**
34
- * Install the bundled fish agent preset under the agent-presets user root,
35
- * create-only: an existing target (even a user-edited one) is left alone,
36
- * and a failed write is a warning, never a load failure.
58
+ * Sandbox facts carried into the canonical result (`ShellSandboxInfo` as
59
+ * lossless JSON).
60
+ * @typedef {object} FishSandboxFacts
61
+ * @property {import('@deepseek-ai/dsh-sandbox').SandboxMode} mode
62
+ * @property {boolean} denied
63
+ * @property {import('@deepseek-ai/dsh-sandbox').SandboxEnforcement} [enforcement]
64
+ * @property {boolean} [runnerFailed]
37
65
  */
38
- function installPreset() {
39
- const home = resolveDshHome()
40
- const targetDir = `${home}/.agent-presets/fish`
41
- const target = `${targetDir}/agent.cordis.yml`
42
- let exists = true
43
- try {
44
- readFileSync(target)
45
- } catch {
46
- exists = false
47
- }
48
- if (exists) return
49
- try {
50
- mkdirSync(targetDir, { recursive: true })
51
- writeFileSync(target, readFileSync(BUNDLED_PRESET_URL, 'utf8'))
52
- } catch (error) {
53
- console.warn('@banbolee/dsh-fish-shell: could not install the fish agent preset:', String(error))
54
- }
55
- }
66
+
67
+ /**
68
+ * The `fish` tool's parsed arguments. Registration is raw JSON Schema, so the
69
+ * registry hands the body `unknown`; these are the fields the tool validates
70
+ * and narrows at runtime, typed as the schema admits them (every one optional).
71
+ * @typedef {object} FishToolArgs
72
+ * @property {string} [command] - the fish command to execute.
73
+ * @property {string} [description] - the one-line UI description.
74
+ * @property {string} [workdir] - optional working directory (session-relative when not absolute).
75
+ * @property {number} [timeoutMs] - optional foreground deadline.
76
+ * @property {boolean} [run_in_background] - run as a `ctx.jobs` background job.
77
+ * @property {string} [sandbox_permissions] - one-shot escalation target.
78
+ * @property {string} [justification] - the escalation's one-sentence reason.
79
+ */
80
+
81
+ /**
82
+ * The canonical JSON value of one foreground fish run — the `foreground`
83
+ * branch of `OUTPUT_SCHEMA` and what `renderResult` renders. Structural on
84
+ * purpose: a replay holds this projection, not the executor's
85
+ * `ShellRunResult`.
86
+ * @typedef {object} FishForegroundValue
87
+ * @property {number | null} exitCode
88
+ * @property {string | null} signal
89
+ * @property {boolean} timedOut
90
+ * @property {boolean} aborted
91
+ * @property {number} timeoutMs
92
+ * @property {FishOutputStream} stdout
93
+ * @property {FishOutputStream} stderr
94
+ * @property {FishSandboxFacts} [sandbox]
95
+ */
96
+
97
+ export const name = 'fish-tool'
98
+ export const inject = ['tools', 'shell', 'systemPrompt', 'shellEnv']
56
99
 
57
100
  /**
58
101
  * The model-facing tool description. Written from the model's perspective:
59
102
  * the most important fact is that this is FISH, not bash, so the model must
60
- * translate bash idioms before running them.
103
+ * translate bash idioms before running them. Structured like the official
104
+ * bash tool description (markers, `DSH_*` environment facts, background
105
+ * execution, sandbox escalation) on top of the fish syntax teaching.
61
106
  */
62
- const TOOL_DESCRIPTION = `Execute a fish shell command (\`fish -c\`) and return its stdout/stderr. Each call runs in a fresh non-login fish: no state (cwd, variables, functions, history) persists between calls — pass \`workdir\` instead of using \`cd\`. This tool uses the FISH shell, NOT bash; translate bash commands to fish syntax first:
107
+ const BASE_TOOL_DESCRIPTION = `Execute a fish shell command (\`fish -c\`) and return its stdout/stderr. Each call runs in a fresh non-login fish: no state (cwd, variables, functions, history) persists between calls — pass \`workdir\` instead of using \`cd\`. This tool uses the FISH shell, NOT bash; translate bash commands to fish syntax first:
63
108
  - Variables: \`set -gx NAME value\` (not \`export NAME=value\`); read them as \`$NAME\`. Use \`env\` to see exported variables.
64
109
  - Conditionals/loops: \`if ...; ...; end\`, \`for x in ...; ...; end\`, \`while ...; ...; end\`, \`switch\` instead of \`case\`.
65
110
  - Chaining: \`a; and b\` / \`a; or b\` (fish 3 also accepts \`&&\` / \`||\`); background a job with \`cmd &\` and wait with \`wait\`.
66
111
  - Command substitution: \`(cmd)\` (fish 3 also accepts \`$(cmd)\`); arithmetic: \`math '1 + 2'\`; string processing: \`string ...\`.
67
112
  - Expansion: \`~\`, \`$VAR\`, and globs like \`*.md\` expand; an unmatched glob is an error, not a literal.
68
- - Commands may run under a file sandbox; a blocked file operation is reported as \`[sandbox: file access denied under <mode> mode]\`.
69
- - Non-zero exits are reported as \`[exit code: N]\`. Long output is truncated to its tail.
113
+ - Commands may run under a file sandbox; a blocked file operation is reported as \`[sandbox: file access denied under <mode> mode]\` — a policy denial, not a bug in the command; do not retry another way.
114
+ - Non-zero exits are reported as \`[exit code: N]\`. Current harness environment facts are exposed through managed \`$${DSH_ENV_PREFIX}*\` variables; inspect them when needed. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available.
115
+ - Set \`run_in_background: true\` for long-running commands: the call returns a job id immediately; read its output with \`job_output\` and stop it with \`job_kill\`. No timeout applies.
70
116
  Bash idioms fish REJECTS with exit 127 — translate before running:
71
117
  \`VAR=x\` / \`export VAR=x\` → \`set VAR x\` / \`set -gx VAR x\`
72
118
  \`for i in ...; do ...; done\` → \`for i in ...; ...; end\`
@@ -74,6 +120,20 @@ Bash idioms fish REJECTS with exit 127 — translate before running:
74
120
  \`cmd <<'EOF' ... EOF\` (heredoc) → \`printf '...' | cmd\` (fish has no heredoc)
75
121
  If fish rejects your command, retry with the fish form from the table — failed results carry a [fish syntax] hint naming the exact fix.`
76
122
 
123
+ const ESCALATION_TOOL_DESCRIPTION = 'Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry is how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one this command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for that command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later.'
124
+
125
+ const TOOL_DESCRIPTION = `${BASE_TOOL_DESCRIPTION}\n${ESCALATION_TOOL_DESCRIPTION}`
126
+
127
+ /**
128
+ * The tool description for one composition: the escalation prose is appended
129
+ * only when the composition advertises escalation targets.
130
+ * @param {readonly import('@deepseek-ai/dsh-sandbox').SandboxMode[]} escalationModes - targets this composition advertises.
131
+ * @returns {string} the model-facing description.
132
+ */
133
+ function fishDescription(escalationModes) {
134
+ return escalationModes.length === 0 ? BASE_TOOL_DESCRIPTION : TOOL_DESCRIPTION
135
+ }
136
+
77
137
  /**
78
138
  * Targeted fish-syntax correction for a failed run. Models default to bash
79
139
  * idioms; when fish rejects a command the stderr carries a diagnostic like
@@ -108,7 +168,11 @@ const FISH_SYNTAX_HINTS = [
108
168
  },
109
169
  ]
110
170
 
111
- /** Find a fish syntax hint for one stderr text, or '' when none applies. */
171
+ /**
172
+ * Find a fish syntax hint for one stderr text, or '' when none applies.
173
+ * @param {string} stderrText - the failed run's stderr text.
174
+ * @returns {string} the mapped hint, or `''` when nothing applies.
175
+ */
112
176
  function fishSyntaxHint(stderrText) {
113
177
  for (const { pattern, hint } of FISH_SYNTAX_HINTS) {
114
178
  if (pattern.test(stderrText)) return hint
@@ -120,11 +184,19 @@ function fishSyntaxHint(stderrText) {
120
184
  * Shape one finished run into the text the model sees: stdout, then a marked
121
185
  * stderr section, then sandbox/timeout/signal/exit markers — the same marker
122
186
  * contract the harness bash tool uses, so the model reads both shells
123
- * identically.
124
- * @param {object} result - the settled foreground result from the executor.
187
+ * identically. A denied run under an escalation-advertising composition
188
+ * additionally carries the shared same-turn escalation hint.
189
+ * @param {FishForegroundValue} result - the canonical foreground value from the executor.
190
+ * @param {readonly string[]} [escalationModes] - the escalation targets this
191
+ * composition advertises; non-empty appends the same-turn escalation hint
192
+ * after a denial marker (default `[]`: no hint).
125
193
  * @returns {string} the model-facing text.
126
194
  */
127
- function renderResult(result) {
195
+ function renderResult(result, escalationModes = []) {
196
+ /**
197
+ * @param {FishOutputStream} stream - one captured stream of the canonical value.
198
+ * @returns {string} its text plus the truncation notice when it was cut.
199
+ */
128
200
  const streamText = (stream) => {
129
201
  if (!stream.truncated) return stream.text
130
202
  return `${stream.text}\n[output truncated${stream.spillPath !== undefined
@@ -152,7 +224,8 @@ function renderResult(result) {
152
224
 
153
225
  const markers = []
154
226
  if (result.sandbox?.denied) {
155
- markers.push(`[sandbox: file access denied under ${result.sandbox.mode} mode]`)
227
+ markers.push(sandboxDenialMarker(result.sandbox.mode))
228
+ if (escalationModes.length > 0) markers.push(escalationHintMarker('command'))
156
229
  }
157
230
  if (result.timedOut) markers.push(`[timed out after ${result.timeoutMs ?? 0}ms]`)
158
231
  if (result.signal !== null) {
@@ -165,6 +238,211 @@ function renderResult(result) {
165
238
  return body + markers.join('\n')
166
239
  }
167
240
 
241
+ /**
242
+ * Sandbox facts worth the job's terminal detail: a runner that never ran the
243
+ * command, or a denial (with the escalation hint this composition offers).
244
+ * The `ctx.jobs` registry owns the read path now, so these notes travel on the
245
+ * outcome's detail line — the one line every reader (the model's status line,
246
+ * the job roster row) shows — instead of being appended to each read.
247
+ * @param {import('@deepseek-ai/dsh-shell').ShellSandboxInfo | undefined} sandbox - settled sandbox facts, when this was a confined process.
248
+ * @param {readonly string[]} escalationModes - escalation targets advertised by this composition.
249
+ * @returns {string[]} the notes to append, oldest first.
250
+ */
251
+ function sandboxNotes(sandbox, escalationModes) {
252
+ if (sandbox?.runnerFailed) {
253
+ return [`[sandbox: the sandbox runner itself failed under ${sandbox.mode} mode — the command did not run; this is a sandbox problem, not a command failure]`]
254
+ }
255
+ if (sandbox?.denied) {
256
+ const notes = [sandboxDenialMarker(sandbox.mode)]
257
+ if (escalationModes.length > 0) notes.push(escalationHintMarker('command'))
258
+ return notes
259
+ }
260
+ return []
261
+ }
262
+
263
+ /**
264
+ * Map a settled background process onto the generic task-outcome vocabulary:
265
+ * `killed` stays `killed` (detail: the signal when one is known), everything
266
+ * else is `completed` with the exit code as detail. A nonzero command exit is
267
+ * reported, not failed, exactly like the foreground rendering. Sandbox facts
268
+ * join the detail, since a job's terminal reason is the one line every
269
+ * reader — the model's status line, the roster row — shows.
270
+ * @param {import('@deepseek-ai/dsh-shell').ShellProcess} proc - the settled process handle.
271
+ * @param {readonly string[]} [escalationModes] - escalation targets advertised by this composition.
272
+ * @returns {{ status: 'completed' | 'killed', detail: string }} the outcome for the `ctx.jobs` registration.
273
+ */
274
+ function processOutcome(proc, escalationModes = []) {
275
+ /** @type {{ status: 'completed' | 'killed', detail: string }} */
276
+ const base = proc.status === 'killed'
277
+ ? { status: 'killed', detail: proc.signal !== null ? `signal: ${proc.signal}` : 'killed before exit' }
278
+ : { status: 'completed', detail: `exit code: ${proc.exitCode ?? 0}` }
279
+ const notes = sandboxNotes(proc.sandbox, escalationModes)
280
+ return notes.length === 0 ? base : {
281
+ ...base,
282
+ detail: `${base.detail}; ${notes.join(' ')}`,
283
+ }
284
+ }
285
+
286
+ /**
287
+ * The process's non-consuming stream readers as `ctx.jobs` pull sources
288
+ * (ported from the official `@deepseek-ai/dsh-tool-bash`'s background
289
+ * adaptation: the registry pumps these instead of the removed
290
+ * `JobHooks.readOutput`). They bind lazily because the handle is published
291
+ * only after asynchronous preparation; a read before then yields nothing, and
292
+ * the pump keeps the model's consuming cursor untouched.
293
+ * @param {() => import('@deepseek-ai/dsh-shell').ShellProcess | undefined} process - resolves the published handle, once preparation has one.
294
+ * @returns {readonly import('@deepseek-ai/dsh-jobs').JobOutputSource[]} one source per stream, stdout first.
295
+ */
296
+ function processSources(process) {
297
+ /**
298
+ * @param {'stdout' | 'stderr'} channel - stream label the registry attaches to this source's chunks.
299
+ * @returns {import('@deepseek-ai/dsh-jobs').JobOutputSource} one non-consuming pull source.
300
+ */
301
+ const source = (channel) => ({
302
+ channel,
303
+ /** @param {number} fromByte - whole-stream offset to resume from (a prior read's `nextOffset`). */
304
+ read: (fromByte) => {
305
+ const live = process()
306
+ return live === undefined
307
+ ? { text: '', nextOffset: fromByte, lossy: false }
308
+ : live.observed[channel].readFrom(fromByte)
309
+ },
310
+ })
311
+ return [source('stdout'), source('stderr')]
312
+ }
313
+
314
+ /**
315
+ * Synchronous job hooks over asynchronous shell preparation (ported from the
316
+ * official `@deepseek-ai/dsh-tool-bash`): `ctx.jobs` admits the job and calls
317
+ * `run()` synchronously, while `ctx.shell.execute` publishes its handle only
318
+ * after preparation. Cancellation aborts the preparation signal (which also
319
+ * stops the command during preparation) and kills an already-published
320
+ * process; `done` spans preparation and settlement, and never rejects.
321
+ * @param {(signal: AbortSignal) => Promise<import('@deepseek-ai/dsh-shell').ShellExecution>} start - starts the process under a job-owned cancellation signal.
322
+ * @param {(proc: import('@deepseek-ai/dsh-shell').ShellProcess) => { status: 'completed' | 'killed', detail: string }} outcome - projects the settled process into the job outcome.
323
+ * @returns {{ cancel: (reason?: string) => void, done: Promise<{ status: 'completed' | 'killed' | 'failed', detail: string }> }} the job's hooks.
324
+ */
325
+ function processJob(start, outcome) {
326
+ const controller = new AbortController()
327
+ /** @type {import('@deepseek-ai/dsh-shell').ShellExecution | undefined} */
328
+ let proc
329
+ return {
330
+ cancel: (reason) => {
331
+ if (controller.signal.aborted) return
332
+ controller.abort(reason)
333
+ proc?.kill()
334
+ },
335
+ done: (async () => {
336
+ try {
337
+ proc = await start(controller.signal)
338
+ try {
339
+ if (controller.signal.aborted) proc.kill()
340
+ } finally {
341
+ await proc.done
342
+ }
343
+ return outcome(proc)
344
+ } catch (error) {
345
+ return {
346
+ status: controller.signal.aborted && proc === undefined ? 'killed' : 'failed',
347
+ detail: error instanceof Error ? error.message : String(error),
348
+ }
349
+ }
350
+ })(),
351
+ }
352
+ }
353
+
354
+ /**
355
+ * Detach the executor DTO from readonly Service Definition types into plain JSON data.
356
+ * @param {import('@deepseek-ai/dsh-shell').ShellRunResult} result - the settled foreground result.
357
+ * @returns {FishForegroundValue} the canonical value declared by OUTPUT_SCHEMA.
358
+ */
359
+ function canonicalFishResult(result) {
360
+ /**
361
+ * @param {import('@deepseek-ai/dsh-shell').CollectedOutput} s - one captured stream of the settled result.
362
+ * @returns {FishOutputStream} its lossless-JSON projection.
363
+ */
364
+ const stream = (s) => ({
365
+ text: s.text,
366
+ truncated: s.truncated,
367
+ ...s.spillPath !== undefined ? { spillPath: s.spillPath } : {},
368
+ })
369
+ return {
370
+ exitCode: result.exitCode,
371
+ signal: result.signal,
372
+ timedOut: result.timedOut,
373
+ aborted: result.aborted,
374
+ timeoutMs: result.timeoutMs,
375
+ stdout: stream(result.stdout),
376
+ stderr: stream(result.stderr),
377
+ ...result.sandbox !== undefined ? { sandbox: {
378
+ mode: result.sandbox.mode,
379
+ denied: result.sandbox.denied,
380
+ ...result.sandbox.enforcement !== undefined ? { enforcement: result.sandbox.enforcement } : {},
381
+ ...result.sandbox.runnerFailed !== undefined ? { runnerFailed: result.sandbox.runnerFailed } : {},
382
+ } } : {},
383
+ }
384
+ }
385
+
386
+ /**
387
+ * Present the PENDING state of one call in a UI: a foreground command is a
388
+ * terminal card (cwd-headed); a background call is a generic execute card.
389
+ * Total over malformed replay args: a non-object or a missing/non-string
390
+ * command/description returns `undefined` so the UI falls back to its
391
+ * generic presentation instead of throwing or minting an invalid title.
392
+ * @param {any} args - the call's parsed arguments.
393
+ * @returns {import('@deepseek-ai/dsh-tools').ToolCallView | undefined} the pending card intent.
394
+ */
395
+ function presentFishCall(args) {
396
+ if (typeof args !== 'object' || args === null || Array.isArray(args)) return undefined
397
+ if (typeof args.command !== 'string' || typeof args.description !== 'string') return undefined
398
+ if (args.run_in_background === true) {
399
+ return {
400
+ card: 'generic',
401
+ title: args.command,
402
+ kind: 'execute',
403
+ rawInput: args.command,
404
+ content: [{ type: 'text', text: args.description }],
405
+ }
406
+ }
407
+ return {
408
+ card: 'terminal',
409
+ title: args.command,
410
+ description: args.description,
411
+ ...typeof args.workdir === 'string' ? { cwd: args.workdir } : {},
412
+ }
413
+ }
414
+
415
+ /**
416
+ * Present completed foreground output as a terminal card with an exit-status
417
+ * pill (recovered via `parseExitStatus`); background acknowledgements and
418
+ * execution errors use generic fenced output without an exit-status pill.
419
+ * Total over malformed replay args/results: invalid inputs return
420
+ * `undefined` (generic UI fallback) instead of throwing.
421
+ * @param {any} args - the call's parsed arguments.
422
+ * @param {any} result - the durable result projection (validated here, so malformed replays cannot throw).
423
+ * @returns {import('@deepseek-ai/dsh-tools').ToolResultView | undefined} the completed card intent.
424
+ */
425
+ function presentFishResult(args, result) {
426
+ if (typeof args !== 'object' || args === null || Array.isArray(args)) return undefined
427
+ if (typeof args.command !== 'string') return undefined
428
+ if (typeof result !== 'object' || result === null || Array.isArray(result) || !Array.isArray(result.content)) return undefined
429
+ const block = result.content.length === 1 ? result.content[0] : undefined
430
+ if (block === undefined || block.type !== 'text') return undefined
431
+ const raw = block.text
432
+ if (args.run_in_background === true || result.isError) {
433
+ return {
434
+ card: 'generic',
435
+ content: [{ type: 'text', text: `\`\`\`console\n${raw.replace(/\n+$/, '')}\n\`\`\`` }],
436
+ }
437
+ }
438
+ const { body, ...exit } = parseExitStatus(raw)
439
+ return {
440
+ card: 'terminal',
441
+ output: body,
442
+ ...exit,
443
+ }
444
+ }
445
+
168
446
  /** Object-root JSON Schema for the tool's parameters (raw-registered). */
169
447
  const PARAMETERS = {
170
448
  type: 'object',
@@ -193,54 +471,110 @@ const PARAMETERS = {
193
471
  },
194
472
  }
195
473
 
196
- const OUTPUT = {
197
- schema: {
198
- type: 'object',
199
- additionalProperties: false,
200
- required: ['kind', 'exitCode', 'signal', 'timedOut', 'aborted', 'timeoutMs', 'stdout', 'stderr'],
474
+ /**
475
+ * The tool's parameters for one composition: the base parameters plus
476
+ * `run_in_background`, and the escalation fields only when the mounted
477
+ * executor advertises confinement (an unadvertised field is never parsed).
478
+ * @param {readonly import('@deepseek-ai/dsh-sandbox').SandboxMode[]} escalationModes - targets the composition advertises; empty hides the escalation fields.
479
+ */
480
+ function fishParameters(escalationModes) {
481
+ return {
482
+ ...PARAMETERS,
201
483
  properties: {
202
- kind: { type: 'string', const: 'foreground' },
203
- exitCode: { oneOf: [{ type: 'integer' }, { type: 'null' }] },
204
- signal: { oneOf: [{ type: 'string' }, { type: 'null' }] },
205
- timedOut: { type: 'boolean' },
206
- aborted: { type: 'boolean' },
207
- timeoutMs: { type: 'number' },
208
- stdout: {
209
- type: 'object',
210
- additionalProperties: false,
211
- required: ['text', 'truncated'],
212
- properties: {
213
- text: { type: 'string' },
214
- truncated: { type: 'boolean' },
215
- spillPath: { type: 'string' },
216
- },
484
+ ...PARAMETERS.properties,
485
+ run_in_background: {
486
+ type: 'boolean',
487
+ description: 'Run in the background and return a job id immediately '
488
+ + '(collect with job_output, stop with job_kill). No timeout applies.',
217
489
  },
218
- stderr: {
219
- type: 'object',
220
- additionalProperties: false,
221
- required: ['text', 'truncated'],
222
- properties: {
223
- text: { type: 'string' },
224
- truncated: { type: 'boolean' },
225
- spillPath: { type: 'string' },
490
+ ...escalationModes.length > 0 ? {
491
+ sandbox_permissions: {
492
+ type: 'string',
493
+ enum: [...escalationModes],
494
+ description: 'The wider sandbox mode this command needs. Only valid as a one-shot '
495
+ + 'retry of a command the sandbox just denied; requires justification and user approval.',
496
+ },
497
+ justification: {
498
+ type: 'string',
499
+ description: 'Required with sandbox_permissions: one sentence for the user explaining '
500
+ + 'why this exact command needs the wider access.',
226
501
  },
502
+ } : {},
503
+ },
504
+ }
505
+ }
506
+
507
+ /** Canonical output declaration: a background acknowledgment or a foreground
508
+ * run. The rc.1 ToolRuntime dialect forbids `type`/`additionalProperties`
509
+ * beside a `oneOf` root (exact-one branches), so the union is expressed as a
510
+ * bare `oneOf` with each branch carrying its own object shape.
511
+ * @type {import('@deepseek-ai/dsh-tools').JsonSchemaNode} */
512
+ const OUTPUT_SCHEMA = {
513
+ oneOf: [
514
+ {
515
+ type: 'object',
516
+ additionalProperties: false,
517
+ properties: {
518
+ kind: { type: 'string', const: 'background' },
519
+ jobId: { type: 'string' },
227
520
  },
228
- sandbox: {
229
- type: 'object',
230
- additionalProperties: false,
231
- required: ['mode', 'denied'],
232
- properties: {
233
- mode: { type: 'string' },
234
- denied: { type: 'boolean' },
521
+ required: ['kind', 'jobId'],
522
+ },
523
+ {
524
+ type: 'object',
525
+ additionalProperties: false,
526
+ properties: {
527
+ kind: { type: 'string', const: 'foreground' },
528
+ exitCode: { oneOf: [{ type: 'integer' }, { type: 'null' }] },
529
+ signal: { oneOf: [{ type: 'string' }, { type: 'null' }] },
530
+ timedOut: { type: 'boolean' },
531
+ aborted: { type: 'boolean' },
532
+ timeoutMs: { type: 'number' },
533
+ stdout: {
534
+ type: 'object',
535
+ additionalProperties: false,
536
+ properties: {
537
+ text: { type: 'string' },
538
+ truncated: { type: 'boolean' },
539
+ spillPath: { type: 'string' },
540
+ },
541
+ required: ['text', 'truncated'],
542
+ },
543
+ stderr: {
544
+ type: 'object',
545
+ additionalProperties: false,
546
+ properties: {
547
+ text: { type: 'string' },
548
+ truncated: { type: 'boolean' },
549
+ spillPath: { type: 'string' },
550
+ },
551
+ required: ['text', 'truncated'],
552
+ },
553
+ sandbox: {
554
+ type: 'object',
555
+ additionalProperties: false,
556
+ properties: {
557
+ mode: { type: 'string' },
558
+ denied: { type: 'boolean' },
559
+ enforcement: { type: 'string' },
560
+ runnerFailed: { type: 'boolean' },
561
+ },
562
+ required: ['mode', 'denied'],
235
563
  },
236
564
  },
565
+ required: ['kind', 'exitCode', 'signal', 'timedOut', 'aborted', 'timeoutMs', 'stdout', 'stderr'],
237
566
  },
238
- },
239
- render: (_args, value) => [{ type: 'text', text: renderResult(value) }],
567
+ ],
240
568
  }
241
569
 
242
- /** Resolve an explicit workdir: the sandbox policy's workspace root wins as
243
- * the base, then the session cwd; a relative path resolves against it. */
570
+ /**
571
+ * Resolve an explicit workdir: the sandbox policy's workspace root wins as
572
+ * the base, then the session cwd; a relative path resolves against it.
573
+ * @param {string | undefined} modelWorkdir - the model's `workdir` argument.
574
+ * @param {import('@deepseek-ai/dsh-tools').ToolRunContext} exec - the tool execution.
575
+ * @param {string | undefined} policyWorkspaceRoot - the standing policy's workspace root.
576
+ * @returns {string | undefined} the workdir for this call, or `undefined` to let the executor default it.
577
+ */
244
578
  function resolveWorkdir(modelWorkdir, exec, policyWorkspaceRoot) {
245
579
  const sessionCwd = policyWorkspaceRoot ?? exec?.agent?.session?.header?.cwd
246
580
  if (modelWorkdir === undefined) return sessionCwd
@@ -251,29 +585,75 @@ function resolveWorkdir(modelWorkdir, exec, policyWorkspaceRoot) {
251
585
  }
252
586
 
253
587
  /**
254
- * Cordis plugin entry: install the bundled fish agent preset (an idempotent
255
- * user-root fallback for surfaces whose roster row this bundle does not
256
- * patch), then register the `fish` tool once the tool registry and the
257
- * `fish` executor are up.
588
+ * Cordis plugin entry: register the `fish` tool once the tool registry, the
589
+ * `fish` executor, and the system-prompt registry are up. The per-agent fish
590
+ * policy lives in the sibling `policy.js` module (mounted by the bundle
591
+ * patch).
258
592
  * @param {import('@deepseek-ai/cordis').Context} ctx - the harness context.
259
593
  */
260
594
  export function apply(ctx) {
261
- installPreset()
262
-
263
595
  // Mirror dsh-tool-bash's policy path: when the mounted executor confines,
264
596
  // resolve each call's standing policy against the calling session so
265
597
  // /permission switches and the session workspace are honored, and fail loud
266
598
  // at load when the policy owner is missing.
267
599
  const defaultMode = ctx.shell.sandboxMode
600
+ const escalationModes = defaultMode === undefined ? [] : ESCALATION_TARGETS
268
601
  const sandboxPolicy = defaultMode === undefined ? undefined : ctx.get('sandboxPolicy')
269
602
  if (defaultMode !== undefined && sandboxPolicy === undefined) {
270
603
  throw new Error('tool-fish: the mounted fish executor confines but ctx.sandboxPolicy is missing')
271
604
  }
605
+ /**
606
+ * @param {import('@deepseek-ai/dsh-tools').ToolRunContext} exec - the tool execution.
607
+ * @returns {import('@deepseek-ai/dsh-sandbox').SandboxExecutionPolicy | undefined} the standing policy for this call's session, or `undefined` without a policy owner.
608
+ */
272
609
  const resolveSandboxPolicy = (exec) => {
273
610
  if (sandboxPolicy === undefined) return undefined
274
611
  return sandboxPolicy.resolve(exec.agent === undefined ? {} : { session: exec.agent.session })
275
612
  }
276
613
 
614
+ /**
615
+ * Resolve a sandbox-escalation request through `ctx.approval` BEFORE
616
+ * anything executes, delegating the shared fail-closed sequence (strict
617
+ * widening, channel resolution, outcome mapping) to approveEscalation —
618
+ * the same composition guard and ingredients as the official bash tool.
619
+ * @param {string} mode - the requested `sandbox_permissions` value (the ladder check lives in `approveEscalation`).
620
+ * @param {string} justification - the model's one-sentence reason, shown verbatim to the user.
621
+ * @param {import('@deepseek-ai/dsh-tools').ToolRunContext} exec - the tool execution awaiting the decision.
622
+ * @param {import('@deepseek-ai/dsh-sandbox').SandboxExecutionPolicy} standingPolicy - the call's standing policy (present whenever escalation is advertised).
623
+ * @returns {Promise<import('@deepseek-ai/dsh-sandbox').SandboxMode>} the granted mode, consumed by this one call.
624
+ */
625
+ const approveFishEscalation = (mode, justification, exec, standingPolicy) => {
626
+ if (escalationModes.length === 0) {
627
+ throw new Error('sandbox_permissions is not available in this composition (no sandboxing executor to escalate)')
628
+ }
629
+ const effectiveMode = standingPolicy.mode
630
+ return approveEscalation({
631
+ requestedMode: mode,
632
+ justification,
633
+ effectiveMode,
634
+ subject: 'command',
635
+ }, {
636
+ approver: ctx.get('approval'),
637
+ agent: exec.agent,
638
+ callId: exec.callId,
639
+ toolName: 'fish',
640
+ signal: exec.signal,
641
+ })
642
+ }
643
+
644
+ ctx.systemPrompt.section({
645
+ name: 'tool:fish',
646
+ order: ctx.systemPrompt.getSectionOrder('TOOL_BASH'),
647
+ text: 'Check the [exit code: N] marker on every fish result; investigate failures before moving on.',
648
+ })
649
+
650
+ /**
651
+ * One `fish` tool call: validate the model's arguments, resolve the standing
652
+ * policy and any same-turn escalation, then run through `ctx.shell`.
653
+ * @param {FishToolArgs} args - the parsed call arguments (validated below).
654
+ * @param {import('@deepseek-ai/dsh-tools').ToolRunContext} exec - the tool execution identity, agent, and cancellation.
655
+ * @returns {Promise<{ kind: 'foreground' } & FishForegroundValue | { kind: 'background', jobId: string }>} the canonical value declared by OUTPUT_SCHEMA.
656
+ */
277
657
  async function execute(args, exec) {
278
658
  if (typeof args.command !== 'string' || args.command.trim().length === 0) {
279
659
  throw new Error('invalid command: expected a non-empty string')
@@ -284,54 +664,108 @@ export function apply(ctx) {
284
664
  if (args.timeoutMs !== undefined && (!Number.isFinite(args.timeoutMs) || args.timeoutMs <= 0)) {
285
665
  throw new Error(`invalid timeoutMs: expected a positive number, got ${JSON.stringify(args.timeoutMs)}`)
286
666
  }
667
+ validateEscalationArgs(args.sandbox_permissions, args.justification)
287
668
  const standingPolicy = resolveSandboxPolicy(exec)
669
+ const approvedMode = args.sandbox_permissions !== undefined && args.justification !== undefined
670
+ // `escalationModes` is non-empty exactly when a policy owner is mounted,
671
+ // so an escalation request always has a standing policy to widen.
672
+ ? await approveFishEscalation(args.sandbox_permissions, args.justification, exec, /** @type {import('@deepseek-ai/dsh-sandbox').SandboxExecutionPolicy} */ (standingPolicy))
673
+ : undefined
674
+ // A one-shot escalation widens the standing policy for this call only.
675
+ // `apply` fails loud when a confining executor has no policy owner, so an
676
+ // approved mode implies a definite standing policy — the second guard makes
677
+ // that explicit for the spread instead of spreading a possible `undefined`.
678
+ const policy = approvedMode !== undefined && standingPolicy !== undefined
679
+ ? { ...standingPolicy, mode: approvedMode }
680
+ : standingPolicy
288
681
  const workdir = resolveWorkdir(args.workdir, exec, standingPolicy?.workspaceRoot)
289
682
  const request = {
290
683
  command: args.command,
291
684
  ...workdir !== undefined ? { workdir } : {},
292
685
  ...args.timeoutMs !== undefined ? { timeoutMs: args.timeoutMs } : {},
293
686
  dshEnv: ctx.shellEnv.collect(exec),
294
- ...standingPolicy !== undefined ? { sandboxPolicy: standingPolicy } : {},
687
+ ...policy !== undefined ? { sandboxPolicy: policy } : {},
688
+ }
689
+ if (args.run_in_background === true) {
690
+ const jobs = ctx.get('jobs')
691
+ if (jobs === undefined) {
692
+ throw new Error('background jobs unavailable: load @deepseek-ai/dsh-jobs and @deepseek-ai/dsh-tool-jobs')
693
+ }
694
+ if (exec.signal.aborted) {
695
+ const error = new HarnessError('tool call aborted', TOOL_ABORTED)
696
+ error.name = 'AbortError'
697
+ throw error
698
+ }
699
+ /** @type {import('@deepseek-ai/dsh-shell').ShellExecution | undefined} */
700
+ let proc
701
+ return {
702
+ kind: 'background',
703
+ jobId: jobs.start({
704
+ kind: 'fish',
705
+ label: args.command,
706
+ // The registry fences access by SESSION id: `owner` is the owning
707
+ // session, not the live agent object (`JobSpec.owner?: SessionId`).
708
+ ...exec.agent !== undefined ? { owner: exec.agent.id } : {},
709
+ // The registry pumps output itself (0.1.7-rc.2 removed the
710
+ // `JobHooks.readOutput` consumer), so the process's streams are
711
+ // passed as pull sources bound to the handle published below.
712
+ output: processSources(() => proc),
713
+ run: () => processJob(async (signal) => {
714
+ // Background work carries NO deadline: 0.1.5's `start()` armed no
715
+ // timer at all (`startArgv` spawned with the caller's signal while
716
+ // only `runArgv` built a `deadline(...)`), and the official
717
+ // 0.1.7 `dsh-tool-bash` resolves its job lane the same way.
718
+ // Without `onExpiry: 'none'` the resolved spec would default to
719
+ // `'kill'` and silently reintroduce a timeout the documented
720
+ // contract ("No timeout applies") never had.
721
+ const spec = ctx.shell.resolve({ ...request, onExpiry: 'none' })
722
+ proc = await ctx.shell.execute({ ...spec, signal })
723
+ return proc
724
+ }, (started) => processOutcome(started, escalationModes)),
725
+ }),
726
+ }
295
727
  }
296
- const result = await ctx.shell.run(ctx.shell.resolve({
728
+ const result = await (await ctx.shell.execute(ctx.shell.resolve({
297
729
  ...request,
298
730
  signal: exec?.signal,
299
- }))
731
+ }))).result()
300
732
  if (result.aborted) {
301
- const error = new Error('tool call aborted')
733
+ const error = new HarnessError('tool call aborted', TOOL_ABORTED)
302
734
  error.name = 'AbortError'
303
735
  throw error
304
736
  }
305
- const stream = (s) => ({
306
- text: s.text,
307
- truncated: s.truncated,
308
- ...s.spillPath !== undefined ? { spillPath: s.spillPath } : {},
309
- })
310
737
  return {
311
738
  kind: 'foreground',
312
- exitCode: result.exitCode,
313
- signal: result.signal,
314
- timedOut: result.timedOut,
315
- aborted: result.aborted,
316
- timeoutMs: result.timeoutMs,
317
- stdout: stream(result.stdout),
318
- stderr: stream(result.stderr),
319
- ...result.sandbox !== undefined
320
- ? { sandbox: { mode: result.sandbox.mode, denied: result.sandbox.denied } }
321
- : {},
739
+ ...canonicalFishResult(result),
322
740
  }
323
741
  }
324
742
 
325
743
  ctx.tools.register({
326
744
  name: 'fish',
327
- description: TOOL_DESCRIPTION,
328
- parameters: PARAMETERS,
329
- output: OUTPUT,
745
+ description: fishDescription(escalationModes),
746
+ parameters: fishParameters(escalationModes),
747
+ output: {
748
+ schema: OUTPUT_SCHEMA,
749
+ render: (_args, value) => {
750
+ // The canonical value crosses the lossless-JSON boundary, so it
751
+ // arrives as `JsonValue`; OUTPUT_SCHEMA is what guarantees the shape.
752
+ const record = typeof value === 'object' && value !== null && !Array.isArray(value) ? value : {}
753
+ return [{
754
+ type: 'text',
755
+ text: record.kind === 'background'
756
+ ? `started background job ${String(record.jobId)}`
757
+ : renderResult(/** @type {FishForegroundValue} */ (value), escalationModes),
758
+ }]
759
+ },
760
+ },
330
761
  execute,
762
+ presentCall: presentFishCall,
763
+ presentResult: presentFishResult,
331
764
  })
332
765
  }
333
766
 
334
- // Deterministic test surface: the tool description and the result renderer are
335
- // pure, so unit tests can assert the bash→fish guidance and the syntax-hint
336
- // injection without booting a harness or running a live shell.
337
- export { FISH_SYNTAX_HINTS, TOOL_DESCRIPTION, fishSyntaxHint, renderResult }
767
+ // Deterministic test surface: the tool description, the result renderers, and
768
+ // the UI presenters are pure, so unit tests can assert the bash→fish
769
+ // guidance, the syntax-hint injection, and the escalation-aware rendering
770
+ // without booting a harness or running a live shell.
771
+ export { FISH_SYNTAX_HINTS, TOOL_DESCRIPTION, fishDescription, fishSyntaxHint, presentFishCall, presentFishResult, renderResult }