@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/persistent.js ADDED
@@ -0,0 +1,832 @@
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 { TerminalSessionId } from '@deepseek-ai/dsh-terminal'
60
+ import { defineTool } from '@deepseek-ai/dsh-tools'
61
+ import { FishTerminalBackend, ensureSandboxModeFence, resolveConfig as resolveTerminalConfig } from './terminal-fish.js'
62
+
63
+ /**
64
+ * The live owner of one persistent shell: the harness's runtime `Agent`
65
+ * (identity, the agent-scoped context, and the session whose header carries the
66
+ * cwd). Test doubles construct a minimal object and assert it once.
67
+ * @typedef {import('@deepseek-ai/dsh-agent').Agent} PersistentOwner
68
+ */
69
+
70
+ /**
71
+ * The unique start/end marker pair one wrapped command's output is delimited by
72
+ * (`markers()`).
73
+ * @typedef {{ start: string, end: string }} CommandMarkers
74
+ */
75
+
76
+ /**
77
+ * One retained scrollback page — `@deepseek-ai/dsh-terminal`'s
78
+ * `TerminalReadResult`, which is what `session.read(...)` resolves.
79
+ * @typedef {import('@deepseek-ai/dsh-terminal').TerminalReadResult} ScrollbackPage
80
+ */
81
+
82
+ /**
83
+ * The PTY session surface this module drives: the backend session with its
84
+ * startup `motd` writable (`FishTerminalBackend.spawn` resolves with the
85
+ * concrete `LocalPtySession`, whose `motd` is mutable).
86
+ * @typedef {import('./terminal-fish.js').PersistentPtySession} PersistentPtySession
87
+ */
88
+
89
+ /**
90
+ * The per-owner shell cache {@link persistentShells} returns.
91
+ * @typedef {object} PersistentShellRegistry
92
+ * @property {(owner: PersistentOwner, signal: AbortSignal) => Promise<PersistentPtySession>} get - resolve (creating lazily) the owner's live session.
93
+ * @property {(owner: PersistentOwner, reason: string) => Promise<void>} reset - close and forget the owner's session.
94
+ * @property {() => Promise<void>} dispose - tear every live session down (the plugin's own cleanup effect).
95
+ */
96
+
97
+ /**
98
+ * The persistent tool's resolved configuration: every default materialized.
99
+ * @typedef {object} ResolvedPersistentConfig
100
+ * @property {string} backendType - PTY backend registry type.
101
+ * @property {number} timeoutMs - per-command deadline.
102
+ * @property {number} maxOutputChars - rendered-output cap.
103
+ * @property {string} description - model-facing tool description.
104
+ * @property {string | undefined} shellPath - fish executable override, when configured.
105
+ * @property {string[] | undefined} shellArgs - fish argument override, when configured.
106
+ */
107
+
108
+ /**
109
+ * One unpublished spawn reservation: its owner, the cancellation controller the
110
+ * owner-cleanup path aborts, the settlement it awaits, and the release that
111
+ * drops the record once the creation settled.
112
+ * @typedef {object} SpawnReservation
113
+ * @property {PersistentOwner} owner - the owner the reservation belongs to.
114
+ * @property {AbortController} controller - aborts the in-flight creation.
115
+ * @property {Promise<void>} settled - resolves when the creation published or failed.
116
+ * @property {() => void} release - drops the record from the owner's tracking set (idempotent).
117
+ */
118
+
119
+ /**
120
+ * The exited branch of a session status snapshot.
121
+ * @typedef {Extract<import('@deepseek-ai/dsh-terminal').TerminalSessionStatus, { kind: 'exited' }>} ExitedSessionStatus
122
+ */
123
+
124
+ 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>'
125
+ 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'
126
+ 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.'
127
+ const TIMEOUT_STATUS_MARKER = '[Command timed out]'
128
+ export const TIMEOUT_CODE = 'PERSISTENT_FISH_TIMEOUT'
129
+ /** Largest delay Node schedules without clamping it to one millisecond
130
+ * (mirrors `@deepseek-ai/dsh-timeout`'s `MAX_TIMER_DELAY_MS`). */
131
+ export const MAX_TIMER_DELAY_MS = 2147483647
132
+ const MODULE_TAG = '@banbolee/dsh-fish-shell'
133
+ const SCROLLBACK_PAGE_LINES = 1e3
134
+ const POLL_INTERVAL_MS = 25
135
+ 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.'
136
+
137
+ /**
138
+ * Abort reason carrying this module's timeout code and elapsed deadline.
139
+ * Mirrors `@deepseek-ai/dsh-timeout`'s `TimeoutReason` (the module is NOT a
140
+ * peer dependency here: the minimal deadline implementation below is
141
+ * self-contained).
142
+ */
143
+ export class TimeoutReason extends Error {
144
+ /** @type {string} */
145
+ code
146
+ /** @type {number} */
147
+ timeoutMs
148
+ name = 'TimeoutReason'
149
+ /**
150
+ * @param {string} code - the timeout code stamped onto this reason.
151
+ * @param {number} timeoutMs - the elapsed deadline in milliseconds.
152
+ */
153
+ constructor(code, timeoutMs) {
154
+ super(`${code} after ${timeoutMs}ms`)
155
+ this.code = code
156
+ this.timeoutMs = timeoutMs
157
+ }
158
+ }
159
+
160
+ /**
161
+ * Fuse upstream cancellation with an identifiable timeout, without pulling
162
+ * in `@deepseek-ai/dsh-timeout`. `timeoutMs <= 0` arms no timer.
163
+ * @param {AbortSignal | undefined} upstream - caller cancellation, if any.
164
+ * @param {number} timeoutMs - deadline in milliseconds; `<= 0` means "no timeout".
165
+ * @param {string} code - timeout code stamped onto the {@link TimeoutReason}.
166
+ * @returns {{ signal: AbortSignal, dispose: () => void }} the fused signal plus a disposer that clears the armed timer.
167
+ */
168
+ export function deadline(upstream, timeoutMs, code) {
169
+ if (timeoutMs <= 0) {
170
+ return { signal: upstream ?? new AbortController().signal, dispose() {} }
171
+ }
172
+ // Node clamps delays above 2^31-1 to ~1ms with a TimeoutOverflowWarning;
173
+ // reject such deadlines instead of aborting immediately.
174
+ if (!Number.isFinite(timeoutMs) || timeoutMs > MAX_TIMER_DELAY_MS) {
175
+ throw new Error(`deadline timeoutMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`)
176
+ }
177
+ const timer = new AbortController()
178
+ const id = setTimeout(() => {
179
+ timer.abort(new TimeoutReason(code, timeoutMs))
180
+ }, timeoutMs)
181
+ return {
182
+ signal: upstream !== undefined ? AbortSignal.any([upstream, timer.signal]) : timer.signal,
183
+ dispose() {
184
+ clearTimeout(id)
185
+ },
186
+ }
187
+ }
188
+
189
+ /**
190
+ * Recover a timeout reason from a reason-bearing carrier.
191
+ * @param {{ reason?: unknown } | undefined} x - an AbortSignal or any `{ reason }` carrier.
192
+ * @param {string} [code] - only a {@link TimeoutReason} with this exact code matches.
193
+ * @returns {TimeoutReason | undefined} the matching reason, else `undefined`.
194
+ */
195
+ export function timeoutOf(x, code) {
196
+ const reason = x?.reason
197
+ if (!(reason instanceof TimeoutReason)) return undefined
198
+ return code === undefined || reason.code === code ? reason : undefined
199
+ }
200
+
201
+ /**
202
+ * Bound one rendered chunk, appending the truncation note when it was cut.
203
+ * @param {string} content - the rendered text so far.
204
+ * @param {number} maxOutputChars - the configured character cap.
205
+ * @param {boolean} [incomplete] - whether the underlying capture was already incomplete.
206
+ * @returns {string} the bounded text.
207
+ */
208
+ export function maybeTruncate(content, maxOutputChars, incomplete = false) {
209
+ if (content.length <= maxOutputChars && !incomplete) return content
210
+ return content.length <= maxOutputChars ? content + TRUNCATED_MESSAGE : content.slice(0, maxOutputChars) + TRUNCATED_MESSAGE
211
+ }
212
+
213
+ export function markers() {
214
+ const nonce = randomUUID()
215
+ return {
216
+ start: `__DSH_PERSISTENT_FISH_START_${nonce}__`,
217
+ end: `__DSH_PERSISTENT_FISH_END_${nonce}:`,
218
+ }
219
+ }
220
+
221
+ /**
222
+ * Quote a value as one fish single-quoted token. fish single-quoted strings
223
+ * only escape `\\` and `\'`, so backslashes are doubled first, then quotes —
224
+ * the decode is exact for arbitrary input (verified against fish 4.0.0).
225
+ * @param {string} value - the raw string to embed.
226
+ * @returns {string} a fish single-quoted literal.
227
+ */
228
+ export function quoteForFish(value) {
229
+ return `'${value.replaceAll('\\', '\\\\').replaceAll("'", "\\'")}'`
230
+ }
231
+
232
+ /**
233
+ * Wrap a command so its output and exit status are delimited by unique
234
+ * markers. fish 4's `eval` evaluates one command string (no `--`), and
235
+ * `$status` must be captured before anything else runs.
236
+ * @param {string} command - the raw fish command text.
237
+ * @param {CommandMarkers} marker - the unique start/end marker pair.
238
+ * @returns {string} the wrapped command line submitted to the persistent shell.
239
+ */
240
+ export function wrapCommand(command, marker) {
241
+ return `printf '%s\\n' ${quoteForFish(marker.start)}; eval ${quoteForFish(command)}; set __dsh_status $status; printf '%s%s\\n' ${quoteForFish(marker.end)} "$__dsh_status"`
242
+ }
243
+
244
+ /**
245
+ * Drop the trailing newline run a PTY echoes after a command.
246
+ * @param {string} text - the captured text.
247
+ * @returns {string} the text without its trailing newlines.
248
+ */
249
+ export function trimTrailingNewline(text) {
250
+ return text.replace(/(?:\r?\n)+$/, '')
251
+ }
252
+
253
+ /**
254
+ * Extract the marker-delimited command output from a scrollback snapshot.
255
+ * @param {{ text: string }} snapshot - the retained scrollback text.
256
+ * @param {CommandMarkers} marker - the unique start/end marker pair.
257
+ * @returns {{ text: string, incomplete: boolean, exitCode: number } | undefined} the captured text, exit code, and whether the start was lost; `undefined` while the end status is not in the snapshot yet.
258
+ */
259
+ export function commandOutput(snapshot, marker) {
260
+ const text = snapshot.text
261
+ const end = text.lastIndexOf(marker.end)
262
+ const status = /^(\d+)\r?\n/.exec(text.slice(end + marker.end.length))?.[1]
263
+ if (status === undefined) return undefined
264
+ const startMarker = text.lastIndexOf(marker.start, end)
265
+ const start = startMarker < 0 ? 0 : startMarker + marker.start.length
266
+ return {
267
+ text: trimTrailingNewline(text.slice(start, end).replace(/^\r?\n/, '')),
268
+ incomplete: startMarker < 0,
269
+ exitCode: Number(status),
270
+ }
271
+ }
272
+
273
+ /**
274
+ * Render the output captured so far for a command that has not finished,
275
+ * falling back to the incremental send delta when the scrollback lost the
276
+ * start marker.
277
+ * @param {{ text: string }} snapshot - the retained scrollback text.
278
+ * @param {CommandMarkers} marker - the unique start/end marker pair.
279
+ * @param {string} fallback - the incremental delta accumulated so far.
280
+ * @param {boolean} [fallbackTruncated] - whether the fallback lost bytes.
281
+ * @returns {{ text: string, incomplete: boolean }} the partial text and whether the start was lost.
282
+ */
283
+ export function partialOutput(snapshot, marker, fallback, fallbackTruncated = false) {
284
+ const startMarker = snapshot.text.lastIndexOf(marker.start)
285
+ if (startMarker >= 0) {
286
+ return {
287
+ text: trimTrailingNewline(snapshot.text.slice(startMarker + marker.start.length).replace(/^\r?\n/, '')),
288
+ incomplete: false,
289
+ }
290
+ }
291
+ const fallbackStart = fallback.lastIndexOf(marker.start)
292
+ const afterStart = fallbackStart < 0 ? fallback : fallback.slice(fallbackStart + marker.start.length).replace(/^\r?\n/, '')
293
+ const fallbackEnd = afterStart.lastIndexOf(marker.end)
294
+ return {
295
+ text: trimTrailingNewline(fallbackEnd < 0 ? afterStart : afterStart.slice(0, fallbackEnd)),
296
+ incomplete: fallbackTruncated || fallbackStart < 0,
297
+ }
298
+ }
299
+
300
+ /**
301
+ * Wait one polling interval between readiness/scrollback probes.
302
+ * @returns {Promise<void>} resolves after the interval.
303
+ */
304
+ export async function pause() {
305
+ await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS))
306
+ }
307
+
308
+ /**
309
+ * The next backward page offset when older lines remain.
310
+ * @param {ScrollbackPage} page - one retained page.
311
+ * @param {number} offset - the newest-relative offset the page started at.
312
+ * @returns {number | undefined} the next offset, or `undefined` when the scrollback is exhausted.
313
+ */
314
+ export function nextScrollbackOffset(page, offset) {
315
+ if (page.text.length === 0 || page.lineEnd <= offset) return undefined
316
+ return page.lineEnd
317
+ }
318
+
319
+ /**
320
+ * Page back through the session's scrollback until the retained window is read.
321
+ * @param {PersistentPtySession} session - the owner's live PTY session.
322
+ * @param {ScrollbackPage} [latest] - the already-read newest page (read here when omitted).
323
+ * @returns {{ text: string, truncated: boolean }} the retained text and whether the window was cut.
324
+ */
325
+ export function retainedScrollback(session, latest = session.read({
326
+ offset: 0,
327
+ count: SCROLLBACK_PAGE_LINES,
328
+ })) {
329
+ const pages = latest.text.length === 0 ? [] : [latest.text]
330
+ let offset = latest.lineEnd
331
+ let truncated = latest.truncated
332
+ for (;;) {
333
+ if (offset >= latest.totalLines) break
334
+ const page = session.read({
335
+ offset,
336
+ count: SCROLLBACK_PAGE_LINES,
337
+ })
338
+ truncated ||= page.truncated
339
+ if (page.text.length > 0) pages.unshift(page.text)
340
+ const next = nextScrollbackOffset(page, offset)
341
+ if (next === undefined || next >= page.totalLines) break
342
+ offset = next
343
+ }
344
+ return {
345
+ text: pages.join('\n'),
346
+ truncated,
347
+ }
348
+ }
349
+
350
+ /**
351
+ * Render one captured command output with its truncation and exit markers.
352
+ * @param {{ text: string, incomplete: boolean, exitCode?: number }} output - the captured chunk.
353
+ * @param {number} maxOutputChars - the configured character cap.
354
+ * @returns {string} the model-facing text.
355
+ */
356
+ export function renderCaptured(output, maxOutputChars) {
357
+ const rendered = maybeTruncate(output.text, maxOutputChars, output.incomplete)
358
+ return appendStatusMarker(output.incomplete && output.text.length > 0 ? LOST_PREFIX_MESSAGE + rendered : rendered, output.exitCode !== undefined ? `[Command finished with exit code ${output.exitCode}]` : undefined)
359
+ }
360
+
361
+ /**
362
+ * Append one status line to already-rendered content.
363
+ * @param {string} content - the rendered text.
364
+ * @param {string | undefined} marker - the status line, when there is one.
365
+ * @returns {string} the content with the marker appended.
366
+ */
367
+ export function appendStatusMarker(content, marker) {
368
+ if (marker === undefined) return content
369
+ return content.length === 0 ? marker : `${content}\n${marker}`
370
+ }
371
+
372
+ /**
373
+ * Append the shell-exit status line for a session that went away.
374
+ * @param {string} content - the rendered text.
375
+ * @param {number | null} exitCode - the shell's exit code, when it exited.
376
+ * @param {NodeJS.Signals | null} signal - the terminating signal, when it was killed.
377
+ * @returns {string} the content with its exit status.
378
+ */
379
+ export function renderShellExitStatus(content, exitCode, signal) {
380
+ return appendStatusMarker(content, signal !== null ? `[shell killed by signal: ${signal}]` : exitCode !== null ? `[shell exited: code ${exitCode}]` : '[shell exited]')
381
+ }
382
+
383
+ /**
384
+ * Render the exited-session result, reset the owner's shell, and carry the
385
+ * notice that the next call starts fresh.
386
+ * @param {PersistentPtySession} session - the session that exited.
387
+ * @param {PersistentShellRegistry} shells - the per-owner cache to reset.
388
+ * @param {PersistentOwner} owner - the owning agent.
389
+ * @param {ExitedSessionStatus} status - the exited status snapshot.
390
+ * @param {CommandMarkers} marker - the unique start/end marker pair.
391
+ * @param {string} fallback - the incremental delta accumulated so far.
392
+ * @param {boolean} fallbackTruncated - whether the fallback lost bytes.
393
+ * @param {ResolvedPersistentConfig} config - the resolved persistent configuration.
394
+ * @returns {Promise<string>} the model-facing reset notice.
395
+ */
396
+ async function respondToSessionExit(session, shells, owner, status, marker, fallback, fallbackTruncated, config) {
397
+ const snapshot = retainedScrollback(session)
398
+ await shells.reset(owner, 'persistent fish shell exited')
399
+ return [renderShellExitStatus(renderCaptured(partialOutput(snapshot, marker, fallback, fallbackTruncated), config.maxOutputChars), status.exitCode, status.signal), SHELL_RESET_MESSAGE].filter((part) => part.length > 0).join('\n')
400
+ }
401
+
402
+ /**
403
+ * Per-owner persistent fish PTY cache with lazy creation and reset, driving
404
+ * one shared `FishTerminalBackend` directly (self-managed: no `terminals`
405
+ * registry, which is invisible at the host/agent plane). The registry's
406
+ * kill is `session.close(reason)`, so close/reset map straight to the
407
+ * session; the sandbox-mode fence gets an injectable owner-activity check
408
+ * backed by this module's own owner→session tracking.
409
+ *
410
+ * Owner lifecycle mirrors the official `@deepseek-ai/dsh-terminal` registry:
411
+ * the owner-scoped cleanup effect is installed BEFORE the first spawn, each
412
+ * creation carries a reservation (AbortController + settlement) merged into
413
+ * the spawn/startup signal, and owner disposal aborts and awaits pending
414
+ * creations before closing live sessions — so a spawn racing an owner
415
+ * disposal cannot outlive its owner or register on a disposed context.
416
+ *
417
+ * A session being closed is tracked in a `closing` set until close actually
418
+ * settles: it still counts as activity (the sandbox-mode fence keeps
419
+ * refusing mode changes while the old PTY is being torn down), a failed
420
+ * close keeps the session retryable instead of losing the handle, and a
421
+ * re-entrant close awaits the in-flight one.
422
+ *
423
+ * @param {import('@deepseek-ai/cordis').Context} ctx - the policy plugin's context (services resolved via `ctx.get`).
424
+ * @param {ResolvedPersistentConfig} config - the resolved persistent configuration.
425
+ * @param {{ spawn: (spec: import('@deepseek-ai/dsh-terminal').TerminalBackendSpawnSpec) => Promise<PersistentPtySession> }} [backend] - the PTY backend (duck-typed: only `spawn(spec)` is read); defaults to a real `FishTerminalBackend` constructed against `ctx` (injectable so tests can fake spawn/close).
426
+ * @returns {PersistentShellRegistry} the per-owner cache the persistent tool drives.
427
+ */
428
+ export function persistentShells(ctx, config, backend = new FishTerminalBackend(ctx, resolveTerminalConfig({
429
+ backendType: config.backendType,
430
+ ...(config.shellPath !== undefined ? { shellPath: config.shellPath } : {}),
431
+ ...(config.shellArgs !== undefined ? { shellArgs: config.shellArgs } : {}),
432
+ }), (spec) => {
433
+ // `ctx.get` (never property access) keeps this working in a policy context
434
+ // whose inject list does not declare `subprocess`; a composition without the
435
+ // service fails loud here instead of with an undefined-property TypeError.
436
+ const subprocess = ctx.get('subprocess')
437
+ if (subprocess === undefined) throw new Error('persistent fish: the PTY backend requires ctx.subprocess in this world')
438
+ return subprocess.spawnTerminal(spec)
439
+ })) {
440
+ /** @type {WeakMap<PersistentOwner, Promise<PersistentPtySession>>} */
441
+ const pending = new WeakMap()
442
+ /** @type {Map<PersistentOwner, PersistentPtySession>} */
443
+ const live = new Map()
444
+ /** @type {Set<Promise<PersistentPtySession>>} */
445
+ const creating = new Set()
446
+ /** Owner → in-flight close promise. A session stays here until its close
447
+ * settles, so it still counts as activity while the PTY is being torn down
448
+ * and a failed close leaves the session retryable (the `live` entry is
449
+ * removed only on success). */
450
+ /** @type {Map<PersistentOwner, Promise<void>>} */
451
+ const closing = new Map()
452
+ /** Owner → Set of unpublished spawn reservations (official registry shape). @type {Map<PersistentOwner, Set<SpawnReservation>>} */
453
+ const pendingSpawns = new Map()
454
+ /** @type {WeakSet<PersistentOwner>} */
455
+ const ownerCleanupInstalled = new WeakSet()
456
+ /** @type {WeakSet<PersistentOwner>} */
457
+ const disposedOwners = new WeakSet()
458
+ const lifecycle = new AbortController()
459
+ /** @param {PersistentOwner} owner - the owner whose activity is probed. @returns {boolean} whether that owner has a live, pending, or closing session. */
460
+ const hasActivity = (owner) =>
461
+ pending.get(owner) !== undefined
462
+ || (pendingSpawns.get(owner)?.size ?? 0) > 0
463
+ || live.has(owner)
464
+ || closing.has(owner)
465
+
466
+ /** Register the owner-scoped cleanup effect BEFORE the first spawn: on
467
+ * owner disposal it aborts and awaits pending creations, then closes the
468
+ * live session. Throws when the owner context is already disposed (no
469
+ * registration happens on a disposed context).
470
+ * @param {PersistentOwner} owner - the owner to install cleanup for. */
471
+ const ensureOwnerCleanup = (owner) => {
472
+ if (ownerCleanupInstalled.has(owner)) return
473
+ if (disposedOwners.has(owner)) {
474
+ throw new Error(`persistent fish owner ${owner.id ?? '(unnamed)'} is no longer live`)
475
+ }
476
+ try {
477
+ owner.ctx.effect(() => async () => {
478
+ disposedOwners.add(owner)
479
+ ownerCleanupInstalled.delete(owner)
480
+ await abortPendingSpawns(owner, 'persistent fish owner disposed')
481
+ await reset(owner, 'persistent fish owner disposed')
482
+ }, 'persistent fish owner cache cleanup')
483
+ } catch (error) {
484
+ // The owner fiber is already disposed (INACTIVE_EFFECT): fail the
485
+ // creation instead of leaving an untracked spawn behind.
486
+ disposedOwners.add(owner)
487
+ throw new Error(`persistent fish owner ${owner.id ?? '(unnamed)'} is no longer live`)
488
+ }
489
+ ownerCleanupInstalled.add(owner)
490
+ }
491
+
492
+ /** One unpublished spawn reservation: an AbortController plus a settlement
493
+ * the owner-cleanup path awaits, tracked owner→Set so `hasActivity` sees
494
+ * creations before they are published. `release` lives ON the record:
495
+ * `abortPendingSpawns` releases every record it snapshotted, and a snapshot
496
+ * that only carried the wrapper's closure threw `release is not a function`.
497
+ * @param {PersistentOwner} owner - the owner the reservation belongs to.
498
+ * @returns {{ signal: AbortSignal, release: () => void }} the reservation's cancellation signal and release. */
499
+ const reserveSpawn = (owner) => {
500
+ ensureOwnerCleanup(owner)
501
+ const controller = new AbortController()
502
+ /** @type {PromiseWithResolvers<void>} */
503
+ const settlement = Promise.withResolvers()
504
+ const pendingSpawn = {
505
+ owner,
506
+ controller,
507
+ settled: settlement.promise,
508
+ /** Drop this reservation from the owner's set and settle the cleanup wait (idempotent). */
509
+ release() {
510
+ const owned = pendingSpawns.get(owner)
511
+ if (owned === undefined) return
512
+ owned.delete(pendingSpawn)
513
+ if (owned.size === 0) pendingSpawns.delete(owner)
514
+ settlement.resolve()
515
+ },
516
+ }
517
+ let owned = pendingSpawns.get(owner)
518
+ if (owned === undefined) {
519
+ owned = new Set()
520
+ pendingSpawns.set(owner, owned)
521
+ }
522
+ owned.add(pendingSpawn)
523
+ return { signal: controller.signal, release: () => pendingSpawn.release() }
524
+ }
525
+
526
+ /** Abort every pending creation of one owner and await its settlement
527
+ * (idempotent with each creation's own `release`).
528
+ * @param {PersistentOwner} owner - the owner whose creations are aborted.
529
+ * @param {string} reason - forwarded to each reservation's abort.
530
+ * @returns {Promise<void>} resolves once every pending creation settled. */
531
+ const abortPendingSpawns = async (owner, reason) => {
532
+ const owned = pendingSpawns.get(owner)
533
+ if (owned === undefined) return
534
+ const pendingSpawnsSnapshot = [...owned]
535
+ for (const pendingSpawn of pendingSpawnsSnapshot) pendingSpawn.controller.abort(reason)
536
+ await Promise.allSettled(pendingSpawnsSnapshot.map((pendingSpawn) => pendingSpawn.settled))
537
+ for (const pendingSpawn of pendingSpawnsSnapshot) pendingSpawn.release()
538
+ }
539
+
540
+ /** Close one session, keeping it in `closing` (still counted as activity)
541
+ * until the close settles. On success the `live` entry is removed; on
542
+ * failure the session stays retryable and the failure is recorded, then
543
+ * rethrown so callers can retry. A second close of the same session awaits
544
+ * the in-flight one instead of stacking another close.
545
+ * @param {PersistentOwner} owner - the session's owner.
546
+ * @param {PersistentPtySession} session - the session to close.
547
+ * @param {string} reason - the close reason recorded on the PTY session.
548
+ * @param {() => void} [onClosed] - invoked once the close succeeded.
549
+ * @returns {Promise<void>} resolves when the close settled. */
550
+ const closeSession = async (owner, session, reason, onClosed) => {
551
+ if (closing.has(owner)) return closing.get(owner)
552
+ const promise = (async () => {
553
+ try {
554
+ await session.close(reason)
555
+ onClosed?.()
556
+ } catch (error) {
557
+ console.warn(`${MODULE_TAG}: could not close persistent fish session for owner ${owner?.id ?? '(unnamed)'}:`, String(error))
558
+ throw error
559
+ } finally {
560
+ closing.delete(owner)
561
+ }
562
+ })()
563
+ closing.set(owner, promise)
564
+ return promise
565
+ }
566
+
567
+ const dispose = ctx.effect(() => async () => {
568
+ lifecycle.abort(new Error('persistent fish disposed during shell creation'))
569
+ await Promise.allSettled([...creating])
570
+ const owners = [...live.keys()]
571
+ await Promise.allSettled(owners.map((owner) => reset(owner, 'persistent fish disposed')))
572
+ }, 'persistent fish shell cleanup')
573
+ /** @param {PersistentOwner} owner - the owner whose session is reset. @param {string} reason - the close reason. @returns {Promise<void>} resolves when the session is closed and forgotten. */
574
+ const reset = async (owner, reason) => {
575
+ pending.delete(owner)
576
+ const session = live.get(owner)
577
+ if (session === undefined) return
578
+ await closeSession(owner, session, reason, () => live.delete(owner))
579
+ }
580
+ /** @param {PersistentOwner} owner - the owner whose session is resolved. @param {AbortSignal} signal - caller cancellation. @returns {Promise<PersistentPtySession>} the live (lazily created) session. */
581
+ const get = (owner, signal) => {
582
+ const existing = pending.get(owner)
583
+ if (existing !== undefined) return existing
584
+ // Owner-scoped lifecycle BEFORE the first spawn: a reservation aborts
585
+ // this creation if the owner is disposed while it is in flight, and the
586
+ // owner-cleanup effect waits for it to settle (official registry
587
+ // semantics). A disposed owner fails fast instead of spawning.
588
+ let reservation
589
+ try {
590
+ reservation = reserveSpawn(owner)
591
+ } catch (error) {
592
+ return Promise.reject(error)
593
+ }
594
+ const combinedSignal = AbortSignal.any([signal, lifecycle.signal, reservation.signal])
595
+ const tracked = (async () => {
596
+ try {
597
+ const cwd = owner.session.header.cwd
598
+ // Self-managed activity check for the sandbox-mode fence (no
599
+ // terminals registry to ask); installed before the first spawn so
600
+ // the backend's own fence call reuses this entry.
601
+ ensureSandboxModeFence(ctx, owner, () => hasActivity(owner))
602
+ const spawned = await backend.spawn({
603
+ // The registry mints `type` from the request's registered backend;
604
+ // the self-managed path names the configured type directly.
605
+ type: config.backendType,
606
+ owner,
607
+ // The registry normally mints this identity; the self-managed path
608
+ // mints its own with the seam's branding helper (a runtime identity
609
+ // function) so the branded `TerminalSessionIdValue` stays honest.
610
+ sessionId: TerminalSessionId(randomUUID()),
611
+ ...(cwd === undefined ? {} : { cwd }),
612
+ signal: combinedSignal,
613
+ })
614
+ live.set(owner, spawned)
615
+ const result = await spawned.startSend({
616
+ text: 'stty -echo',
617
+ submit: true,
618
+ signal: combinedSignal,
619
+ }).done
620
+ if (result.sessionStatus.kind === 'exited' || result.waitReason === 'timeout') throw new Error('persistent fish shell did not accept initialization')
621
+ return spawned
622
+ } catch (error) {
623
+ await reset(owner, 'persistent fish initialization failed')
624
+ throw error
625
+ } finally {
626
+ reservation.release()
627
+ }
628
+ })().finally(() => {
629
+ creating.delete(tracked)
630
+ })
631
+ creating.add(tracked)
632
+ pending.set(owner, tracked)
633
+ return tracked
634
+ }
635
+ return { get, reset, dispose }
636
+ }
637
+
638
+ /**
639
+ * Execute one wrapped command against the owner's persistent fish session,
640
+ * reading the marker-delimited result from the scrollback.
641
+ * @param {import('@deepseek-ai/cordis').Context} ctx - the policy plugin's context, threaded through for the call shape (the owner-scoped fence bookkeeping lives in {@link persistentShells}; this function reads no context service).
642
+ * @param {PersistentShellRegistry} shells - the per-owner session cache.
643
+ * @param {PersistentOwner} owner - the owning agent.
644
+ * @param {string} command - the raw fish command text.
645
+ * @param {ResolvedPersistentConfig} config - the resolved persistent configuration.
646
+ * @param {AbortSignal | undefined} upstream - the caller's cancellation signal.
647
+ * @returns {Promise<string>} the model-facing command result.
648
+ */
649
+ async function executeCommand(ctx, shells, owner, command, config, upstream) {
650
+ const commandDeadline = deadline(upstream, config.timeoutMs, TIMEOUT_CODE)
651
+ try {
652
+ const session = await shells.get(owner, commandDeadline.signal)
653
+ const marker = markers()
654
+ const wrapped = wrapCommand(command, marker)
655
+ let first = true
656
+ let fallback = ''
657
+ let fallbackTruncated = false
658
+ for (;;) {
659
+ const status = session.status()
660
+ if (status.kind === 'exited') return await respondToSessionExit(session, shells, owner, status, marker, fallback, fallbackTruncated, config)
661
+ let operation
662
+ let result
663
+ try {
664
+ operation = session.startSend({
665
+ text: first ? wrapped : '',
666
+ submit: first,
667
+ signal: commandDeadline.signal,
668
+ })
669
+ first = false
670
+ result = await operation.done
671
+ } catch (error) {
672
+ await shells.reset(owner, 'persistent fish send failed')
673
+ throw error
674
+ }
675
+ const incremental = operation.readOutput()
676
+ fallback = incremental.delta.length > 0 ? fallback + incremental.delta : result.viewport
677
+ fallbackTruncated ||= incremental.truncated || result.truncated
678
+ const latest = session.read({
679
+ offset: 0,
680
+ count: SCROLLBACK_PAGE_LINES,
681
+ })
682
+ const timedOut = timeoutOf(commandDeadline.signal, TIMEOUT_CODE)
683
+ if (timedOut !== undefined) {
684
+ const partial = renderCaptured(partialOutput(retainedScrollback(session, latest), marker, fallback, fallbackTruncated), config.maxOutputChars)
685
+ await shells.reset(owner, 'persistent fish command timed out')
686
+ return [
687
+ `Your command timed out after ${Math.round(timedOut.timeoutMs / 1e3)} seconds. Below is partial output:`,
688
+ appendStatusMarker(partial, TIMEOUT_STATUS_MARKER),
689
+ SHELL_RESET_MESSAGE,
690
+ ].join('\n')
691
+ }
692
+ if (commandDeadline.signal.aborted) {
693
+ await shells.reset(owner, 'persistent fish command aborted')
694
+ commandDeadline.signal.throwIfAborted()
695
+ }
696
+ if (latest.text.includes(marker.end)) {
697
+ const complete = commandOutput(retainedScrollback(session, latest), marker)
698
+ if (complete !== undefined) return renderCaptured(complete, config.maxOutputChars)
699
+ }
700
+ if (result.sessionStatus.kind === 'exited') return await respondToSessionExit(session, shells, owner, result.sessionStatus, marker, fallback, fallbackTruncated, config)
701
+ if (result.waitReason === 'stdin_read') return renderCaptured(partialOutput(retainedScrollback(session, latest), marker, fallback, fallbackTruncated), config.maxOutputChars)
702
+ await pause()
703
+ }
704
+ } finally {
705
+ commandDeadline.dispose()
706
+ }
707
+ }
708
+
709
+ /**
710
+ * Optional overrides for the persistent tool, as accepted by
711
+ * {@link resolvePersistentConfig} and passed through by
712
+ * {@link registerPersistentFish}.
713
+ * @typedef {object} PersistentFishOverrides
714
+ * @property {string} [backendType] - PTY backend registry type (default `fish`).
715
+ * @property {number} [timeoutMs] - per-command deadline in milliseconds (default 300000).
716
+ * @property {number} [maxOutputChars] - rendered-output cap in characters (default 16000).
717
+ * @property {string} [description] - model-facing tool description.
718
+ * @property {string} [shellPath] - fish executable override.
719
+ * @property {string[]} [shellArgs] - fish argument override.
720
+ */
721
+
722
+ /**
723
+ * Resolve the persistent tool configuration with defaults applied.
724
+ * @param {PersistentFishOverrides} [config] - optional overrides (backend type, deadline, output cap,
725
+ * fish executable).
726
+ * @returns {ResolvedPersistentConfig} the fully resolved configuration (validated).
727
+ */
728
+ export function resolvePersistentConfig(config = {}) {
729
+ const resolved = {
730
+ backendType: config.backendType ?? 'fish',
731
+ timeoutMs: config.timeoutMs ?? 3e5,
732
+ maxOutputChars: config.maxOutputChars ?? 16e3,
733
+ description: config.description ?? DEFAULT_DESCRIPTION,
734
+ shellPath: config.shellPath,
735
+ shellArgs: config.shellArgs,
736
+ }
737
+ validatePersistentConfig(resolved)
738
+ return resolved
739
+ }
740
+
741
+ /**
742
+ * Validate the resolved persistent configuration. `timeoutMs` is additionally
743
+ * bounded by {@link MAX_TIMER_DELAY_MS}: a larger value would make Node clamp
744
+ * the timer to ~1ms (TimeoutOverflowWarning) and abort almost immediately.
745
+ * @param {ResolvedPersistentConfig} resolved - the fully resolved configuration.
746
+ * @returns {void} throws on a value the tool cannot run with.
747
+ */
748
+ export function validatePersistentConfig(resolved) {
749
+ if (resolved.backendType.trim().length === 0) throw new Error('persistent fish: backendType must be non-empty')
750
+ if (!Number.isSafeInteger(resolved.timeoutMs) || resolved.timeoutMs <= 0 || resolved.timeoutMs > MAX_TIMER_DELAY_MS) {
751
+ throw new Error(`persistent fish: timeoutMs must be a positive safe integer no greater than ${MAX_TIMER_DELAY_MS}`)
752
+ }
753
+ if (!Number.isSafeInteger(resolved.maxOutputChars) || resolved.maxOutputChars <= 0) throw new Error('persistent fish: maxOutputChars must be a positive safe integer')
754
+ if (resolved.description.trim().length === 0) throw new Error('persistent fish: description must be non-empty')
755
+ }
756
+
757
+ /**
758
+ * Register the model-facing persistent `fish` tool at the AGENT scope,
759
+ * shadowing the host-global one-shot `fish` tool. Intended to be called by
760
+ * `policy.js` from the agent boundary when the agent's standing bash tool is
761
+ * the persistent form (`minimal` preset).
762
+ * @param {import('@deepseek-ai/cordis').Context} ctx - the host context carrying `sandboxPolicy` and `subprocess`
763
+ * (resolvable from the policy plugin ctx in dsh-base/TUI host planes and
764
+ * in the composition test profiles); `terminals` is deliberately NOT
765
+ * needed — sessions are self-managed through a `FishTerminalBackend`.
766
+ * @param {import('@deepseek-ai/cordis').Context} agentCtx - the agent's scoped context; the tool is registered here
767
+ * so the agent sees it (shadowing the host-global fish).
768
+ * @param {PersistentFishOverrides} [config] - optional overrides (backend type, deadline, output cap,
769
+ * fish executable).
770
+ * @returns {() => Promise<void> | undefined} the disposer; it unregisters the tool and returns a Promise that
771
+ * settles when the session cleanup has quiesced (policy.js awaits it
772
+ * out-of-band via `Promise.resolve(...).catch`).
773
+ */
774
+ export function registerPersistentFish(ctx, agentCtx, config = {}) {
775
+ const resolved = resolvePersistentConfig(config)
776
+
777
+ const shells = persistentShells(ctx, resolved)
778
+ /** @type {WeakMap<PersistentOwner, Promise<void>>} */
779
+ const queues = new WeakMap()
780
+ /** @param {PersistentOwner} owner - the agent whose calls are serialized. @param {() => Promise<string>} operation - one call's work. @returns {Promise<string>} the call's result, in submission order per owner. */
781
+ const serialized = async (owner, operation) => {
782
+ const run = (queues.get(owner) ?? Promise.resolve()).then(operation, operation)
783
+ const tail = run.then(() => undefined, () => undefined)
784
+ queues.set(owner, tail)
785
+ try {
786
+ return await run
787
+ } finally {
788
+ if (queues.get(owner) === tail) queues.delete(owner)
789
+ }
790
+ }
791
+ const unregisterTool = agentCtx.tools.register(defineTool({
792
+ name: 'fish',
793
+ description: resolved.description,
794
+ parameters: {
795
+ command: {
796
+ type: 'string',
797
+ required: true,
798
+ description: 'The fish command to run. State persists across calls: the current directory, exported variables, and defined functions. Relative paths are preferred.',
799
+ },
800
+ },
801
+ output: {
802
+ schema: { type: 'string' },
803
+ render: (_args, value) => [{ type: 'text', text: value }],
804
+ },
805
+ async execute(args, exec) {
806
+ if (args.command.trim().length === 0) throw new Error('command must be a non-empty string')
807
+ const owner = exec.agent
808
+ if (owner === undefined) throw new Error('fish requires an owning agent session')
809
+ return serialized(owner, async () => {
810
+ exec.signal.throwIfAborted()
811
+ return executeCommand(ctx, shells, owner, args.command, resolved, exec.signal)
812
+ })
813
+ },
814
+ presentCall: (args) => ({
815
+ card: 'terminal',
816
+ title: args.command,
817
+ }),
818
+ }))
819
+ let disposed = false
820
+ const dispose = () => {
821
+ if (disposed) return undefined
822
+ disposed = true
823
+ unregisterTool()
824
+ // Awaitable cleanup: the caller may wait for the PTY teardown to
825
+ // quiesce, and failures are recorded instead of becoming unhandled.
826
+ return Promise.resolve(shells.dispose()).catch((error) => {
827
+ console.warn('persistent fish cleanup failed:', String(error))
828
+ })
829
+ }
830
+ agentCtx.effect(() => dispose, 'persistent fish tool cleanup')
831
+ return dispose
832
+ }