@banbolee/dsh-fish-shell 0.4.1 → 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/persistent.js ADDED
@@ -0,0 +1,639 @@
1
+ /**
2
+ * Model-facing persistent `fish` tool over the owner-scoped PTY seam, for
3
+ * agent presets whose standing bash is the PERSISTENT form (`minimal`).
4
+ *
5
+ * This module is a fish translation of the official
6
+ * `@deepseek-ai/dsh-tool-bash-persistent` pattern: a per-owner cached PTY
7
+ * session, serialized command execution, marker-wrapped commands, scrollback
8
+ * reads, deadline/timeout reset, and the shell-reset notice. Differences
9
+ * from the official bash version are all fish dialect:
10
+ *
11
+ * - `quoteForFish` uses fish single-quote escaping (`\\` and `\'` are the
12
+ * only escapes inside fish single quotes) — fish 4.0.0 has NO bash-style
13
+ * `$'...'` ANSI-C quoting, so the official `$'...'` wrapper cannot be
14
+ * reused (verified against real fish 4.0.0).
15
+ * - `wrapCommand` feeds the command to `eval` as ONE quoted string
16
+ * argument: fish 4's `eval` takes a command string (it no longer has an
17
+ * option terminator, so `eval --` fails).
18
+ * - The status is captured with `set __dsh_status $status` (fish's
19
+ * `$status`, not bash's `$?`).
20
+ *
21
+ * PTY self-management: the official persistent bash tool talks to the
22
+ * `terminals` registry (`ctx.terminals.spawn/startSend/read/list/kill`),
23
+ * which only exists inside the `minimal` preset's entry-local realm — it is
24
+ * invisible at the host/agent plane, so an agent-scope tool cannot resolve
25
+ * it. This module therefore manages its own sessions WITHOUT the registry,
26
+ * mirroring the official semantics exactly:
27
+ *
28
+ * - One `FishTerminalBackend` instance (`./terminal-fish.js`) is created
29
+ * per registration and shared by all owners, exactly like one registry
30
+ * backend is shared by all owners.
31
+ * - Spawn is `backend.spawn({ owner, sessionId: <uuid>, cwd,
32
+ * signal })`; the returned session replaces the registry snapshot id
33
+ * everywhere (`session.startSend(...).done` instead of
34
+ * `ctx.terminals.startSend(owner, id, ...)`, `session.read(...)`,
35
+ * `session.status()`).
36
+ * - The registry's `kill(owner, id, reason)` is exactly
37
+ * `session.close(reason)` (verified against
38
+ * `@deepseek-ai/dsh-terminal`'s registry), so reset/close map to
39
+ * `session.close`.
40
+ * - The backend needs `sandboxPolicy` / `subprocess` resolvable from the
41
+ * policy plugin's ctx (true in dsh-base/TUI host planes and in the
42
+ * composition test profiles) plus the sandbox provider via `ctx.get
43
+ * ('sandbox')` for confined modes.
44
+ * - The sandbox-mode fence copy in `terminal-fish.js` is installed with an
45
+ * injectable owner-activity check (this module's own owner→session
46
+ * tracking) because there is no registry to ask; the open-during-change
47
+ * refusal semantics are unchanged.
48
+ *
49
+ * The module deliberately exports NO Cordis `apply`: it is a library invoked
50
+ * by `policy.js` (`registerPersistentFish(ctx, agentCtx)`) when the policy
51
+ * detects the persistent bash form at the agent boundary, registering the
52
+ * persistent `fish` tool at the agent scope so it shadows the host-global
53
+ * one-shot `fish` tool.
54
+ *
55
+ * @module @banbolee/dsh-fish-shell/persistent
56
+ */
57
+
58
+ import { randomUUID } from 'node:crypto'
59
+ import { defineTool } from '@deepseek-ai/dsh-tools'
60
+ import { FishTerminalBackend, ensureSandboxModeFence, resolveConfig as resolveTerminalConfig } from './terminal-fish.js'
61
+
62
+ const TRUNCATED_MESSAGE = '<response clipped><NOTE>To save on context only part of this file has been shown to you. You should retry this tool after you have searched inside the file with `grep -n` in order to find the line numbers of what you are looking for.</NOTE>'
63
+ const LOST_PREFIX_MESSAGE = '<response clipped><NOTE>The beginning of this command output was dropped by the terminal scrollback limit. The following text is the earliest retained output.</NOTE>\n'
64
+ const SHELL_RESET_MESSAGE = 'The persistent fish shell was reset; the next fish call starts from the workspace with a fresh current directory and environment.'
65
+ const TIMEOUT_STATUS_MARKER = '[Command timed out]'
66
+ export const TIMEOUT_CODE = 'PERSISTENT_FISH_TIMEOUT'
67
+ /** Largest delay Node schedules without clamping it to one millisecond
68
+ * (mirrors `@deepseek-ai/dsh-timeout`'s `MAX_TIMER_DELAY_MS`). */
69
+ export const MAX_TIMER_DELAY_MS = 2147483647
70
+ const MODULE_TAG = '@banbolee/dsh-fish-shell'
71
+ const SCROLLBACK_PAGE_LINES = 1e3
72
+ const POLL_INTERVAL_MS = 25
73
+ const DEFAULT_DESCRIPTION = 'Run commands in a persistent fish shell. State, including the current directory, exported variables, and defined functions, persists across calls for this agent. Use fish syntax: set x 5; echo $x; $status for the last exit code; function name; ...; end for functions.'
74
+
75
+ /**
76
+ * Abort reason carrying this module's timeout code and elapsed deadline.
77
+ * Mirrors `@deepseek-ai/dsh-timeout`'s `TimeoutReason` (the module is NOT a
78
+ * peer dependency here: the minimal deadline implementation below is
79
+ * self-contained).
80
+ */
81
+ export class TimeoutReason extends Error {
82
+ code
83
+ timeoutMs
84
+ name = 'TimeoutReason'
85
+ constructor(code, timeoutMs) {
86
+ super(`${code} after ${timeoutMs}ms`)
87
+ this.code = code
88
+ this.timeoutMs = timeoutMs
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Fuse upstream cancellation with an identifiable timeout, without pulling
94
+ * in `@deepseek-ai/dsh-timeout`. `timeoutMs <= 0` arms no timer.
95
+ * @param upstream - caller cancellation, if any.
96
+ * @param timeoutMs - deadline in milliseconds; `<= 0` means "no timeout".
97
+ * @param code - timeout code stamped onto the {@link TimeoutReason}.
98
+ * @returns the fused signal plus a disposer that clears the armed timer.
99
+ */
100
+ export function deadline(upstream, timeoutMs, code) {
101
+ if (timeoutMs <= 0) {
102
+ return { signal: upstream ?? new AbortController().signal, dispose() {} }
103
+ }
104
+ // Node clamps delays above 2^31-1 to ~1ms with a TimeoutOverflowWarning;
105
+ // reject such deadlines instead of aborting immediately.
106
+ if (!Number.isFinite(timeoutMs) || timeoutMs > MAX_TIMER_DELAY_MS) {
107
+ throw new Error(`deadline timeoutMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`)
108
+ }
109
+ const timer = new AbortController()
110
+ const id = setTimeout(() => {
111
+ timer.abort(new TimeoutReason(code, timeoutMs))
112
+ }, timeoutMs)
113
+ return {
114
+ signal: upstream !== undefined ? AbortSignal.any([upstream, timer.signal]) : timer.signal,
115
+ dispose() {
116
+ clearTimeout(id)
117
+ },
118
+ }
119
+ }
120
+
121
+ /**
122
+ * Recover a timeout reason from a reason-bearing carrier.
123
+ * @param x - an AbortSignal or any `{ reason }` carrier.
124
+ * @param code - only a {@link TimeoutReason} with this exact code matches.
125
+ * @returns the matching reason, else `undefined`.
126
+ */
127
+ export function timeoutOf(x, code) {
128
+ const reason = x?.reason
129
+ if (!(reason instanceof TimeoutReason)) return undefined
130
+ return code === undefined || reason.code === code ? reason : undefined
131
+ }
132
+
133
+ export function maybeTruncate(content, maxOutputChars, incomplete = false) {
134
+ if (content.length <= maxOutputChars && !incomplete) return content
135
+ return content.length <= maxOutputChars ? content + TRUNCATED_MESSAGE : content.slice(0, maxOutputChars) + TRUNCATED_MESSAGE
136
+ }
137
+
138
+ export function markers() {
139
+ const nonce = randomUUID()
140
+ return {
141
+ start: `__DSH_PERSISTENT_FISH_START_${nonce}__`,
142
+ end: `__DSH_PERSISTENT_FISH_END_${nonce}:`,
143
+ }
144
+ }
145
+
146
+ /**
147
+ * Quote a value as one fish single-quoted token. fish single-quoted strings
148
+ * only escape `\\` and `\'`, so backslashes are doubled first, then quotes —
149
+ * the decode is exact for arbitrary input (verified against fish 4.0.0).
150
+ * @param value - the raw string to embed.
151
+ * @returns a fish single-quoted literal.
152
+ */
153
+ export function quoteForFish(value) {
154
+ return `'${value.replaceAll('\\', '\\\\').replaceAll("'", "\\'")}'`
155
+ }
156
+
157
+ /**
158
+ * Wrap a command so its output and exit status are delimited by unique
159
+ * markers. fish 4's `eval` evaluates one command string (no `--`), and
160
+ * `$status` must be captured before anything else runs.
161
+ * @param command - the raw fish command text.
162
+ * @param marker - the unique start/end marker pair.
163
+ * @returns the wrapped command line submitted to the persistent shell.
164
+ */
165
+ export function wrapCommand(command, marker) {
166
+ return `printf '%s\\n' ${quoteForFish(marker.start)}; eval ${quoteForFish(command)}; set __dsh_status $status; printf '%s%s\\n' ${quoteForFish(marker.end)} "$__dsh_status"`
167
+ }
168
+
169
+ export function trimTrailingNewline(text) {
170
+ return text.replace(/(?:\r?\n)+$/, '')
171
+ }
172
+
173
+ /**
174
+ * Extract the marker-delimited command output from a scrollback snapshot.
175
+ * @param snapshot - the retained scrollback.
176
+ * @param marker - the unique start/end marker pair.
177
+ * @returns the captured text, exit code, and whether the start was lost.
178
+ */
179
+ export function commandOutput(snapshot, marker) {
180
+ const text = snapshot.text
181
+ const end = text.lastIndexOf(marker.end)
182
+ const status = /^(\d+)\r?\n/.exec(text.slice(end + marker.end.length))?.[1]
183
+ if (status === undefined) return undefined
184
+ const startMarker = text.lastIndexOf(marker.start, end)
185
+ const start = startMarker < 0 ? 0 : startMarker + marker.start.length
186
+ return {
187
+ text: trimTrailingNewline(text.slice(start, end).replace(/^\r?\n/, '')),
188
+ incomplete: startMarker < 0,
189
+ exitCode: Number(status),
190
+ }
191
+ }
192
+
193
+ /**
194
+ * Render the not-yet-complete output after a start marker, falling back to
195
+ * the incremental send delta when the scrollback lost the start.
196
+ */
197
+ export function partialOutput(snapshot, marker, fallback, fallbackTruncated = false) {
198
+ const startMarker = snapshot.text.lastIndexOf(marker.start)
199
+ if (startMarker >= 0) {
200
+ return {
201
+ text: trimTrailingNewline(snapshot.text.slice(startMarker + marker.start.length).replace(/^\r?\n/, '')),
202
+ incomplete: false,
203
+ }
204
+ }
205
+ const fallbackStart = fallback.lastIndexOf(marker.start)
206
+ const afterStart = fallbackStart < 0 ? fallback : fallback.slice(fallbackStart + marker.start.length).replace(/^\r?\n/, '')
207
+ const fallbackEnd = afterStart.lastIndexOf(marker.end)
208
+ return {
209
+ text: trimTrailingNewline(fallbackEnd < 0 ? afterStart : afterStart.slice(0, fallbackEnd)),
210
+ incomplete: fallbackTruncated || fallbackStart < 0,
211
+ }
212
+ }
213
+
214
+ export async function pause() {
215
+ await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS))
216
+ }
217
+
218
+ export function nextScrollbackOffset(page, offset) {
219
+ if (page.text.length === 0 || page.lineEnd <= offset) return undefined
220
+ return page.lineEnd
221
+ }
222
+
223
+ export function retainedScrollback(session, latest = session.read({
224
+ offset: 0,
225
+ count: SCROLLBACK_PAGE_LINES,
226
+ })) {
227
+ const pages = latest.text.length === 0 ? [] : [latest.text]
228
+ let offset = latest.lineEnd
229
+ let truncated = latest.truncated
230
+ for (;;) {
231
+ if (offset >= latest.totalLines) break
232
+ const page = session.read({
233
+ offset,
234
+ count: SCROLLBACK_PAGE_LINES,
235
+ })
236
+ truncated ||= page.truncated
237
+ if (page.text.length > 0) pages.unshift(page.text)
238
+ const next = nextScrollbackOffset(page, offset)
239
+ if (next === undefined || next >= page.totalLines) break
240
+ offset = next
241
+ }
242
+ return {
243
+ text: pages.join('\n'),
244
+ truncated,
245
+ }
246
+ }
247
+
248
+ export function renderCaptured(output, maxOutputChars) {
249
+ const rendered = maybeTruncate(output.text, maxOutputChars, output.incomplete)
250
+ return appendStatusMarker(output.incomplete && output.text.length > 0 ? LOST_PREFIX_MESSAGE + rendered : rendered, output.exitCode !== undefined ? `[Command finished with exit code ${output.exitCode}]` : undefined)
251
+ }
252
+
253
+ export function appendStatusMarker(content, marker) {
254
+ if (marker === undefined) return content
255
+ return content.length === 0 ? marker : `${content}\n${marker}`
256
+ }
257
+
258
+ export function renderShellExitStatus(content, exitCode, signal) {
259
+ return appendStatusMarker(content, signal !== null ? `[shell killed by signal: ${signal}]` : exitCode !== null ? `[shell exited: code ${exitCode}]` : '[shell exited]')
260
+ }
261
+
262
+ /**
263
+ * Render the exited-session result, reset the owner's shell, and carry the
264
+ * notice that the next call starts fresh.
265
+ */
266
+ async function respondToSessionExit(session, shells, owner, status, marker, fallback, fallbackTruncated, config) {
267
+ const snapshot = retainedScrollback(session)
268
+ await shells.reset(owner, 'persistent fish shell exited')
269
+ return [renderShellExitStatus(renderCaptured(partialOutput(snapshot, marker, fallback, fallbackTruncated), config.maxOutputChars), status.exitCode, status.signal), SHELL_RESET_MESSAGE].filter((part) => part.length > 0).join('\n')
270
+ }
271
+
272
+ /**
273
+ * Per-owner persistent fish PTY cache with lazy creation and reset, driving
274
+ * one shared `FishTerminalBackend` directly (self-managed: no `terminals`
275
+ * registry, which is invisible at the host/agent plane). The registry's
276
+ * kill is `session.close(reason)`, so close/reset map straight to the
277
+ * session; the sandbox-mode fence gets an injectable owner-activity check
278
+ * backed by this module's own owner→session tracking.
279
+ *
280
+ * Owner lifecycle mirrors the official `@deepseek-ai/dsh-terminal` registry:
281
+ * the owner-scoped cleanup effect is installed BEFORE the first spawn, each
282
+ * creation carries a reservation (AbortController + settlement) merged into
283
+ * the spawn/startup signal, and owner disposal aborts and awaits pending
284
+ * creations before closing live sessions — so a spawn racing an owner
285
+ * disposal cannot outlive its owner or register on a disposed context.
286
+ *
287
+ * A session being closed is tracked in a `closing` set until close actually
288
+ * settles: it still counts as activity (the sandbox-mode fence keeps
289
+ * refusing mode changes while the old PTY is being torn down), a failed
290
+ * close keeps the session retryable instead of losing the handle, and a
291
+ * re-entrant close awaits the in-flight one.
292
+ *
293
+ * @param ctx - the policy plugin's context (services resolved via `ctx.get`).
294
+ * @param config - the resolved persistent configuration.
295
+ * @param backend - the PTY backend (duck-typed: only `spawn(spec)` is read);
296
+ * defaults to a real `FishTerminalBackend` constructed against `ctx`
297
+ * (injectable so tests can fake spawn/close).
298
+ */
299
+ export function persistentShells(ctx, config, backend = new FishTerminalBackend(ctx, resolveTerminalConfig({
300
+ backendType: config.backendType,
301
+ ...(config.shellPath !== undefined ? { shellPath: config.shellPath } : {}),
302
+ ...(config.shellArgs !== undefined ? { shellArgs: config.shellArgs } : {}),
303
+ }), (spec) => ctx.get('subprocess').spawnTerminal(spec))) {
304
+ const pending = new WeakMap()
305
+ const live = new Map()
306
+ const creating = new Set()
307
+ /** Owner → in-flight close promise. A session stays here until its close
308
+ * settles, so it still counts as activity while the PTY is being torn down
309
+ * and a failed close leaves the session retryable (the `live` entry is
310
+ * removed only on success). */
311
+ const closing = new Map()
312
+ /** Owner → Set of unpublished spawn reservations (official registry shape). */
313
+ const pendingSpawns = new Map()
314
+ const ownerCleanupInstalled = new WeakSet()
315
+ const disposedOwners = new WeakSet()
316
+ const lifecycle = new AbortController()
317
+ const hasActivity = (owner) =>
318
+ pending.get(owner) !== undefined
319
+ || (pendingSpawns.get(owner)?.size ?? 0) > 0
320
+ || live.has(owner)
321
+ || closing.has(owner)
322
+
323
+ /** Register the owner-scoped cleanup effect BEFORE the first spawn: on
324
+ * owner disposal it aborts and awaits pending creations, then closes the
325
+ * live session. Throws when the owner context is already disposed (no
326
+ * registration happens on a disposed context). */
327
+ const ensureOwnerCleanup = (owner) => {
328
+ if (ownerCleanupInstalled.has(owner)) return
329
+ if (disposedOwners.has(owner)) {
330
+ throw new Error(`persistent fish owner ${owner.id ?? '(unnamed)'} is no longer live`)
331
+ }
332
+ try {
333
+ owner.ctx.effect(() => async () => {
334
+ disposedOwners.add(owner)
335
+ ownerCleanupInstalled.delete(owner)
336
+ await abortPendingSpawns(owner, 'persistent fish owner disposed')
337
+ await reset(owner, 'persistent fish owner disposed')
338
+ }, 'persistent fish owner cache cleanup')
339
+ } catch (error) {
340
+ // The owner fiber is already disposed (INACTIVE_EFFECT): fail the
341
+ // creation instead of leaving an untracked spawn behind.
342
+ disposedOwners.add(owner)
343
+ throw new Error(`persistent fish owner ${owner.id ?? '(unnamed)'} is no longer live`)
344
+ }
345
+ ownerCleanupInstalled.add(owner)
346
+ }
347
+
348
+ /** One unpublished spawn reservation: an AbortController plus a settlement
349
+ * the owner-cleanup path awaits, tracked owner→Set so `hasActivity` sees
350
+ * creations before they are published. */
351
+ const reserveSpawn = (owner) => {
352
+ ensureOwnerCleanup(owner)
353
+ const controller = new AbortController()
354
+ const settlement = Promise.withResolvers()
355
+ const pendingSpawn = { owner, controller, settled: settlement.promise }
356
+ let owned = pendingSpawns.get(owner)
357
+ if (owned === undefined) {
358
+ owned = new Set()
359
+ pendingSpawns.set(owner, owned)
360
+ }
361
+ owned.add(pendingSpawn)
362
+ return {
363
+ signal: controller.signal,
364
+ release() {
365
+ owned.delete(pendingSpawn)
366
+ if (owned.size === 0) pendingSpawns.delete(owner)
367
+ settlement.resolve()
368
+ },
369
+ }
370
+ }
371
+
372
+ /** Abort every pending creation of one owner and await its settlement
373
+ * (idempotent with each creation's own `release`). */
374
+ const abortPendingSpawns = async (owner, reason) => {
375
+ const owned = pendingSpawns.get(owner)
376
+ if (owned === undefined) return
377
+ const pendingSpawnsSnapshot = [...owned]
378
+ for (const pendingSpawn of pendingSpawnsSnapshot) pendingSpawn.controller.abort(reason)
379
+ await Promise.allSettled(pendingSpawnsSnapshot.map((pendingSpawn) => pendingSpawn.settled))
380
+ for (const pendingSpawn of pendingSpawnsSnapshot) pendingSpawn.release()
381
+ }
382
+
383
+ /** Close one session, keeping it in `closing` (still counted as activity)
384
+ * until the close settles. On success the `live` entry is removed; on
385
+ * failure the session stays retryable and the failure is recorded, then
386
+ * rethrown so callers can retry. A second close of the same session awaits
387
+ * the in-flight one instead of stacking another close. */
388
+ const closeSession = async (owner, session, reason, onClosed) => {
389
+ if (closing.has(owner)) return closing.get(owner)
390
+ const promise = (async () => {
391
+ try {
392
+ await session.close(reason)
393
+ onClosed?.()
394
+ } catch (error) {
395
+ console.warn(`${MODULE_TAG}: could not close persistent fish session for owner ${owner?.id ?? '(unnamed)'}:`, String(error))
396
+ throw error
397
+ } finally {
398
+ closing.delete(owner)
399
+ }
400
+ })()
401
+ closing.set(owner, promise)
402
+ return promise
403
+ }
404
+
405
+ const dispose = ctx.effect(() => async () => {
406
+ lifecycle.abort(new Error('persistent fish disposed during shell creation'))
407
+ await Promise.allSettled([...creating])
408
+ const owners = [...live.keys()]
409
+ await Promise.allSettled(owners.map((owner) => reset(owner, 'persistent fish disposed')))
410
+ }, 'persistent fish shell cleanup')
411
+ const reset = async (owner, reason) => {
412
+ pending.delete(owner)
413
+ const session = live.get(owner)
414
+ if (session === undefined) return
415
+ await closeSession(owner, session, reason, () => live.delete(owner))
416
+ }
417
+ const get = (owner, signal) => {
418
+ const existing = pending.get(owner)
419
+ if (existing !== undefined) return existing
420
+ // Owner-scoped lifecycle BEFORE the first spawn: a reservation aborts
421
+ // this creation if the owner is disposed while it is in flight, and the
422
+ // owner-cleanup effect waits for it to settle (official registry
423
+ // semantics). A disposed owner fails fast instead of spawning.
424
+ let reservation
425
+ try {
426
+ reservation = reserveSpawn(owner)
427
+ } catch (error) {
428
+ return Promise.reject(error)
429
+ }
430
+ const combinedSignal = AbortSignal.any([signal, lifecycle.signal, reservation.signal])
431
+ const tracked = (async () => {
432
+ try {
433
+ const cwd = owner.session.header.cwd
434
+ // Self-managed activity check for the sandbox-mode fence (no
435
+ // terminals registry to ask); installed before the first spawn so
436
+ // the backend's own fence call reuses this entry.
437
+ ensureSandboxModeFence(ctx, owner, () => hasActivity(owner))
438
+ const spawned = await backend.spawn({
439
+ owner,
440
+ sessionId: randomUUID(),
441
+ ...(cwd === undefined ? {} : { cwd }),
442
+ signal: combinedSignal,
443
+ })
444
+ live.set(owner, spawned)
445
+ const result = await spawned.startSend({
446
+ text: 'stty -echo',
447
+ submit: true,
448
+ signal: combinedSignal,
449
+ }).done
450
+ if (result.sessionStatus.kind === 'exited' || result.waitReason === 'timeout') throw new Error('persistent fish shell did not accept initialization')
451
+ return spawned
452
+ } catch (error) {
453
+ await reset(owner, 'persistent fish initialization failed')
454
+ throw error
455
+ } finally {
456
+ reservation.release()
457
+ }
458
+ })().finally(() => {
459
+ creating.delete(tracked)
460
+ })
461
+ creating.add(tracked)
462
+ pending.set(owner, tracked)
463
+ return tracked
464
+ }
465
+ return { get, reset, dispose }
466
+ }
467
+
468
+ /**
469
+ * Execute one wrapped command against the owner's persistent fish session,
470
+ * reading the marker-delimited result from the scrollback.
471
+ */
472
+ async function executeCommand(ctx, shells, owner, command, config, upstream) {
473
+ const commandDeadline = deadline(upstream, config.timeoutMs, TIMEOUT_CODE)
474
+ try {
475
+ const session = await shells.get(owner, commandDeadline.signal)
476
+ const marker = markers()
477
+ const wrapped = wrapCommand(command, marker)
478
+ let first = true
479
+ let fallback = ''
480
+ let fallbackTruncated = false
481
+ for (;;) {
482
+ const status = session.status()
483
+ if (status.kind === 'exited') return await respondToSessionExit(session, shells, owner, status, marker, fallback, fallbackTruncated, config)
484
+ let operation
485
+ let result
486
+ try {
487
+ operation = session.startSend({
488
+ text: first ? wrapped : '',
489
+ submit: first,
490
+ signal: commandDeadline.signal,
491
+ })
492
+ first = false
493
+ result = await operation.done
494
+ } catch (error) {
495
+ await shells.reset(owner, 'persistent fish send failed')
496
+ throw error
497
+ }
498
+ const incremental = operation.readOutput()
499
+ fallback = incremental.delta.length > 0 ? fallback + incremental.delta : result.viewport
500
+ fallbackTruncated ||= incremental.truncated || result.truncated
501
+ const latest = session.read({
502
+ offset: 0,
503
+ count: SCROLLBACK_PAGE_LINES,
504
+ })
505
+ const timedOut = timeoutOf(commandDeadline.signal, TIMEOUT_CODE)
506
+ if (timedOut !== undefined) {
507
+ const partial = renderCaptured(partialOutput(retainedScrollback(session, latest), marker, fallback, fallbackTruncated), config.maxOutputChars)
508
+ await shells.reset(owner, 'persistent fish command timed out')
509
+ return [
510
+ `Your command timed out after ${Math.round(timedOut.timeoutMs / 1e3)} seconds. Below is partial output:`,
511
+ appendStatusMarker(partial, TIMEOUT_STATUS_MARKER),
512
+ SHELL_RESET_MESSAGE,
513
+ ].join('\n')
514
+ }
515
+ if (commandDeadline.signal.aborted) {
516
+ await shells.reset(owner, 'persistent fish command aborted')
517
+ commandDeadline.signal.throwIfAborted()
518
+ }
519
+ if (latest.text.includes(marker.end)) {
520
+ const complete = commandOutput(retainedScrollback(session, latest), marker)
521
+ if (complete !== undefined) return renderCaptured(complete, config.maxOutputChars)
522
+ }
523
+ if (result.sessionStatus.kind === 'exited') return await respondToSessionExit(session, shells, owner, result.sessionStatus, marker, fallback, fallbackTruncated, config)
524
+ if (result.waitReason === 'stdin_read') return renderCaptured(partialOutput(retainedScrollback(session, latest), marker, fallback, fallbackTruncated), config.maxOutputChars)
525
+ await pause()
526
+ }
527
+ } finally {
528
+ commandDeadline.dispose()
529
+ }
530
+ }
531
+
532
+ /**
533
+ * Resolve the persistent tool configuration with defaults applied.
534
+ * @param config - optional overrides (backend type, deadline, output cap,
535
+ * fish executable).
536
+ * @returns the fully resolved configuration (validated).
537
+ */
538
+ export function resolvePersistentConfig(config = {}) {
539
+ const resolved = {
540
+ backendType: config.backendType ?? 'fish',
541
+ timeoutMs: config.timeoutMs ?? 3e5,
542
+ maxOutputChars: config.maxOutputChars ?? 16e3,
543
+ description: config.description ?? DEFAULT_DESCRIPTION,
544
+ shellPath: config.shellPath,
545
+ shellArgs: config.shellArgs,
546
+ }
547
+ validatePersistentConfig(resolved)
548
+ return resolved
549
+ }
550
+
551
+ /**
552
+ * Validate the resolved persistent configuration. `timeoutMs` is additionally
553
+ * bounded by {@link MAX_TIMER_DELAY_MS}: a larger value would make Node clamp
554
+ * the timer to ~1ms (TimeoutOverflowWarning) and abort almost immediately.
555
+ * @param resolved - the fully resolved configuration.
556
+ */
557
+ export function validatePersistentConfig(resolved) {
558
+ if (resolved.backendType.trim().length === 0) throw new Error('persistent fish: backendType must be non-empty')
559
+ if (!Number.isSafeInteger(resolved.timeoutMs) || resolved.timeoutMs <= 0 || resolved.timeoutMs > MAX_TIMER_DELAY_MS) {
560
+ throw new Error(`persistent fish: timeoutMs must be a positive safe integer no greater than ${MAX_TIMER_DELAY_MS}`)
561
+ }
562
+ if (!Number.isSafeInteger(resolved.maxOutputChars) || resolved.maxOutputChars <= 0) throw new Error('persistent fish: maxOutputChars must be a positive safe integer')
563
+ if (resolved.description.trim().length === 0) throw new Error('persistent fish: description must be non-empty')
564
+ }
565
+
566
+ /**
567
+ * Register the model-facing persistent `fish` tool at the AGENT scope,
568
+ * shadowing the host-global one-shot `fish` tool. Intended to be called by
569
+ * `policy.js` from the agent boundary when the agent's standing bash tool is
570
+ * the persistent form (`minimal` preset).
571
+ * @param ctx - the host context carrying `sandboxPolicy` and `subprocess`
572
+ * (resolvable from the policy plugin ctx in dsh-base/TUI host planes and
573
+ * in the composition test profiles); `terminals` is deliberately NOT
574
+ * needed — sessions are self-managed through a `FishTerminalBackend`.
575
+ * @param agentCtx - the agent's scoped context; the tool is registered here
576
+ * so the agent sees it (shadowing the host-global fish).
577
+ * @param config - optional overrides (backend type, deadline, output cap,
578
+ * fish executable).
579
+ * @returns the disposer; it unregisters the tool and returns a Promise that
580
+ * settles when the session cleanup has quiesced (policy.js awaits it
581
+ * out-of-band via `Promise.resolve(...).catch`).
582
+ */
583
+ export function registerPersistentFish(ctx, agentCtx, config = {}) {
584
+ const resolved = resolvePersistentConfig(config)
585
+
586
+ const shells = persistentShells(ctx, resolved)
587
+ const queues = new WeakMap()
588
+ const serialized = async (owner, operation) => {
589
+ const run = (queues.get(owner) ?? Promise.resolve()).then(operation, operation)
590
+ const tail = run.then(() => undefined, () => undefined)
591
+ queues.set(owner, tail)
592
+ try {
593
+ return await run
594
+ } finally {
595
+ if (queues.get(owner) === tail) queues.delete(owner)
596
+ }
597
+ }
598
+ const unregisterTool = agentCtx.tools.register(defineTool({
599
+ name: 'fish',
600
+ description: resolved.description,
601
+ parameters: {
602
+ command: {
603
+ type: 'string',
604
+ required: true,
605
+ description: 'The fish command to run. State persists across calls: the current directory, exported variables, and defined functions. Relative paths are preferred.',
606
+ },
607
+ },
608
+ output: {
609
+ schema: { type: 'string' },
610
+ render: (_args, value) => [{ type: 'text', text: value }],
611
+ },
612
+ async execute(args, exec) {
613
+ if (args.command.trim().length === 0) throw new Error('command must be a non-empty string')
614
+ const owner = exec.agent
615
+ if (owner === undefined) throw new Error('fish requires an owning agent session')
616
+ return serialized(owner, async () => {
617
+ exec.signal.throwIfAborted()
618
+ return executeCommand(ctx, shells, owner, args.command, resolved, exec.signal)
619
+ })
620
+ },
621
+ presentCall: (args) => ({
622
+ card: 'terminal',
623
+ title: args.command,
624
+ }),
625
+ }))
626
+ let disposed = false
627
+ const dispose = () => {
628
+ if (disposed) return undefined
629
+ disposed = true
630
+ unregisterTool()
631
+ // Awaitable cleanup: the caller may wait for the PTY teardown to
632
+ // quiesce, and failures are recorded instead of becoming unhandled.
633
+ return Promise.resolve(shells.dispose()).catch((error) => {
634
+ console.warn('persistent fish cleanup failed:', String(error))
635
+ })
636
+ }
637
+ agentCtx.effect(() => dispose, 'persistent fish tool cleanup')
638
+ return dispose
639
+ }