@banbolee/dsh-fish-shell 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/policy.js CHANGED
@@ -63,18 +63,43 @@ export const inject = ['tools', 'systemPrompt']
63
63
 
64
64
  const PLUGIN_TAG = '@banbolee/dsh-fish-shell'
65
65
 
66
+ /**
67
+ * One agent as this policy reads it: the harness `Agent` viewed through the two
68
+ * members every registration below keys on — its session id (for warnings) and
69
+ * its agent-scoped context (where the restriction/prompt/tool registrations
70
+ * land). The full runtime `Agent` face is structurally assignable to this view.
71
+ * @typedef {{ id?: string, ctx: import('@deepseek-ai/cordis').Context }} PolicyAgent
72
+ */
73
+
74
+ /**
75
+ * One agent's installed policy: which bash form was detected, the preset parent
76
+ * key it was installed under (the switch/re-entry fast path compares it), and
77
+ * the disposers that must move with `/preset`.
78
+ * @typedef {object} PolicyState
79
+ * @property {'persistent' | 'one-shot'} kind - the bash form this policy swapped out.
80
+ * @property {import('@deepseek-ai/dsh-scope').ScopeKey | undefined} parentKey - the preset parent scope key this state was installed under (`scopeParentOf(agent)`).
81
+ * @property {(() => Promise<void> | undefined) | undefined} persistentFish - the persistent fish tool's disposer, when this state installed one.
82
+ * @property {() => void} restriction - the `deny: ['bash']` restriction disposer.
83
+ * @property {() => void} prompt - the `tool:bash` prompt-shadow disposer.
84
+ */
85
+
66
86
  /** Agents currently being reconciled (guards the synchronous tools/change
67
- * re-entry that restriction/prompt registration and disposal trigger). */
87
+ * re-entry that restriction/prompt registration and disposal trigger).
88
+ * @type {WeakSet<PolicyAgent>} */
68
89
  const installing = new WeakSet()
69
- /** Agents already warned about an invisible fish tool (warn once per agent). */
90
+ /** Agents already warned about an invisible fish tool (warn once per agent).
91
+ * @type {WeakSet<PolicyAgent>} */
70
92
  const warned = new WeakSet()
71
93
  /** Agents uninstalled via `agent/disposed`: a tools/change fired by the
72
- * uninstall's own disposals must not re-install a dying agent. */
94
+ * uninstall's own disposals must not re-install a dying agent.
95
+ * @type {WeakSet<PolicyAgent>} */
73
96
  const removed = new WeakSet()
74
- /** Per-agent policy state, including disposers that must move with /preset. */
97
+ /** Per-agent policy state, including disposers that must move with /preset.
98
+ * @type {WeakMap<PolicyAgent, PolicyState>} */
75
99
  const states = new WeakMap()
76
100
  /** Live agents with policy installed; traversable so plugin teardown can
77
- * strip every registration (a WeakMap would be unreachable on teardown). */
101
+ * strip every registration (a WeakMap would be unreachable on teardown).
102
+ * @type {Set<PolicyAgent>} */
78
103
  const liveAgents = new Set()
79
104
  /** Global reconcile re-entrancy guard: a tools/change arriving while a
80
105
  * reconcile round is in flight marks `reconcilePending` instead of starting a
@@ -83,17 +108,32 @@ const liveAgents = new Set()
83
108
  let reconciling = false
84
109
  let reconcilePending = false
85
110
 
111
+ /**
112
+ * Narrow one unchecked value to a non-null, non-array object: the shape this
113
+ * policy reads duck-typed tool definitions and disposer results through.
114
+ * @param {unknown} value - the value to inspect.
115
+ * @returns {value is Record<string, unknown>} whether it is a plain object record.
116
+ */
86
117
  function isPlainObject(value) {
87
118
  return typeof value === 'object' && value !== null && !Array.isArray(value)
88
119
  }
89
120
 
121
+ /**
122
+ * Dispose one registration quietly: `undefined` means nothing was installed,
123
+ * a synchronous throw and a rejected async disposer are both warned instead of
124
+ * breaking the policy round that runs the disposal.
125
+ * @param {(() => unknown) | undefined} disposer - the registration's disposer, when there is one.
126
+ * @param {string} label - the registration's name in the warning.
127
+ * @param {PolicyAgent} agent - the agent the registration belongs to.
128
+ */
90
129
  function disposeQuietly(disposer, label, agent) {
91
130
  if (disposer === undefined) return
92
131
  try {
93
132
  const result = disposer()
94
133
  // Async disposers (persistent fish cleanup) are awaited out-of-band so a
95
- // failing teardown is recorded instead of unhandled.
96
- if (result !== undefined && typeof result.then === 'function') {
134
+ // failing teardown is recorded instead of unhandled; a plain `void`
135
+ // disposer returns a non-thenable and needs no wait.
136
+ if (isPlainObject(result) && typeof result.then === 'function') {
97
137
  void Promise.resolve(result).catch((error) => {
98
138
  console.warn(`${PLUGIN_TAG}: could not dispose ${label} for agent ${agent.id ?? '(unnamed)'}:`, String(error))
99
139
  })
@@ -103,12 +143,23 @@ function disposeQuietly(disposer, label, agent) {
103
143
  }
104
144
  }
105
145
 
146
+ /**
147
+ * Drop every registration one policy state owns.
148
+ * @param {PolicyAgent} agent - the agent the state belongs to.
149
+ * @param {PolicyState} state - the installed state to tear down.
150
+ */
106
151
  function disposeState(agent, state) {
107
152
  disposeQuietly(state.prompt, 'tool:bash prompt shadow', agent)
108
153
  disposeQuietly(state.restriction, 'bash restriction', agent)
109
154
  disposeQuietly(state.persistentFish, 'persistent fish tool', agent)
110
155
  }
111
156
 
157
+ /**
158
+ * Drop the registrations a preset switch must lift before it probes the target
159
+ * preset's surface (the prompt shadow and the bash restriction only).
160
+ * @param {PolicyAgent} agent - the agent whose transient policy is removed.
161
+ * @param {PolicyState | undefined} state - the previous state, when there is one.
162
+ */
112
163
  function removeTransientPolicy(agent, state) {
113
164
  disposeQuietly(state?.prompt, 'tool:bash prompt shadow', agent)
114
165
  disposeQuietly(state?.restriction, 'bash restriction', agent)
@@ -120,8 +171,8 @@ function removeTransientPolicy(agent, state) {
120
171
  * output schema is a plain string. The one-shot bash tool (command +
121
172
  * description + workdir + timeoutMs + run_in_background +
122
173
  * sandbox_permissions…) and every other tool shape fall through.
123
- * @param definition - the tool definition an agent resolves for `bash`.
124
- * @returns true when the agent's bash is the persistent PTY form.
174
+ * @param {import('@deepseek-ai/dsh-tools').ToolDefinition} definition - the tool definition an agent resolves for `bash`.
175
+ * @returns {boolean} true when the agent's bash is the persistent PTY form.
125
176
  */
126
177
  export function isPersistentBashForm(definition) {
127
178
  if (!isPlainObject(definition)) return false
@@ -139,7 +190,10 @@ export function isPersistentBashForm(definition) {
139
190
  return isPlainObject(properties) && Object.keys(properties).length === 1 && Object.hasOwn(properties, 'command')
140
191
  }
141
192
 
142
- /** Remove every policy-owned registration for an agent. */
193
+ /**
194
+ * Remove every policy-owned registration for an agent.
195
+ * @param {PolicyAgent} agent - the agent whose policy is uninstalled.
196
+ */
143
197
  export function uninstallFishPolicy(agent) {
144
198
  liveAgents.delete(agent)
145
199
  removed.add(agent)
@@ -160,7 +214,7 @@ export function uninstallFishPolicy(agent) {
160
214
  * disposers THIS call created.
161
215
  *
162
216
  * @param {import('@deepseek-ai/cordis').Context} ctx - the policy plugin's context.
163
- * @param {object} agent - the live agent (its `ctx` is the agent's scoped context).
217
+ * @param {PolicyAgent} agent - the live agent (its `ctx` is the agent's scoped context).
164
218
  */
165
219
  export function installFishPolicy(ctx, agent) {
166
220
  if (installing.has(agent)) return
@@ -222,6 +276,7 @@ export function installFishPolicy(ctx, agent) {
222
276
  // bash is hidden, so minimal sessions keep persistent semantics.
223
277
  persistentFish = registerPersistentFish(ctx, agent.ctx)
224
278
  }
279
+ /** @type {Array<() => void>} */
225
280
  const created = []
226
281
  try {
227
282
  const restriction = agent.ctx.tools.restrict({ deny: ['bash'] })
@@ -244,7 +299,7 @@ export function installFishPolicy(ctx, agent) {
244
299
  // persistent fish. The previous state's entries were already removed
245
300
  // above, so `states` ends up consistent: no restriction, no shadow, no
246
301
  // persistent fish — bash stays visible and the failure is surfaced.
247
- for (let index = created.length - 1; index >= 0; index -= 1) created[index]()
302
+ for (let index = created.length - 1; index >= 0; index -= 1) created[index]?.()
248
303
  disposeQuietly(persistentFish, 'persistent fish tool', agent)
249
304
  throw error
250
305
  }
@@ -256,11 +311,15 @@ export function installFishPolicy(ctx, agent) {
256
311
  }
257
312
  }
258
313
 
259
- /** Apply the policy to every live agent (no-op when the agents service is
314
+ /**
315
+ * Apply the policy to every live agent (no-op when the agents service is
260
316
  * absent). Re-entrant calls (a registration/disposal mid-round fires
261
317
  * `tools/change` synchronously) are absorbed into at most ONE coalesced
262
318
  * catch-up round, so an external change dispatches a small constant number
263
- * of times regardless of how many agents are live. */
319
+ * of times regardless of how many agents are live.
320
+ * @param {import('@deepseek-ai/cordis').Context} ctx - the policy plugin's context (the `agents` service is resolved from it).
321
+ * @param {boolean} [catchUp] - whether this round is the coalesced catch-up (never re-triggers one).
322
+ */
264
323
  function reconcile(ctx, catchUp = false) {
265
324
  if (reconciling) {
266
325
  reconcilePending = true
package/terminal-fish.js CHANGED
@@ -72,10 +72,66 @@ const DEFAULT_PTY_CONFIG = {
72
72
  exactProbeAfterMs: 150,
73
73
  idleSilenceMs: 3e3,
74
74
  handoffGraceMs: 500,
75
+ // The official default is `0` (tail grace DISABLED, i.e. the pre-`promptTailGraceMs`
76
+ // behaviour). This backend keeps that default rather than inventing a positive
77
+ // one: the fish prompt is verified to arrive whole under `TERM=dumb`, so the
78
+ // tolerance is only there for a caller that asks for it.
79
+ promptTailGraceMs: 0,
75
80
  timeoutMs: 3e4,
76
81
  disposeGraceMs: 3e3,
77
82
  }
78
83
 
84
+ /**
85
+ * The terminal handle surface this backend reads: the cleanup verb a failed
86
+ * session factory must call (the real handle is `SubprocessTerminalHandle`).
87
+ * @typedef {{ terminate(): void | Promise<void> }} TerminalHandleLike
88
+ */
89
+
90
+ /**
91
+ * The fully resolved backend configuration: the public `Config` surface with
92
+ * every default materialized, matching `@deepseek-ai/dsh-terminal-bash`'s
93
+ * `ResolvedConfig` (which that package does not export from its root).
94
+ * @typedef {object} ResolvedBackendConfig
95
+ * @property {string} backendType - backend registry type (`fish` by default).
96
+ * @property {'bash'} shellDialect - the readiness/prompt contract the fish prompt implements.
97
+ * @property {string} shellPath - the fish executable to spawn.
98
+ * @property {string[]} shellArgs - the resolved fish arguments.
99
+ * @property {number} rows - initial terminal rows.
100
+ * @property {number} cols - initial terminal columns.
101
+ * @property {number} scrollbackLines - retained logical lines.
102
+ * @property {number} scrollbackMaxBytes - retained UTF-8 bytes.
103
+ * @property {number} maxReadBytes - one read's byte cap.
104
+ * @property {number} pollIntervalMs - readiness polling interval.
105
+ * @property {number} exactProbeAfterMs - delay before exact syscall probes.
106
+ * @property {number} idleSilenceMs - silence that yields `inferred_idle`.
107
+ * @property {number} handoffGraceMs - extra wait for foreground handoff.
108
+ * @property {number} promptTailGraceMs - extra wait for a controlled prompt prefix to complete; `0` disables the tail grace.
109
+ * @property {number} timeoutMs - readiness deadline for one startup sequence.
110
+ * @property {number} disposeGraceMs - SIGTERM→SIGKILL grace for the PTY session.
111
+ */
112
+
113
+ /**
114
+ * The backend session surface this module drives: `TerminalBackendSession`
115
+ * with the backend's own mutable `motd`. The interface declares that field
116
+ * read-only, while the concrete `LocalPtySession` (and the test doubles) assign
117
+ * the startup banner once readiness settles.
118
+ * @typedef {Omit<import('@deepseek-ai/dsh-terminal').TerminalBackendSession, 'motd'> & { motd: string }} PersistentPtySession
119
+ */
120
+
121
+ /**
122
+ * The `BashTerminalBackend` internals this subclass must reach to override
123
+ * `spawn()`. The upstream declarations mark `ctx`, `config`, `spawnTerminal`,
124
+ * and `createSession` private — and are byte-identical in 0.1.5-rc.1 and
125
+ * 0.1.7-rc.2 — while the JS prototype keeps them as ordinary fields, so the
126
+ * subclass reads them through this one structural view instead of a
127
+ * suppression per access.
128
+ * @typedef {object} BashBackendInternals
129
+ * @property {import('@deepseek-ai/cordis').Context} ctx - the backend's plugin context.
130
+ * @property {ResolvedBackendConfig} config - the resolved backend configuration the base class stores.
131
+ * @property {(spec: object) => Promise<TerminalHandleLike>} spawnTerminal - the subprocess terminal primitive (`ctx.subprocess.spawnTerminal` by default).
132
+ * @property {(terminal: unknown, config: unknown) => any} createSession - the session factory (`new LocalPtySession(...)` by default).
133
+ */
134
+
79
135
  /**
80
136
  * One-line fish prompt setup submitted before readiness: suppresses the
81
137
  * greeting and right prompt, and defines a prompt that emits the shared OSC
@@ -89,44 +145,64 @@ export const FISH_PROMPT_SETUP = 'function fish_greeting; end; function fish_pro
89
145
  * Resolve the effective fish shell specification: an unset or empty
90
146
  * `shellPath`/`shellArgs` selects the defaults, a non-empty explicit value
91
147
  * wins. Mirrors the official `resolveConfig` (minus dialect selection).
92
- * @param config - the plugin configuration.
93
- * @returns the fully resolved configuration.
148
+ * @param {import('@deepseek-ai/dsh-terminal-bash').TerminalLocalConfig} config - the plugin configuration.
149
+ * @returns {ResolvedBackendConfig} the fully resolved configuration.
94
150
  */
95
151
  export function resolveConfig(config) {
96
152
  return {
97
- backendType: config.backendType?.length > 0 ? config.backendType : 'fish',
153
+ // `config.backendType?.length > 0` compared an optional property with `>`
154
+ // (a coercion, not a narrowing); the explicit guard is the same decision
155
+ // and lets the compiler see a `string` in the true branch.
156
+ backendType: config.backendType !== undefined && config.backendType.length > 0 ? config.backendType : 'fish',
157
+ // Inherited `ResolvedConfig` shape, not a dialect choice: this backend
158
+ // overrides `spawn()` with its own child environment and fish prompt, so
159
+ // the field is inert — `bash` names the readiness/prompt contract the
160
+ // fish prompt implements (`dsh-terminal-bash`'s bash branch).
161
+ shellDialect: /** @type {const} */ ('bash'),
98
162
  shellPath: config.shellPath !== undefined && config.shellPath.length > 0 ? config.shellPath : DEFAULT_FISH_SHELL,
99
163
  shellArgs: config.shellArgs !== undefined && config.shellArgs.length > 0 ? config.shellArgs : DEFAULT_FISH_ARGS,
100
164
  ...DEFAULT_PTY_CONFIG,
101
- ...Object.fromEntries(Object.entries(config).filter(([key]) => key in DEFAULT_PTY_CONFIG && config[key] !== undefined)),
165
+ ...Object.fromEntries(Object.entries(config).filter(([key, value]) => key in DEFAULT_PTY_CONFIG && value !== undefined)),
102
166
  }
103
167
  }
104
168
 
105
169
  /**
106
170
  * Validate the effective configuration (mirrors the official
107
171
  * `validateConfig`).
108
- * @param config - the fully resolved configuration.
109
- * @returns the narrowed configuration.
172
+ * @param {ResolvedBackendConfig} config - the fully resolved configuration.
173
+ * @returns {void} throws on a value the backend cannot run with.
110
174
  */
111
175
  export function validateConfig(config) {
112
176
  const resolved = config
113
177
  if (resolved.backendType.length === 0) throw new Error('terminal-fish: backendType must be non-empty')
114
178
  if (resolved.shellPath.length === 0) throw new Error('terminal-fish: shellPath must be non-empty')
115
179
  for (const [field, value] of Object.entries(resolved)) {
180
+ // `promptTailGraceMs` is the one duration that legally reads `0` (it means
181
+ // "no tail grace", which is the official default), so it is checked on its
182
+ // own below instead of by the positive-integer rule.
183
+ if (field === 'promptTailGraceMs') continue
116
184
  if (typeof value === 'number' && (!Number.isSafeInteger(value) || value <= 0)) {
117
185
  throw new Error(`terminal-fish: ${field} must be a positive safe integer`)
118
186
  }
119
187
  }
188
+ if (!Number.isSafeInteger(resolved.promptTailGraceMs) || resolved.promptTailGraceMs < 0) {
189
+ throw new Error('terminal-fish: promptTailGraceMs must be a non-negative safe integer (0 disables the prompt tail grace)')
190
+ }
120
191
  if (resolved.maxReadBytes > resolved.scrollbackMaxBytes) throw new Error('terminal-fish: maxReadBytes must not exceed scrollbackMaxBytes')
121
192
  if (resolved.handoffGraceMs < resolved.pollIntervalMs) throw new Error('terminal-fish: handoffGraceMs must be at least pollIntervalMs so one readiness poll runs inside the grace window')
193
+ // A nonzero tolerance shorter than one poll cannot observe the rest of the
194
+ // prompt, so it would be a silent no-op; the official schema rejects it too.
195
+ if (resolved.promptTailGraceMs !== 0 && resolved.promptTailGraceMs < resolved.pollIntervalMs) {
196
+ throw new Error('terminal-fish: promptTailGraceMs must be zero or at least pollIntervalMs so a nonzero tail grace covers one readiness poll')
197
+ }
122
198
  }
123
199
 
124
200
  /**
125
201
  * Fish child environment: the official bash `childEnvironment` common set
126
202
  * (TERM=dumb so the reader emits no cursor/redraw sequences, PAGER=cat,
127
203
  * harness identity facts) minus the bash-only PS1/PROMPT_COMMAND.
128
- * @param spec - the spawn specification.
129
- * @returns the child environment.
204
+ * @param {import('@deepseek-ai/dsh-terminal').TerminalBackendSpawnSpec} spec - the spawn specification.
205
+ * @returns {Record<string, string>} the child environment.
130
206
  */
131
207
  export function childEnvironment(spec) {
132
208
  return {
@@ -157,7 +233,17 @@ export function childEnvironment(spec) {
157
233
  // ever contributed the fence falls back to the terminals registry probe
158
234
  // (`ctx.terminals` may be absent — missing services resolve to `undefined`
159
235
  // on a Cordis context, never a throw).
236
+ /** Per-owner fence state: the context whose dispatch is watched plus every
237
+ * manager's aggregated activity probe. @type {WeakMap<import('@deepseek-ai/dsh-agent').Agent, { ctx: import('@deepseek-ai/cordis').Context, checkers: Set<() => boolean> }>} */
160
238
  const sandboxModeFences = new WeakMap()
239
+
240
+ /**
241
+ * Install (or refresh) the per-owner sandbox-mode fence and register this
242
+ * manager's owner-activity probe with it.
243
+ * @param {import('@deepseek-ai/cordis').Context} ctx - the context whose `internal/dispatch` the fence listens to.
244
+ * @param {import('@deepseek-ai/dsh-agent').Agent} owner - the live owner whose activity keeps the fence closed.
245
+ * @param {() => boolean} [hasActivity] - this manager's owner-activity probe, when it has one.
246
+ */
161
247
  export function ensureSandboxModeFence(ctx, owner, hasActivity) {
162
248
  let state = sandboxModeFences.get(owner)
163
249
  if (state === undefined) {
@@ -185,21 +271,25 @@ export function ensureSandboxModeFence(ctx, owner, hasActivity) {
185
271
  /**
186
272
  * Confine the fish argv through the shared sandbox policy, exactly like the
187
273
  * official `spawnArgv` (which is private to `@deepseek-ai/dsh-terminal-bash`
188
- * and therefore copied here).
189
- * @param ctx - the plugin context carrying the sandbox provider.
190
- * @param config - the resolved backend configuration.
191
- * @param policy - the resolved execution policy for this session.
192
- * @returns the (possibly confined) argv.
274
+ * and therefore copied here). `SandboxProvider.confine` is ASYNC in
275
+ * 0.1.7-rc.2, so the confined branch MUST be awaited: reading `.argv` off the
276
+ * returned promise yields `undefined`, which made every confined persistent
277
+ * spawn fail (previously masked by a synchronous test double).
278
+ * @param {import('@deepseek-ai/cordis').Context} ctx - the plugin context carrying the sandbox provider.
279
+ * @param {ResolvedBackendConfig} config - the resolved backend configuration the base class stores.
280
+ * @param {import('@deepseek-ai/dsh-sandbox').SandboxExecutionPolicy} policy - the resolved execution policy for this session; the `danger-full-access` early return narrows `mode` to a confined mode for `confine()`.
281
+ * @param {AbortSignal} [signal] - cancellation of confinement preparation.
282
+ * @returns {Promise<string[]>} the (possibly confined) argv.
193
283
  */
194
- export function spawnArgv(ctx, config, policy) {
284
+ export async function spawnArgv(ctx, config, policy, signal) {
195
285
  const argv = [config.shellPath, ...config.shellArgs]
196
286
  if (policy.mode === 'danger-full-access') return argv
197
287
  const sandbox = ctx.get('sandbox')
198
288
  if (sandbox === undefined) throw new Error(`terminal-fish: sandbox mode "${policy.mode}" requires a ctx.sandbox provider in the execution world`)
199
- return sandbox.confine(argv, {
289
+ return (await sandbox.confine(argv, {
200
290
  ...policy,
201
291
  mode: policy.mode,
202
- }).argv
292
+ }, signal)).argv
203
293
  }
204
294
 
205
295
  /**
@@ -207,11 +297,12 @@ export function spawnArgv(ctx, config, policy) {
207
297
  * poll with empty sends until the fish prompt satisfies the shared
208
298
  * readiness contract (OSC `133;D;` + `dsh> ` tail). Mirrors the official
209
299
  * `startupSession` pwsh branch, with a startup deadline race.
210
- * @param session - the backend session.
211
- * @param timeoutMs - readiness deadline.
212
- * @param signal - optional cancellation.
300
+ * @param {PersistentPtySession} session - the backend session to bring up.
301
+ * @param {number} timeoutMs - readiness deadline.
302
+ * @param {AbortSignal} [signal] - optional cancellation.
213
303
  */
214
304
  export async function startupSession(session, timeoutMs, signal) {
305
+ /** @type {import('@deepseek-ai/dsh-terminal').TerminalSendOperation | undefined} */
215
306
  let startupOperation
216
307
  const start = async () => {
217
308
  let viewport = ''
@@ -255,6 +346,25 @@ export async function startupSession(session, timeoutMs, signal) {
255
346
  }
256
347
  }
257
348
 
349
+ /**
350
+ * Reject a failed startup only after its unpublished resources reach
351
+ * quiescence (copied from the official `@deepseek-ai/dsh-terminal-bash`,
352
+ * which does not export it): a cleanup failure is reported as
353
+ * `TerminalBackendCleanupError` so a leaked PTY is distinguishable from the
354
+ * startup failure that caused it.
355
+ * @param {unknown} error - the startup or session-factory failure.
356
+ * @param {() => unknown} cleanup - releases the resource the failure left behind.
357
+ * @returns {Promise<never>} never resolves; always rejects.
358
+ */
359
+ async function rejectAfterStartupCleanup(error, cleanup) {
360
+ try {
361
+ await cleanup()
362
+ } catch (cleanupError) {
363
+ throw new TerminalBackendCleanupError(error, cleanupError)
364
+ }
365
+ throw error
366
+ }
367
+
258
368
  /**
259
369
  * Local fish PTY backend registered under the configured type (`fish`):
260
370
  * spawns `fish --no-config -i` (config-overridable) with the fish child
@@ -264,37 +374,55 @@ export async function startupSession(session, timeoutMs, signal) {
264
374
  * `BashTerminalBackend`/`LocalPtySession`.
265
375
  */
266
376
  export class FishTerminalBackend extends BashTerminalBackend {
377
+ /**
378
+ * Spawn and bring up one fish PTY session for a spec the registry (or the
379
+ * self-managed persistent tool) resolved. Resolves with the ready session
380
+ * the configured session factory built (the base class declares the concrete
381
+ * `LocalPtySession` return, which this package cannot name).
382
+ * @param {import('@deepseek-ai/dsh-terminal').TerminalBackendSpawnSpec} spec - the identified spawn request.
383
+ */
267
384
  async spawn(spec) {
268
385
  spec.signal?.throwIfAborted()
269
- ensureSandboxModeFence(this.ctx, spec.owner)
386
+ // The inherited fields (`ctx`, `config`, `spawnTerminal`, `createSession`)
387
+ // are TS-private upstream, so they are read through the one structural
388
+ // view declared above instead of repeating the same suppression per use.
389
+ const backend = /** @type {BashBackendInternals} */ (/** @type {unknown} */ (this))
390
+ ensureSandboxModeFence(backend.ctx, spec.owner)
270
391
  // Resolve services through `ctx.get` (never property access): the
271
392
  // backend may be constructed with a plugin context whose inject list
272
393
  // does not declare these services (the policy plugin's ctx), and Cordis
273
394
  // property access would throw "cannot get property … without inject".
274
395
  // `ctx.get` resolves any registered service regardless of inject.
275
- const policy = this.ctx.get('sandboxPolicy').resolve({ session: spec.owner.session })
276
- const argv = spawnArgv(this.ctx, this.config, policy)
396
+ const policy = backend.ctx.get('sandboxPolicy').resolve({ session: spec.owner.session })
397
+ const argv = await spawnArgv(backend.ctx, backend.config, policy, spec.signal)
398
+ // Confinement is async, so the caller may have cancelled while it ran.
399
+ spec.signal?.throwIfAborted()
277
400
  if (argv[0] === undefined) throw new Error('terminal-fish: sandbox returned empty argv')
278
- const terminal = await this.spawnTerminal({
401
+ const terminal = await backend.spawnTerminal({
279
402
  argv,
280
403
  cwd: spec.cwd ?? policy.workspaceRoot,
281
404
  env: childEnvironment(spec),
282
- rows: this.config.rows,
283
- cols: this.config.cols,
284
- graceMs: this.config.disposeGraceMs,
405
+ rows: backend.config.rows,
406
+ cols: backend.config.cols,
407
+ // The provider advertises this as the child's `TERM` (it overwrites
408
+ // whatever `env` carried with it), so it must be the same `dumb` the
409
+ // fish prompt readiness contract is verified against.
410
+ terminalType: 'dumb',
411
+ graceMs: backend.config.disposeGraceMs,
285
412
  signal: spec.signal,
286
413
  })
287
- const session = this.createSession(terminal, this.config)
414
+ let session
415
+ try {
416
+ session = backend.createSession(terminal, backend.config)
417
+ } catch (error) {
418
+ // A failed session factory must not leak the freshly allocated terminal.
419
+ return rejectAfterStartupCleanup(error, () => terminal.terminate())
420
+ }
288
421
  try {
289
- await startupSession(session, this.config.timeoutMs, spec.signal)
422
+ await startupSession(session, backend.config.timeoutMs, spec.signal)
290
423
  return session
291
424
  } catch (error) {
292
- try {
293
- await session.close('PTY startup failed')
294
- } catch (closeError) {
295
- throw new TerminalBackendCleanupError(error, closeError)
296
- }
297
- throw error
425
+ return rejectAfterStartupCleanup(error, () => session.close('PTY startup failed'))
298
426
  }
299
427
  }
300
428
  }
@@ -307,8 +435,8 @@ export class FishTerminalBackend extends BashTerminalBackend {
307
435
  * backend up explicitly. Plugin configuration mirrors the official
308
436
  * `@deepseek-ai/dsh-terminal-bash` Config surface (sans dialect), with fish
309
437
  * defaults: backend type `fish`, `fish --no-config -i` argv.
310
- * @param ctx - the harness context.
311
- * @param config - optional backend configuration.
438
+ * @param {import('@deepseek-ai/cordis').Context} ctx - the harness context.
439
+ * @param {import('@deepseek-ai/dsh-terminal-bash').TerminalLocalConfig} [config] - optional backend configuration.
312
440
  */
313
441
  export function apply(ctx, config = {}) {
314
442
  const resolved = resolveConfig(config)