@banbolee/dsh-fish-shell 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/tool.js CHANGED
@@ -9,64 +9,51 @@
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` (with lossy-read
15
+ * and sandbox notices), same-turn sandbox escalation through
16
+ * `ctx.approval` (strictly-wider modes only, fail-closed), canonical
17
+ * foreground results carrying the complete sandbox facts
18
+ * (`enforcement`/`runnerFailed`), terminal/generic UI presentation, and the
19
+ * `tool:fish` exit-code prompt section. On top of that it keeps this
20
+ * bundle's fish-specific teaching: the description translates bash idioms,
21
+ * and failed runs carry a `[fish syntax]` hint naming the exact fix.
15
22
  *
16
23
  * @module @banbolee/dsh-fish-shell/tool
17
24
  */
18
25
 
19
26
  import { isAbsolute, resolve } from 'node:path'
20
- import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
21
- import { resolveDshHome } from '@deepseek-ai/dsh-home-paths'
27
+ import { TOOL_ABORTED } from '@deepseek-ai/dsh-tools'
28
+ import { HarnessError } from '@deepseek-ai/dsh-llm'
29
+ import {
30
+ ESCALATION_TARGETS,
31
+ approveEscalation,
32
+ escalationHintMarker,
33
+ sandboxDenialMarker,
34
+ validateEscalationArgs,
35
+ } from '@deepseek-ai/dsh-sandbox'
36
+ import { DSH_ENV_PREFIX, parseExitStatus } from '@deepseek-ai/dsh-shell'
22
37
 
23
38
  export const name = 'fish-tool'
24
- export const inject = ['tools', 'shell', 'shellEnv']
25
-
26
- /**
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).
30
- */
31
- const BUNDLED_PRESET_URL = new URL('./presets/fish/agent.cordis.yml', import.meta.url)
32
-
33
- /**
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.
37
- */
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
- }
39
+ export const inject = ['tools', 'shell', 'systemPrompt', 'shellEnv']
56
40
 
57
41
  /**
58
42
  * The model-facing tool description. Written from the model's perspective:
59
43
  * the most important fact is that this is FISH, not bash, so the model must
60
- * translate bash idioms before running them.
44
+ * translate bash idioms before running them. Structured like the official
45
+ * bash tool description (markers, `DSH_*` environment facts, background
46
+ * execution, sandbox escalation) on top of the fish syntax teaching.
61
47
  */
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:
48
+ 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
49
  - Variables: \`set -gx NAME value\` (not \`export NAME=value\`); read them as \`$NAME\`. Use \`env\` to see exported variables.
64
50
  - Conditionals/loops: \`if ...; ...; end\`, \`for x in ...; ...; end\`, \`while ...; ...; end\`, \`switch\` instead of \`case\`.
65
51
  - Chaining: \`a; and b\` / \`a; or b\` (fish 3 also accepts \`&&\` / \`||\`); background a job with \`cmd &\` and wait with \`wait\`.
66
52
  - Command substitution: \`(cmd)\` (fish 3 also accepts \`$(cmd)\`); arithmetic: \`math '1 + 2'\`; string processing: \`string ...\`.
67
53
  - 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.
54
+ - 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.
55
+ - 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.
56
+ - 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
57
  Bash idioms fish REJECTS with exit 127 — translate before running:
71
58
  \`VAR=x\` / \`export VAR=x\` → \`set VAR x\` / \`set -gx VAR x\`
72
59
  \`for i in ...; do ...; done\` → \`for i in ...; ...; end\`
@@ -74,6 +61,14 @@ Bash idioms fish REJECTS with exit 127 — translate before running:
74
61
  \`cmd <<'EOF' ... EOF\` (heredoc) → \`printf '...' | cmd\` (fish has no heredoc)
75
62
  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
63
 
64
+ 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.'
65
+
66
+ const TOOL_DESCRIPTION = `${BASE_TOOL_DESCRIPTION}\n${ESCALATION_TOOL_DESCRIPTION}`
67
+
68
+ function fishDescription(escalationModes) {
69
+ return escalationModes.length === 0 ? BASE_TOOL_DESCRIPTION : TOOL_DESCRIPTION
70
+ }
71
+
77
72
  /**
78
73
  * Targeted fish-syntax correction for a failed run. Models default to bash
79
74
  * idioms; when fish rejects a command the stderr carries a diagnostic like
@@ -120,11 +115,15 @@ function fishSyntaxHint(stderrText) {
120
115
  * Shape one finished run into the text the model sees: stdout, then a marked
121
116
  * stderr section, then sandbox/timeout/signal/exit markers — the same marker
122
117
  * contract the harness bash tool uses, so the model reads both shells
123
- * identically.
118
+ * identically. A denied run under an escalation-advertising composition
119
+ * additionally carries the shared same-turn escalation hint.
124
120
  * @param {object} result - the settled foreground result from the executor.
121
+ * @param {readonly string[]} [escalationModes] - the escalation targets this
122
+ * composition advertises; non-empty appends the same-turn escalation hint
123
+ * after a denial marker (default `[]`: no hint).
125
124
  * @returns {string} the model-facing text.
126
125
  */
127
- function renderResult(result) {
126
+ function renderResult(result, escalationModes = []) {
128
127
  const streamText = (stream) => {
129
128
  if (!stream.truncated) return stream.text
130
129
  return `${stream.text}\n[output truncated${stream.spillPath !== undefined
@@ -152,7 +151,8 @@ function renderResult(result) {
152
151
 
153
152
  const markers = []
154
153
  if (result.sandbox?.denied) {
155
- markers.push(`[sandbox: file access denied under ${result.sandbox.mode} mode]`)
154
+ markers.push(sandboxDenialMarker(result.sandbox.mode))
155
+ if (escalationModes.length > 0) markers.push(escalationHintMarker('command'))
156
156
  }
157
157
  if (result.timedOut) markers.push(`[timed out after ${result.timeoutMs ?? 0}ms]`)
158
158
  if (result.signal !== null) {
@@ -165,6 +165,134 @@ function renderResult(result) {
165
165
  return body + markers.join('\n')
166
166
  }
167
167
 
168
+ /**
169
+ * Shape one background-process read into the `job_output` delta the model
170
+ * sees: the incremental delta, plus the lossy-read notice (with full-stream
171
+ * spill paths) when in-memory truncation dropped unread bytes, and the
172
+ * settled sandbox facts (runner failure / denial + escalation hint) when the
173
+ * process was confined. Empty-delta rendering (`(no new output)`) is the
174
+ * generic job controller's job.
175
+ * @param {object} read - one incremental read from the process handle.
176
+ * @param {object} [sandbox] - settled sandbox facts, when this was a confined process.
177
+ * @param {readonly string[]} [escalationModes] - escalation targets advertised by this composition.
178
+ * @returns {string} the delta text with any loss or sandbox notice appended.
179
+ */
180
+ function renderFishProcessRead(read, sandbox, escalationModes = []) {
181
+ const notices = []
182
+ if (read.lossy) {
183
+ const paths = [read.stdoutSpillPath, read.stderrSpillPath].filter((path) => path !== undefined)
184
+ notices.push(`[some output was dropped from memory; full output: ${paths.length > 0 ? paths.join(', ') : '(unavailable)'}]`)
185
+ }
186
+ if (sandbox?.runnerFailed) {
187
+ notices.push(`[sandbox: the sandbox runner itself failed under ${sandbox.mode} mode — the command did not run; this is a sandbox problem, not a command failure]`)
188
+ } else if (sandbox?.denied) {
189
+ notices.push(sandboxDenialMarker(sandbox.mode))
190
+ if (escalationModes.length > 0) notices.push(escalationHintMarker('command'))
191
+ }
192
+ if (notices.length === 0) return read.delta
193
+ return `${read.delta}${read.delta.length > 0 && !read.delta.endsWith('\n') ? '\n' : ''}${notices.join('\n')}`
194
+ }
195
+
196
+ /**
197
+ * Map a settled background process onto the generic task-outcome vocabulary:
198
+ * `killed` stays `killed` (detail: the signal when one is known), everything
199
+ * else is `completed` with the exit code as detail. A nonzero command exit is
200
+ * reported, not failed, exactly like the foreground rendering.
201
+ * @param {object} proc - the settled process handle.
202
+ * @returns {{ status: 'completed' | 'killed', detail: string }} the outcome for the `ctx.jobs` registration.
203
+ */
204
+ function processOutcome(proc) {
205
+ if (proc.status === 'killed') {
206
+ return {
207
+ status: 'killed',
208
+ detail: proc.signal !== null ? `signal: ${proc.signal}` : 'killed before exit',
209
+ }
210
+ }
211
+ return {
212
+ status: 'completed',
213
+ detail: `exit code: ${proc.exitCode ?? 0}`,
214
+ }
215
+ }
216
+
217
+ /** Detach the executor DTO from readonly Service Definition types into plain JSON data. */
218
+ function canonicalFishResult(result) {
219
+ const stream = (s) => ({
220
+ text: s.text,
221
+ truncated: s.truncated,
222
+ ...s.spillPath !== undefined ? { spillPath: s.spillPath } : {},
223
+ })
224
+ return {
225
+ exitCode: result.exitCode,
226
+ signal: result.signal,
227
+ timedOut: result.timedOut,
228
+ aborted: result.aborted,
229
+ timeoutMs: result.timeoutMs,
230
+ stdout: stream(result.stdout),
231
+ stderr: stream(result.stderr),
232
+ ...result.sandbox !== undefined ? { sandbox: {
233
+ mode: result.sandbox.mode,
234
+ denied: result.sandbox.denied,
235
+ ...result.sandbox.enforcement !== undefined ? { enforcement: result.sandbox.enforcement } : {},
236
+ ...result.sandbox.runnerFailed !== undefined ? { runnerFailed: result.sandbox.runnerFailed } : {},
237
+ } } : {},
238
+ }
239
+ }
240
+
241
+ /**
242
+ * Present the PENDING state of one call in a UI: a foreground command is a
243
+ * terminal card (cwd-headed); a background call is a generic execute card.
244
+ * Total over malformed replay args: a non-object or a missing/non-string
245
+ * command/description returns `undefined` so the UI falls back to its
246
+ * generic presentation instead of throwing or minting an invalid title.
247
+ */
248
+ function presentFishCall(args) {
249
+ if (typeof args !== 'object' || args === null || Array.isArray(args)) return undefined
250
+ if (typeof args.command !== 'string' || typeof args.description !== 'string') return undefined
251
+ if (args.run_in_background === true) {
252
+ return {
253
+ card: 'generic',
254
+ title: args.command,
255
+ kind: 'execute',
256
+ rawInput: args.command,
257
+ content: [{ type: 'text', text: args.description }],
258
+ }
259
+ }
260
+ return {
261
+ card: 'terminal',
262
+ title: args.command,
263
+ description: args.description,
264
+ ...typeof args.workdir === 'string' ? { cwd: args.workdir } : {},
265
+ }
266
+ }
267
+
268
+ /**
269
+ * Present completed foreground output as a terminal card with an exit-status
270
+ * pill (recovered via `parseExitStatus`); background acknowledgements and
271
+ * execution errors use generic fenced output without an exit-status pill.
272
+ * Total over malformed replay args/results: invalid inputs return
273
+ * `undefined` (generic UI fallback) instead of throwing.
274
+ */
275
+ function presentFishResult(args, result) {
276
+ if (typeof args !== 'object' || args === null || Array.isArray(args)) return undefined
277
+ if (typeof args.command !== 'string') return undefined
278
+ if (typeof result !== 'object' || result === null || Array.isArray(result) || !Array.isArray(result.content)) return undefined
279
+ const block = result.content.length === 1 ? result.content[0] : undefined
280
+ if (block === undefined || block.type !== 'text') return undefined
281
+ const raw = block.text
282
+ if (args.run_in_background === true || result.isError) {
283
+ return {
284
+ card: 'generic',
285
+ content: [{ type: 'text', text: `\`\`\`console\n${raw.replace(/\n+$/, '')}\n\`\`\`` }],
286
+ }
287
+ }
288
+ const { body, ...exit } = parseExitStatus(raw)
289
+ return {
290
+ card: 'terminal',
291
+ output: body,
292
+ ...exit,
293
+ }
294
+ }
295
+
168
296
  /** Object-root JSON Schema for the tool's parameters (raw-registered). */
169
297
  const PARAMETERS = {
170
298
  type: 'object',
@@ -193,50 +321,98 @@ const PARAMETERS = {
193
321
  },
194
322
  }
195
323
 
196
- const OUTPUT = {
197
- schema: {
198
- type: 'object',
199
- additionalProperties: false,
200
- required: ['kind', 'exitCode', 'signal', 'timedOut', 'aborted', 'timeoutMs', 'stdout', 'stderr'],
324
+ /**
325
+ * The tool's parameters for one composition: the base parameters plus
326
+ * `run_in_background`, and the escalation fields only when the mounted
327
+ * executor advertises confinement (an unadvertised field is never parsed).
328
+ */
329
+ function fishParameters(escalationModes) {
330
+ return {
331
+ ...PARAMETERS,
201
332
  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
- },
333
+ ...PARAMETERS.properties,
334
+ run_in_background: {
335
+ type: 'boolean',
336
+ description: 'Run in the background and return a job id immediately '
337
+ + '(collect with job_output, stop with job_kill). No timeout applies.',
217
338
  },
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' },
339
+ ...escalationModes.length > 0 ? {
340
+ sandbox_permissions: {
341
+ type: 'string',
342
+ enum: [...escalationModes],
343
+ description: 'The wider sandbox mode this command needs. Only valid as a one-shot '
344
+ + 'retry of a command the sandbox just denied; requires justification and user approval.',
345
+ },
346
+ justification: {
347
+ type: 'string',
348
+ description: 'Required with sandbox_permissions: one sentence for the user explaining '
349
+ + 'why this exact command needs the wider access.',
226
350
  },
351
+ } : {},
352
+ },
353
+ }
354
+ }
355
+
356
+ /** Canonical output declaration: a background acknowledgment or a foreground
357
+ * run. The rc.1 ToolRuntime dialect forbids `type`/`additionalProperties`
358
+ * beside a `oneOf` root (exact-one branches), so the union is expressed as a
359
+ * bare `oneOf` with each branch carrying its own object shape. */
360
+ const OUTPUT_SCHEMA = {
361
+ oneOf: [
362
+ {
363
+ type: 'object',
364
+ additionalProperties: false,
365
+ properties: {
366
+ kind: { type: 'string', const: 'background' },
367
+ jobId: { type: 'string' },
227
368
  },
228
- sandbox: {
229
- type: 'object',
230
- additionalProperties: false,
231
- required: ['mode', 'denied'],
232
- properties: {
233
- mode: { type: 'string' },
234
- denied: { type: 'boolean' },
369
+ required: ['kind', 'jobId'],
370
+ },
371
+ {
372
+ type: 'object',
373
+ additionalProperties: false,
374
+ properties: {
375
+ kind: { type: 'string', const: 'foreground' },
376
+ exitCode: { oneOf: [{ type: 'integer' }, { type: 'null' }] },
377
+ signal: { oneOf: [{ type: 'string' }, { type: 'null' }] },
378
+ timedOut: { type: 'boolean' },
379
+ aborted: { type: 'boolean' },
380
+ timeoutMs: { type: 'number' },
381
+ stdout: {
382
+ type: 'object',
383
+ additionalProperties: false,
384
+ properties: {
385
+ text: { type: 'string' },
386
+ truncated: { type: 'boolean' },
387
+ spillPath: { type: 'string' },
388
+ },
389
+ required: ['text', 'truncated'],
390
+ },
391
+ stderr: {
392
+ type: 'object',
393
+ additionalProperties: false,
394
+ properties: {
395
+ text: { type: 'string' },
396
+ truncated: { type: 'boolean' },
397
+ spillPath: { type: 'string' },
398
+ },
399
+ required: ['text', 'truncated'],
400
+ },
401
+ sandbox: {
402
+ type: 'object',
403
+ additionalProperties: false,
404
+ properties: {
405
+ mode: { type: 'string' },
406
+ denied: { type: 'boolean' },
407
+ enforcement: { type: 'string' },
408
+ runnerFailed: { type: 'boolean' },
409
+ },
410
+ required: ['mode', 'denied'],
235
411
  },
236
412
  },
413
+ required: ['kind', 'exitCode', 'signal', 'timedOut', 'aborted', 'timeoutMs', 'stdout', 'stderr'],
237
414
  },
238
- },
239
- render: (_args, value) => [{ type: 'text', text: renderResult(value) }],
415
+ ],
240
416
  }
241
417
 
242
418
  /** Resolve an explicit workdir: the sandbox policy's workspace root wins as
@@ -251,20 +427,19 @@ function resolveWorkdir(modelWorkdir, exec, policyWorkspaceRoot) {
251
427
  }
252
428
 
253
429
  /**
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.
430
+ * Cordis plugin entry: register the `fish` tool once the tool registry, the
431
+ * `fish` executor, and the system-prompt registry are up. The per-agent fish
432
+ * policy lives in the sibling `policy.js` module (mounted by the bundle
433
+ * patch).
258
434
  * @param {import('@deepseek-ai/cordis').Context} ctx - the harness context.
259
435
  */
260
436
  export function apply(ctx) {
261
- installPreset()
262
-
263
437
  // Mirror dsh-tool-bash's policy path: when the mounted executor confines,
264
438
  // resolve each call's standing policy against the calling session so
265
439
  // /permission switches and the session workspace are honored, and fail loud
266
440
  // at load when the policy owner is missing.
267
441
  const defaultMode = ctx.shell.sandboxMode
442
+ const escalationModes = defaultMode === undefined ? [] : ESCALATION_TARGETS
268
443
  const sandboxPolicy = defaultMode === undefined ? undefined : ctx.get('sandboxPolicy')
269
444
  if (defaultMode !== undefined && sandboxPolicy === undefined) {
270
445
  throw new Error('tool-fish: the mounted fish executor confines but ctx.sandboxPolicy is missing')
@@ -274,6 +449,37 @@ export function apply(ctx) {
274
449
  return sandboxPolicy.resolve(exec.agent === undefined ? {} : { session: exec.agent.session })
275
450
  }
276
451
 
452
+ /**
453
+ * Resolve a sandbox-escalation request through `ctx.approval` BEFORE
454
+ * anything executes, delegating the shared fail-closed sequence (strict
455
+ * widening, channel resolution, outcome mapping) to approveEscalation —
456
+ * the same composition guard and ingredients as the official bash tool.
457
+ */
458
+ const approveFishEscalation = (mode, justification, exec, standingPolicy) => {
459
+ if (escalationModes.length === 0) {
460
+ throw new Error('sandbox_permissions is not available in this composition (no sandboxing executor to escalate)')
461
+ }
462
+ const effectiveMode = standingPolicy.mode
463
+ return approveEscalation({
464
+ requestedMode: mode,
465
+ justification,
466
+ effectiveMode,
467
+ subject: 'command',
468
+ }, {
469
+ approver: ctx.get('approval'),
470
+ agent: exec.agent,
471
+ callId: exec.callId,
472
+ toolName: 'fish',
473
+ signal: exec.signal,
474
+ })
475
+ }
476
+
477
+ ctx.systemPrompt.section({
478
+ name: 'tool:fish',
479
+ order: ctx.systemPrompt.getSectionOrder('TOOL_BASH'),
480
+ text: 'Check the [exit code: N] marker on every fish result; investigate failures before moving on.',
481
+ })
482
+
277
483
  async function execute(args, exec) {
278
484
  if (typeof args.command !== 'string' || args.command.trim().length === 0) {
279
485
  throw new Error('invalid command: expected a non-empty string')
@@ -284,54 +490,84 @@ export function apply(ctx) {
284
490
  if (args.timeoutMs !== undefined && (!Number.isFinite(args.timeoutMs) || args.timeoutMs <= 0)) {
285
491
  throw new Error(`invalid timeoutMs: expected a positive number, got ${JSON.stringify(args.timeoutMs)}`)
286
492
  }
493
+ validateEscalationArgs(args.sandbox_permissions, args.justification)
287
494
  const standingPolicy = resolveSandboxPolicy(exec)
495
+ const approvedMode = args.sandbox_permissions !== undefined && args.justification !== undefined
496
+ ? await approveFishEscalation(args.sandbox_permissions, args.justification, exec, standingPolicy)
497
+ : undefined
498
+ const policy = approvedMode === undefined ? standingPolicy : {
499
+ ...standingPolicy,
500
+ mode: approvedMode,
501
+ }
288
502
  const workdir = resolveWorkdir(args.workdir, exec, standingPolicy?.workspaceRoot)
289
503
  const request = {
290
504
  command: args.command,
291
505
  ...workdir !== undefined ? { workdir } : {},
292
506
  ...args.timeoutMs !== undefined ? { timeoutMs: args.timeoutMs } : {},
293
507
  dshEnv: ctx.shellEnv.collect(exec),
294
- ...standingPolicy !== undefined ? { sandboxPolicy: standingPolicy } : {},
508
+ ...policy !== undefined ? { sandboxPolicy: policy } : {},
509
+ }
510
+ if (args.run_in_background === true) {
511
+ const jobs = ctx.get('jobs')
512
+ if (jobs === undefined) {
513
+ throw new Error('background jobs unavailable: load @deepseek-ai/dsh-jobs and @deepseek-ai/dsh-tool-jobs')
514
+ }
515
+ if (exec.signal.aborted) {
516
+ const error = new HarnessError('tool call aborted', TOOL_ABORTED)
517
+ error.name = 'AbortError'
518
+ throw error
519
+ }
520
+ return {
521
+ kind: 'background',
522
+ jobId: jobs.start({
523
+ kind: 'fish',
524
+ label: args.command,
525
+ ...exec.agent !== undefined ? { owner: exec.agent } : {},
526
+ run: () => {
527
+ const proc = ctx.shell.start(ctx.shell.resolve(request))
528
+ return {
529
+ cancel: () => void proc.kill(),
530
+ done: proc.done.then(() => processOutcome(proc)),
531
+ readOutput: () => renderFishProcessRead(proc.readOutput(), proc.sandbox, escalationModes),
532
+ }
533
+ },
534
+ }),
535
+ }
295
536
  }
296
537
  const result = await ctx.shell.run(ctx.shell.resolve({
297
538
  ...request,
298
539
  signal: exec?.signal,
299
540
  }))
300
541
  if (result.aborted) {
301
- const error = new Error('tool call aborted')
542
+ const error = new HarnessError('tool call aborted', TOOL_ABORTED)
302
543
  error.name = 'AbortError'
303
544
  throw error
304
545
  }
305
- const stream = (s) => ({
306
- text: s.text,
307
- truncated: s.truncated,
308
- ...s.spillPath !== undefined ? { spillPath: s.spillPath } : {},
309
- })
310
546
  return {
311
547
  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
- : {},
548
+ ...canonicalFishResult(result),
322
549
  }
323
550
  }
324
551
 
325
552
  ctx.tools.register({
326
553
  name: 'fish',
327
- description: TOOL_DESCRIPTION,
328
- parameters: PARAMETERS,
329
- output: OUTPUT,
554
+ description: fishDescription(escalationModes),
555
+ parameters: fishParameters(escalationModes),
556
+ output: {
557
+ schema: OUTPUT_SCHEMA,
558
+ render: (_args, value) => [{
559
+ type: 'text',
560
+ text: value.kind === 'background' ? `started background job ${value.jobId}` : renderResult(value, escalationModes),
561
+ }],
562
+ },
330
563
  execute,
564
+ presentCall: presentFishCall,
565
+ presentResult: presentFishResult,
331
566
  })
332
567
  }
333
568
 
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 }
569
+ // Deterministic test surface: the tool description, the result renderers, and
570
+ // the UI presenters are pure, so unit tests can assert the bash→fish
571
+ // guidance, the syntax-hint injection, and the escalation-aware rendering
572
+ // without booting a harness or running a live shell.
573
+ export { FISH_SYNTAX_HINTS, TOOL_DESCRIPTION, fishDescription, fishSyntaxHint, presentFishCall, presentFishResult, renderResult }