@banbolee/dsh-fish-shell 0.5.0 → 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 CHANGED
@@ -56,9 +56,71 @@
56
56
  */
57
57
 
58
58
  import { randomUUID } from 'node:crypto'
59
+ import { TerminalSessionId } from '@deepseek-ai/dsh-terminal'
59
60
  import { defineTool } from '@deepseek-ai/dsh-tools'
60
61
  import { FishTerminalBackend, ensureSandboxModeFence, resolveConfig as resolveTerminalConfig } from './terminal-fish.js'
61
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
+
62
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>'
63
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'
64
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.'
@@ -79,9 +141,15 @@ const DEFAULT_DESCRIPTION = 'Run commands in a persistent fish shell. State, inc
79
141
  * self-contained).
80
142
  */
81
143
  export class TimeoutReason extends Error {
144
+ /** @type {string} */
82
145
  code
146
+ /** @type {number} */
83
147
  timeoutMs
84
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
+ */
85
153
  constructor(code, timeoutMs) {
86
154
  super(`${code} after ${timeoutMs}ms`)
87
155
  this.code = code
@@ -92,10 +160,10 @@ export class TimeoutReason extends Error {
92
160
  /**
93
161
  * Fuse upstream cancellation with an identifiable timeout, without pulling
94
162
  * 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.
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.
99
167
  */
100
168
  export function deadline(upstream, timeoutMs, code) {
101
169
  if (timeoutMs <= 0) {
@@ -120,9 +188,9 @@ export function deadline(upstream, timeoutMs, code) {
120
188
 
121
189
  /**
122
190
  * 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`.
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`.
126
194
  */
127
195
  export function timeoutOf(x, code) {
128
196
  const reason = x?.reason
@@ -130,6 +198,13 @@ export function timeoutOf(x, code) {
130
198
  return code === undefined || reason.code === code ? reason : undefined
131
199
  }
132
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
+ */
133
208
  export function maybeTruncate(content, maxOutputChars, incomplete = false) {
134
209
  if (content.length <= maxOutputChars && !incomplete) return content
135
210
  return content.length <= maxOutputChars ? content + TRUNCATED_MESSAGE : content.slice(0, maxOutputChars) + TRUNCATED_MESSAGE
@@ -147,8 +222,8 @@ export function markers() {
147
222
  * Quote a value as one fish single-quoted token. fish single-quoted strings
148
223
  * only escape `\\` and `\'`, so backslashes are doubled first, then quotes —
149
224
  * 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.
225
+ * @param {string} value - the raw string to embed.
226
+ * @returns {string} a fish single-quoted literal.
152
227
  */
153
228
  export function quoteForFish(value) {
154
229
  return `'${value.replaceAll('\\', '\\\\').replaceAll("'", "\\'")}'`
@@ -158,23 +233,28 @@ export function quoteForFish(value) {
158
233
  * Wrap a command so its output and exit status are delimited by unique
159
234
  * markers. fish 4's `eval` evaluates one command string (no `--`), and
160
235
  * `$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.
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.
164
239
  */
165
240
  export function wrapCommand(command, marker) {
166
241
  return `printf '%s\\n' ${quoteForFish(marker.start)}; eval ${quoteForFish(command)}; set __dsh_status $status; printf '%s%s\\n' ${quoteForFish(marker.end)} "$__dsh_status"`
167
242
  }
168
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
+ */
169
249
  export function trimTrailingNewline(text) {
170
250
  return text.replace(/(?:\r?\n)+$/, '')
171
251
  }
172
252
 
173
253
  /**
174
254
  * 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.
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.
178
258
  */
179
259
  export function commandOutput(snapshot, marker) {
180
260
  const text = snapshot.text
@@ -191,8 +271,14 @@ export function commandOutput(snapshot, marker) {
191
271
  }
192
272
 
193
273
  /**
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.
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.
196
282
  */
197
283
  export function partialOutput(snapshot, marker, fallback, fallbackTruncated = false) {
198
284
  const startMarker = snapshot.text.lastIndexOf(marker.start)
@@ -211,15 +297,31 @@ export function partialOutput(snapshot, marker, fallback, fallbackTruncated = fa
211
297
  }
212
298
  }
213
299
 
300
+ /**
301
+ * Wait one polling interval between readiness/scrollback probes.
302
+ * @returns {Promise<void>} resolves after the interval.
303
+ */
214
304
  export async function pause() {
215
305
  await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS))
216
306
  }
217
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
+ */
218
314
  export function nextScrollbackOffset(page, offset) {
219
315
  if (page.text.length === 0 || page.lineEnd <= offset) return undefined
220
316
  return page.lineEnd
221
317
  }
222
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
+ */
223
325
  export function retainedScrollback(session, latest = session.read({
224
326
  offset: 0,
225
327
  count: SCROLLBACK_PAGE_LINES,
@@ -245,16 +347,35 @@ export function retainedScrollback(session, latest = session.read({
245
347
  }
246
348
  }
247
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
+ */
248
356
  export function renderCaptured(output, maxOutputChars) {
249
357
  const rendered = maybeTruncate(output.text, maxOutputChars, output.incomplete)
250
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)
251
359
  }
252
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
+ */
253
367
  export function appendStatusMarker(content, marker) {
254
368
  if (marker === undefined) return content
255
369
  return content.length === 0 ? marker : `${content}\n${marker}`
256
370
  }
257
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
+ */
258
379
  export function renderShellExitStatus(content, exitCode, signal) {
259
380
  return appendStatusMarker(content, signal !== null ? `[shell killed by signal: ${signal}]` : exitCode !== null ? `[shell exited: code ${exitCode}]` : '[shell exited]')
260
381
  }
@@ -262,6 +383,15 @@ export function renderShellExitStatus(content, exitCode, signal) {
262
383
  /**
263
384
  * Render the exited-session result, reset the owner's shell, and carry the
264
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.
265
395
  */
266
396
  async function respondToSessionExit(session, shells, owner, status, marker, fallback, fallbackTruncated, config) {
267
397
  const snapshot = retainedScrollback(session)
@@ -290,30 +420,43 @@ async function respondToSessionExit(session, shells, owner, status, marker, fall
290
420
  * close keeps the session retryable instead of losing the handle, and a
291
421
  * re-entrant close awaits the in-flight one.
292
422
  *
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).
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.
298
427
  */
299
428
  export function persistentShells(ctx, config, backend = new FishTerminalBackend(ctx, resolveTerminalConfig({
300
429
  backendType: config.backendType,
301
430
  ...(config.shellPath !== undefined ? { shellPath: config.shellPath } : {}),
302
431
  ...(config.shellArgs !== undefined ? { shellArgs: config.shellArgs } : {}),
303
- }), (spec) => ctx.get('subprocess').spawnTerminal(spec))) {
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>>} */
304
441
  const pending = new WeakMap()
442
+ /** @type {Map<PersistentOwner, PersistentPtySession>} */
305
443
  const live = new Map()
444
+ /** @type {Set<Promise<PersistentPtySession>>} */
306
445
  const creating = new Set()
307
446
  /** Owner → in-flight close promise. A session stays here until its close
308
447
  * settles, so it still counts as activity while the PTY is being torn down
309
448
  * and a failed close leaves the session retryable (the `live` entry is
310
449
  * removed only on success). */
450
+ /** @type {Map<PersistentOwner, Promise<void>>} */
311
451
  const closing = new Map()
312
- /** Owner → Set of unpublished spawn reservations (official registry shape). */
452
+ /** Owner → Set of unpublished spawn reservations (official registry shape). @type {Map<PersistentOwner, Set<SpawnReservation>>} */
313
453
  const pendingSpawns = new Map()
454
+ /** @type {WeakSet<PersistentOwner>} */
314
455
  const ownerCleanupInstalled = new WeakSet()
456
+ /** @type {WeakSet<PersistentOwner>} */
315
457
  const disposedOwners = new WeakSet()
316
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. */
317
460
  const hasActivity = (owner) =>
318
461
  pending.get(owner) !== undefined
319
462
  || (pendingSpawns.get(owner)?.size ?? 0) > 0
@@ -323,7 +466,8 @@ export function persistentShells(ctx, config, backend = new FishTerminalBackend(
323
466
  /** Register the owner-scoped cleanup effect BEFORE the first spawn: on
324
467
  * owner disposal it aborts and awaits pending creations, then closes the
325
468
  * live session. Throws when the owner context is already disposed (no
326
- * registration happens on a disposed context). */
469
+ * registration happens on a disposed context).
470
+ * @param {PersistentOwner} owner - the owner to install cleanup for. */
327
471
  const ensureOwnerCleanup = (owner) => {
328
472
  if (ownerCleanupInstalled.has(owner)) return
329
473
  if (disposedOwners.has(owner)) {
@@ -347,30 +491,43 @@ export function persistentShells(ctx, config, backend = new FishTerminalBackend(
347
491
 
348
492
  /** One unpublished spawn reservation: an AbortController plus a settlement
349
493
  * the owner-cleanup path awaits, tracked owner→Set so `hasActivity` sees
350
- * creations before they are published. */
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. */
351
499
  const reserveSpawn = (owner) => {
352
500
  ensureOwnerCleanup(owner)
353
501
  const controller = new AbortController()
502
+ /** @type {PromiseWithResolvers<void>} */
354
503
  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,
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). */
364
509
  release() {
510
+ const owned = pendingSpawns.get(owner)
511
+ if (owned === undefined) return
365
512
  owned.delete(pendingSpawn)
366
513
  if (owned.size === 0) pendingSpawns.delete(owner)
367
514
  settlement.resolve()
368
515
  },
369
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() }
370
524
  }
371
525
 
372
526
  /** Abort every pending creation of one owner and await its settlement
373
- * (idempotent with each creation's own `release`). */
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. */
374
531
  const abortPendingSpawns = async (owner, reason) => {
375
532
  const owned = pendingSpawns.get(owner)
376
533
  if (owned === undefined) return
@@ -384,7 +541,12 @@ export function persistentShells(ctx, config, backend = new FishTerminalBackend(
384
541
  * until the close settles. On success the `live` entry is removed; on
385
542
  * failure the session stays retryable and the failure is recorded, then
386
543
  * rethrown so callers can retry. A second close of the same session awaits
387
- * the in-flight one instead of stacking another close. */
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. */
388
550
  const closeSession = async (owner, session, reason, onClosed) => {
389
551
  if (closing.has(owner)) return closing.get(owner)
390
552
  const promise = (async () => {
@@ -408,12 +570,14 @@ export function persistentShells(ctx, config, backend = new FishTerminalBackend(
408
570
  const owners = [...live.keys()]
409
571
  await Promise.allSettled(owners.map((owner) => reset(owner, 'persistent fish disposed')))
410
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. */
411
574
  const reset = async (owner, reason) => {
412
575
  pending.delete(owner)
413
576
  const session = live.get(owner)
414
577
  if (session === undefined) return
415
578
  await closeSession(owner, session, reason, () => live.delete(owner))
416
579
  }
580
+ /** @param {PersistentOwner} owner - the owner whose session is resolved. @param {AbortSignal} signal - caller cancellation. @returns {Promise<PersistentPtySession>} the live (lazily created) session. */
417
581
  const get = (owner, signal) => {
418
582
  const existing = pending.get(owner)
419
583
  if (existing !== undefined) return existing
@@ -436,8 +600,14 @@ export function persistentShells(ctx, config, backend = new FishTerminalBackend(
436
600
  // the backend's own fence call reuses this entry.
437
601
  ensureSandboxModeFence(ctx, owner, () => hasActivity(owner))
438
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,
439
606
  owner,
440
- sessionId: randomUUID(),
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()),
441
611
  ...(cwd === undefined ? {} : { cwd }),
442
612
  signal: combinedSignal,
443
613
  })
@@ -468,6 +638,13 @@ export function persistentShells(ctx, config, backend = new FishTerminalBackend(
468
638
  /**
469
639
  * Execute one wrapped command against the owner's persistent fish session,
470
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.
471
648
  */
472
649
  async function executeCommand(ctx, shells, owner, command, config, upstream) {
473
650
  const commandDeadline = deadline(upstream, config.timeoutMs, TIMEOUT_CODE)
@@ -529,11 +706,24 @@ async function executeCommand(ctx, shells, owner, command, config, upstream) {
529
706
  }
530
707
  }
531
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
+
532
722
  /**
533
723
  * Resolve the persistent tool configuration with defaults applied.
534
- * @param config - optional overrides (backend type, deadline, output cap,
724
+ * @param {PersistentFishOverrides} [config] - optional overrides (backend type, deadline, output cap,
535
725
  * fish executable).
536
- * @returns the fully resolved configuration (validated).
726
+ * @returns {ResolvedPersistentConfig} the fully resolved configuration (validated).
537
727
  */
538
728
  export function resolvePersistentConfig(config = {}) {
539
729
  const resolved = {
@@ -552,7 +742,8 @@ export function resolvePersistentConfig(config = {}) {
552
742
  * Validate the resolved persistent configuration. `timeoutMs` is additionally
553
743
  * bounded by {@link MAX_TIMER_DELAY_MS}: a larger value would make Node clamp
554
744
  * the timer to ~1ms (TimeoutOverflowWarning) and abort almost immediately.
555
- * @param resolved - the fully resolved configuration.
745
+ * @param {ResolvedPersistentConfig} resolved - the fully resolved configuration.
746
+ * @returns {void} throws on a value the tool cannot run with.
556
747
  */
557
748
  export function validatePersistentConfig(resolved) {
558
749
  if (resolved.backendType.trim().length === 0) throw new Error('persistent fish: backendType must be non-empty')
@@ -568,15 +759,15 @@ export function validatePersistentConfig(resolved) {
568
759
  * shadowing the host-global one-shot `fish` tool. Intended to be called by
569
760
  * `policy.js` from the agent boundary when the agent's standing bash tool is
570
761
  * the persistent form (`minimal` preset).
571
- * @param ctx - the host context carrying `sandboxPolicy` and `subprocess`
762
+ * @param {import('@deepseek-ai/cordis').Context} ctx - the host context carrying `sandboxPolicy` and `subprocess`
572
763
  * (resolvable from the policy plugin ctx in dsh-base/TUI host planes and
573
764
  * in the composition test profiles); `terminals` is deliberately NOT
574
765
  * needed — sessions are self-managed through a `FishTerminalBackend`.
575
- * @param agentCtx - the agent's scoped context; the tool is registered here
766
+ * @param {import('@deepseek-ai/cordis').Context} agentCtx - the agent's scoped context; the tool is registered here
576
767
  * so the agent sees it (shadowing the host-global fish).
577
- * @param config - optional overrides (backend type, deadline, output cap,
768
+ * @param {PersistentFishOverrides} [config] - optional overrides (backend type, deadline, output cap,
578
769
  * fish executable).
579
- * @returns the disposer; it unregisters the tool and returns a Promise that
770
+ * @returns {() => Promise<void> | undefined} the disposer; it unregisters the tool and returns a Promise that
580
771
  * settles when the session cleanup has quiesced (policy.js awaits it
581
772
  * out-of-band via `Promise.resolve(...).catch`).
582
773
  */
@@ -584,7 +775,9 @@ export function registerPersistentFish(ctx, agentCtx, config = {}) {
584
775
  const resolved = resolvePersistentConfig(config)
585
776
 
586
777
  const shells = persistentShells(ctx, resolved)
778
+ /** @type {WeakMap<PersistentOwner, Promise<void>>} */
587
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. */
588
781
  const serialized = async (owner, operation) => {
589
782
  const run = (queues.get(owner) ?? Promise.resolve()).then(operation, operation)
590
783
  const tail = run.then(() => undefined, () => undefined)