@banbolee/dsh-fish-shell 0.5.0 → 0.7.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
@@ -11,8 +11,9 @@
11
11
  *
12
12
  * The functional surface mirrors the official `@deepseek-ai/dsh-tool-bash`
13
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
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
16
17
  * `ctx.approval` (strictly-wider modes only, fail-closed), canonical
17
18
  * foreground results carrying the complete sandbox facts
18
19
  * (`enforcement`/`runnerFailed`), terminal/generic UI presentation, and the
@@ -23,6 +24,8 @@
23
24
  * @module @banbolee/dsh-fish-shell/tool
24
25
  */
25
26
 
27
+ /// <reference path="./job-kind.d.ts" />
28
+
26
29
  import { isAbsolute, resolve } from 'node:path'
27
30
  import { TOOL_ABORTED } from '@deepseek-ai/dsh-tools'
28
31
  import { HarnessError } from '@deepseek-ai/dsh-llm'
@@ -35,6 +38,62 @@ import {
35
38
  } from '@deepseek-ai/dsh-sandbox'
36
39
  import { DSH_ENV_PREFIX, parseExitStatus } from '@deepseek-ai/dsh-shell'
37
40
 
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
+ */
48
+
49
+ /**
50
+ * One captured stream with its truncation facts.
51
+ * @typedef {object} FishOutputStream
52
+ * @property {string} text
53
+ * @property {boolean} truncated
54
+ * @property {string} [spillPath]
55
+ */
56
+
57
+ /**
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]
65
+ */
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
+
38
97
  export const name = 'fish-tool'
39
98
  export const inject = ['tools', 'shell', 'systemPrompt', 'shellEnv']
40
99
 
@@ -65,6 +124,12 @@ const ESCALATION_TOOL_DESCRIPTION = 'Attempting a command the sandbox may deny i
65
124
 
66
125
  const TOOL_DESCRIPTION = `${BASE_TOOL_DESCRIPTION}\n${ESCALATION_TOOL_DESCRIPTION}`
67
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
+ */
68
133
  function fishDescription(escalationModes) {
69
134
  return escalationModes.length === 0 ? BASE_TOOL_DESCRIPTION : TOOL_DESCRIPTION
70
135
  }
@@ -103,7 +168,11 @@ const FISH_SYNTAX_HINTS = [
103
168
  },
104
169
  ]
105
170
 
106
- /** 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
+ */
107
176
  function fishSyntaxHint(stderrText) {
108
177
  for (const { pattern, hint } of FISH_SYNTAX_HINTS) {
109
178
  if (pattern.test(stderrText)) return hint
@@ -117,13 +186,17 @@ function fishSyntaxHint(stderrText) {
117
186
  * contract the harness bash tool uses, so the model reads both shells
118
187
  * identically. A denied run under an escalation-advertising composition
119
188
  * additionally carries the shared same-turn escalation hint.
120
- * @param {object} result - the settled foreground result from the executor.
189
+ * @param {FishForegroundValue} result - the canonical foreground value from the executor.
121
190
  * @param {readonly string[]} [escalationModes] - the escalation targets this
122
191
  * composition advertises; non-empty appends the same-turn escalation hint
123
192
  * after a denial marker (default `[]`: no hint).
124
193
  * @returns {string} the model-facing text.
125
194
  */
126
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
+ */
127
200
  const streamText = (stream) => {
128
201
  if (!stream.truncated) return stream.text
129
202
  return `${stream.text}\n[output truncated${stream.spillPath !== undefined
@@ -166,56 +239,128 @@ function renderResult(result, escalationModes = []) {
166
239
  }
167
240
 
168
241
  /**
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.
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.
179
250
  */
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
- }
251
+ function sandboxNotes(sandbox, escalationModes) {
186
252
  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'))
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
191
259
  }
192
- if (notices.length === 0) return read.delta
193
- return `${read.delta}${read.delta.length > 0 && !read.delta.endsWith('\n') ? '\n' : ''}${notices.join('\n')}`
260
+ return []
194
261
  }
195
262
 
196
263
  /**
197
264
  * Map a settled background process onto the generic task-outcome vocabulary:
198
265
  * `killed` stays `killed` (detail: the signal when one is known), everything
199
266
  * 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.
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.
202
272
  * @returns {{ status: 'completed' | 'killed', detail: string }} the outcome for the `ctx.jobs` registration.
203
273
  */
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
- }
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(' ')}`,
210
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
211
329
  return {
212
- status: 'completed',
213
- detail: `exit code: ${proc.exitCode ?? 0}`,
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
+ })(),
214
351
  }
215
352
  }
216
353
 
217
- /** Detach the executor DTO from readonly Service Definition types into plain JSON data. */
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
+ */
218
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
+ */
219
364
  const stream = (s) => ({
220
365
  text: s.text,
221
366
  truncated: s.truncated,
@@ -244,6 +389,8 @@ function canonicalFishResult(result) {
244
389
  * Total over malformed replay args: a non-object or a missing/non-string
245
390
  * command/description returns `undefined` so the UI falls back to its
246
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.
247
394
  */
248
395
  function presentFishCall(args) {
249
396
  if (typeof args !== 'object' || args === null || Array.isArray(args)) return undefined
@@ -271,6 +418,9 @@ function presentFishCall(args) {
271
418
  * execution errors use generic fenced output without an exit-status pill.
272
419
  * Total over malformed replay args/results: invalid inputs return
273
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.
274
424
  */
275
425
  function presentFishResult(args, result) {
276
426
  if (typeof args !== 'object' || args === null || Array.isArray(args)) return undefined
@@ -325,6 +475,7 @@ const PARAMETERS = {
325
475
  * The tool's parameters for one composition: the base parameters plus
326
476
  * `run_in_background`, and the escalation fields only when the mounted
327
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.
328
479
  */
329
480
  function fishParameters(escalationModes) {
330
481
  return {
@@ -356,7 +507,8 @@ function fishParameters(escalationModes) {
356
507
  /** Canonical output declaration: a background acknowledgment or a foreground
357
508
  * run. The rc.1 ToolRuntime dialect forbids `type`/`additionalProperties`
358
509
  * 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. */
510
+ * bare `oneOf` with each branch carrying its own object shape.
511
+ * @type {import('@deepseek-ai/dsh-tools').JsonSchemaNode} */
360
512
  const OUTPUT_SCHEMA = {
361
513
  oneOf: [
362
514
  {
@@ -415,8 +567,14 @@ const OUTPUT_SCHEMA = {
415
567
  ],
416
568
  }
417
569
 
418
- /** Resolve an explicit workdir: the sandbox policy's workspace root wins as
419
- * 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
+ */
420
578
  function resolveWorkdir(modelWorkdir, exec, policyWorkspaceRoot) {
421
579
  const sessionCwd = policyWorkspaceRoot ?? exec?.agent?.session?.header?.cwd
422
580
  if (modelWorkdir === undefined) return sessionCwd
@@ -444,6 +602,10 @@ export function apply(ctx) {
444
602
  if (defaultMode !== undefined && sandboxPolicy === undefined) {
445
603
  throw new Error('tool-fish: the mounted fish executor confines but ctx.sandboxPolicy is missing')
446
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
+ */
447
609
  const resolveSandboxPolicy = (exec) => {
448
610
  if (sandboxPolicy === undefined) return undefined
449
611
  return sandboxPolicy.resolve(exec.agent === undefined ? {} : { session: exec.agent.session })
@@ -454,6 +616,11 @@ export function apply(ctx) {
454
616
  * anything executes, delegating the shared fail-closed sequence (strict
455
617
  * widening, channel resolution, outcome mapping) to approveEscalation —
456
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.
457
624
  */
458
625
  const approveFishEscalation = (mode, justification, exec, standingPolicy) => {
459
626
  if (escalationModes.length === 0) {
@@ -480,6 +647,13 @@ export function apply(ctx) {
480
647
  text: 'Check the [exit code: N] marker on every fish result; investigate failures before moving on.',
481
648
  })
482
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
+ */
483
657
  async function execute(args, exec) {
484
658
  if (typeof args.command !== 'string' || args.command.trim().length === 0) {
485
659
  throw new Error('invalid command: expected a non-empty string')
@@ -493,12 +667,17 @@ export function apply(ctx) {
493
667
  validateEscalationArgs(args.sandbox_permissions, args.justification)
494
668
  const standingPolicy = resolveSandboxPolicy(exec)
495
669
  const approvedMode = args.sandbox_permissions !== undefined && args.justification !== undefined
496
- ? await approveFishEscalation(args.sandbox_permissions, args.justification, exec, standingPolicy)
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))
497
673
  : undefined
498
- const policy = approvedMode === undefined ? standingPolicy : {
499
- ...standingPolicy,
500
- mode: approvedMode,
501
- }
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
502
681
  const workdir = resolveWorkdir(args.workdir, exec, standingPolicy?.workspaceRoot)
503
682
  const request = {
504
683
  command: args.command,
@@ -517,27 +696,39 @@ export function apply(ctx) {
517
696
  error.name = 'AbortError'
518
697
  throw error
519
698
  }
699
+ /** @type {import('@deepseek-ai/dsh-shell').ShellExecution | undefined} */
700
+ let proc
520
701
  return {
521
702
  kind: 'background',
522
703
  jobId: jobs.start({
523
704
  kind: 'fish',
524
705
  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
- },
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)),
534
725
  }),
535
726
  }
536
727
  }
537
- const result = await ctx.shell.run(ctx.shell.resolve({
728
+ const result = await (await ctx.shell.execute(ctx.shell.resolve({
538
729
  ...request,
539
730
  signal: exec?.signal,
540
- }))
731
+ }))).result()
541
732
  if (result.aborted) {
542
733
  const error = new HarnessError('tool call aborted', TOOL_ABORTED)
543
734
  error.name = 'AbortError'
@@ -555,10 +746,17 @@ export function apply(ctx) {
555
746
  parameters: fishParameters(escalationModes),
556
747
  output: {
557
748
  schema: OUTPUT_SCHEMA,
558
- render: (_args, value) => [{
559
- type: 'text',
560
- text: value.kind === 'background' ? `started background job ${value.jobId}` : renderResult(value, escalationModes),
561
- }],
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
+ },
562
760
  },
563
761
  execute,
564
762
  presentCall: presentFishCall,