@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/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
@@ -76,6 +76,56 @@ const DEFAULT_PTY_CONFIG = {
76
76
  disposeGraceMs: 3e3,
77
77
  }
78
78
 
79
+ /**
80
+ * The terminal handle surface this backend reads: the cleanup verb a failed
81
+ * session factory must call (the real handle is `SubprocessTerminalHandle`).
82
+ * @typedef {{ terminate(): void | Promise<void> }} TerminalHandleLike
83
+ */
84
+
85
+ /**
86
+ * The fully resolved backend configuration: the public `Config` surface with
87
+ * every default materialized, matching `@deepseek-ai/dsh-terminal-bash`'s
88
+ * `ResolvedConfig` (which that package does not export from its root).
89
+ * @typedef {object} ResolvedBackendConfig
90
+ * @property {string} backendType - backend registry type (`fish` by default).
91
+ * @property {'bash'} shellDialect - the readiness/prompt contract the fish prompt implements.
92
+ * @property {string} shellPath - the fish executable to spawn.
93
+ * @property {string[]} shellArgs - the resolved fish arguments.
94
+ * @property {number} rows - initial terminal rows.
95
+ * @property {number} cols - initial terminal columns.
96
+ * @property {number} scrollbackLines - retained logical lines.
97
+ * @property {number} scrollbackMaxBytes - retained UTF-8 bytes.
98
+ * @property {number} maxReadBytes - one read's byte cap.
99
+ * @property {number} pollIntervalMs - readiness polling interval.
100
+ * @property {number} exactProbeAfterMs - delay before exact syscall probes.
101
+ * @property {number} idleSilenceMs - silence that yields `inferred_idle`.
102
+ * @property {number} handoffGraceMs - extra wait for foreground handoff.
103
+ * @property {number} timeoutMs - readiness deadline for one startup sequence.
104
+ * @property {number} disposeGraceMs - SIGTERM→SIGKILL grace for the PTY session.
105
+ */
106
+
107
+ /**
108
+ * The backend session surface this module drives: `TerminalBackendSession`
109
+ * with the backend's own mutable `motd`. The interface declares that field
110
+ * read-only, while the concrete `LocalPtySession` (and the test doubles) assign
111
+ * the startup banner once readiness settles.
112
+ * @typedef {Omit<import('@deepseek-ai/dsh-terminal').TerminalBackendSession, 'motd'> & { motd: string }} PersistentPtySession
113
+ */
114
+
115
+ /**
116
+ * The `BashTerminalBackend` internals this subclass must reach to override
117
+ * `spawn()`. The upstream declarations mark `ctx`, `config`, `spawnTerminal`,
118
+ * and `createSession` private — and are byte-identical in 0.1.5-rc.1 and
119
+ * 0.1.7-rc.2 — while the JS prototype keeps them as ordinary fields, so the
120
+ * subclass reads them through this one structural view instead of a
121
+ * suppression per access.
122
+ * @typedef {object} BashBackendInternals
123
+ * @property {import('@deepseek-ai/cordis').Context} ctx - the backend's plugin context.
124
+ * @property {ResolvedBackendConfig} config - the resolved backend configuration the base class stores.
125
+ * @property {(spec: object) => Promise<TerminalHandleLike>} spawnTerminal - the subprocess terminal primitive (`ctx.subprocess.spawnTerminal` by default).
126
+ * @property {(terminal: unknown, config: unknown) => any} createSession - the session factory (`new LocalPtySession(...)` by default).
127
+ */
128
+
79
129
  /**
80
130
  * One-line fish prompt setup submitted before readiness: suppresses the
81
131
  * greeting and right prompt, and defines a prompt that emits the shared OSC
@@ -89,24 +139,32 @@ export const FISH_PROMPT_SETUP = 'function fish_greeting; end; function fish_pro
89
139
  * Resolve the effective fish shell specification: an unset or empty
90
140
  * `shellPath`/`shellArgs` selects the defaults, a non-empty explicit value
91
141
  * wins. Mirrors the official `resolveConfig` (minus dialect selection).
92
- * @param config - the plugin configuration.
93
- * @returns the fully resolved configuration.
142
+ * @param {import('@deepseek-ai/dsh-terminal-bash').TerminalLocalConfig} config - the plugin configuration.
143
+ * @returns {ResolvedBackendConfig} the fully resolved configuration.
94
144
  */
95
145
  export function resolveConfig(config) {
96
146
  return {
97
- backendType: config.backendType?.length > 0 ? config.backendType : 'fish',
147
+ // `config.backendType?.length > 0` compared an optional property with `>`
148
+ // (a coercion, not a narrowing); the explicit guard is the same decision
149
+ // and lets the compiler see a `string` in the true branch.
150
+ backendType: config.backendType !== undefined && config.backendType.length > 0 ? config.backendType : 'fish',
151
+ // Inherited `ResolvedConfig` shape, not a dialect choice: this backend
152
+ // overrides `spawn()` with its own child environment and fish prompt, so
153
+ // the field is inert — `bash` names the readiness/prompt contract the
154
+ // fish prompt implements (`dsh-terminal-bash`'s bash branch).
155
+ shellDialect: /** @type {const} */ ('bash'),
98
156
  shellPath: config.shellPath !== undefined && config.shellPath.length > 0 ? config.shellPath : DEFAULT_FISH_SHELL,
99
157
  shellArgs: config.shellArgs !== undefined && config.shellArgs.length > 0 ? config.shellArgs : DEFAULT_FISH_ARGS,
100
158
  ...DEFAULT_PTY_CONFIG,
101
- ...Object.fromEntries(Object.entries(config).filter(([key]) => key in DEFAULT_PTY_CONFIG && config[key] !== undefined)),
159
+ ...Object.fromEntries(Object.entries(config).filter(([key, value]) => key in DEFAULT_PTY_CONFIG && value !== undefined)),
102
160
  }
103
161
  }
104
162
 
105
163
  /**
106
164
  * Validate the effective configuration (mirrors the official
107
165
  * `validateConfig`).
108
- * @param config - the fully resolved configuration.
109
- * @returns the narrowed configuration.
166
+ * @param {ResolvedBackendConfig} config - the fully resolved configuration.
167
+ * @returns {void} throws on a value the backend cannot run with.
110
168
  */
111
169
  export function validateConfig(config) {
112
170
  const resolved = config
@@ -125,8 +183,8 @@ export function validateConfig(config) {
125
183
  * Fish child environment: the official bash `childEnvironment` common set
126
184
  * (TERM=dumb so the reader emits no cursor/redraw sequences, PAGER=cat,
127
185
  * harness identity facts) minus the bash-only PS1/PROMPT_COMMAND.
128
- * @param spec - the spawn specification.
129
- * @returns the child environment.
186
+ * @param {import('@deepseek-ai/dsh-terminal').TerminalBackendSpawnSpec} spec - the spawn specification.
187
+ * @returns {Record<string, string>} the child environment.
130
188
  */
131
189
  export function childEnvironment(spec) {
132
190
  return {
@@ -157,7 +215,17 @@ export function childEnvironment(spec) {
157
215
  // ever contributed the fence falls back to the terminals registry probe
158
216
  // (`ctx.terminals` may be absent — missing services resolve to `undefined`
159
217
  // on a Cordis context, never a throw).
218
+ /** Per-owner fence state: the context whose dispatch is watched plus every
219
+ * manager's aggregated activity probe. @type {WeakMap<import('@deepseek-ai/dsh-agent').Agent, { ctx: import('@deepseek-ai/cordis').Context, checkers: Set<() => boolean> }>} */
160
220
  const sandboxModeFences = new WeakMap()
221
+
222
+ /**
223
+ * Install (or refresh) the per-owner sandbox-mode fence and register this
224
+ * manager's owner-activity probe with it.
225
+ * @param {import('@deepseek-ai/cordis').Context} ctx - the context whose `internal/dispatch` the fence listens to.
226
+ * @param {import('@deepseek-ai/dsh-agent').Agent} owner - the live owner whose activity keeps the fence closed.
227
+ * @param {() => boolean} [hasActivity] - this manager's owner-activity probe, when it has one.
228
+ */
161
229
  export function ensureSandboxModeFence(ctx, owner, hasActivity) {
162
230
  let state = sandboxModeFences.get(owner)
163
231
  if (state === undefined) {
@@ -185,21 +253,25 @@ export function ensureSandboxModeFence(ctx, owner, hasActivity) {
185
253
  /**
186
254
  * Confine the fish argv through the shared sandbox policy, exactly like the
187
255
  * 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.
256
+ * and therefore copied here). `SandboxProvider.confine` is ASYNC in
257
+ * 0.1.7-rc.2, so the confined branch MUST be awaited: reading `.argv` off the
258
+ * returned promise yields `undefined`, which made every confined persistent
259
+ * spawn fail (previously masked by a synchronous test double).
260
+ * @param {import('@deepseek-ai/cordis').Context} ctx - the plugin context carrying the sandbox provider.
261
+ * @param {ResolvedBackendConfig} config - the resolved backend configuration the base class stores.
262
+ * @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()`.
263
+ * @param {AbortSignal} [signal] - cancellation of confinement preparation.
264
+ * @returns {Promise<string[]>} the (possibly confined) argv.
193
265
  */
194
- export function spawnArgv(ctx, config, policy) {
266
+ export async function spawnArgv(ctx, config, policy, signal) {
195
267
  const argv = [config.shellPath, ...config.shellArgs]
196
268
  if (policy.mode === 'danger-full-access') return argv
197
269
  const sandbox = ctx.get('sandbox')
198
270
  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, {
271
+ return (await sandbox.confine(argv, {
200
272
  ...policy,
201
273
  mode: policy.mode,
202
- }).argv
274
+ }, signal)).argv
203
275
  }
204
276
 
205
277
  /**
@@ -207,11 +279,12 @@ export function spawnArgv(ctx, config, policy) {
207
279
  * poll with empty sends until the fish prompt satisfies the shared
208
280
  * readiness contract (OSC `133;D;` + `dsh> ` tail). Mirrors the official
209
281
  * `startupSession` pwsh branch, with a startup deadline race.
210
- * @param session - the backend session.
211
- * @param timeoutMs - readiness deadline.
212
- * @param signal - optional cancellation.
282
+ * @param {PersistentPtySession} session - the backend session to bring up.
283
+ * @param {number} timeoutMs - readiness deadline.
284
+ * @param {AbortSignal} [signal] - optional cancellation.
213
285
  */
214
286
  export async function startupSession(session, timeoutMs, signal) {
287
+ /** @type {import('@deepseek-ai/dsh-terminal').TerminalSendOperation | undefined} */
215
288
  let startupOperation
216
289
  const start = async () => {
217
290
  let viewport = ''
@@ -255,6 +328,25 @@ export async function startupSession(session, timeoutMs, signal) {
255
328
  }
256
329
  }
257
330
 
331
+ /**
332
+ * Reject a failed startup only after its unpublished resources reach
333
+ * quiescence (copied from the official `@deepseek-ai/dsh-terminal-bash`,
334
+ * which does not export it): a cleanup failure is reported as
335
+ * `TerminalBackendCleanupError` so a leaked PTY is distinguishable from the
336
+ * startup failure that caused it.
337
+ * @param {unknown} error - the startup or session-factory failure.
338
+ * @param {() => unknown} cleanup - releases the resource the failure left behind.
339
+ * @returns {Promise<never>} never resolves; always rejects.
340
+ */
341
+ async function rejectAfterStartupCleanup(error, cleanup) {
342
+ try {
343
+ await cleanup()
344
+ } catch (cleanupError) {
345
+ throw new TerminalBackendCleanupError(error, cleanupError)
346
+ }
347
+ throw error
348
+ }
349
+
258
350
  /**
259
351
  * Local fish PTY backend registered under the configured type (`fish`):
260
352
  * spawns `fish --no-config -i` (config-overridable) with the fish child
@@ -264,37 +356,55 @@ export async function startupSession(session, timeoutMs, signal) {
264
356
  * `BashTerminalBackend`/`LocalPtySession`.
265
357
  */
266
358
  export class FishTerminalBackend extends BashTerminalBackend {
359
+ /**
360
+ * Spawn and bring up one fish PTY session for a spec the registry (or the
361
+ * self-managed persistent tool) resolved. Resolves with the ready session
362
+ * the configured session factory built (the base class declares the concrete
363
+ * `LocalPtySession` return, which this package cannot name).
364
+ * @param {import('@deepseek-ai/dsh-terminal').TerminalBackendSpawnSpec} spec - the identified spawn request.
365
+ */
267
366
  async spawn(spec) {
268
367
  spec.signal?.throwIfAborted()
269
- ensureSandboxModeFence(this.ctx, spec.owner)
368
+ // The inherited fields (`ctx`, `config`, `spawnTerminal`, `createSession`)
369
+ // are TS-private upstream, so they are read through the one structural
370
+ // view declared above instead of repeating the same suppression per use.
371
+ const backend = /** @type {BashBackendInternals} */ (/** @type {unknown} */ (this))
372
+ ensureSandboxModeFence(backend.ctx, spec.owner)
270
373
  // Resolve services through `ctx.get` (never property access): the
271
374
  // backend may be constructed with a plugin context whose inject list
272
375
  // does not declare these services (the policy plugin's ctx), and Cordis
273
376
  // property access would throw "cannot get property … without inject".
274
377
  // `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)
378
+ const policy = backend.ctx.get('sandboxPolicy').resolve({ session: spec.owner.session })
379
+ const argv = await spawnArgv(backend.ctx, backend.config, policy, spec.signal)
380
+ // Confinement is async, so the caller may have cancelled while it ran.
381
+ spec.signal?.throwIfAborted()
277
382
  if (argv[0] === undefined) throw new Error('terminal-fish: sandbox returned empty argv')
278
- const terminal = await this.spawnTerminal({
383
+ const terminal = await backend.spawnTerminal({
279
384
  argv,
280
385
  cwd: spec.cwd ?? policy.workspaceRoot,
281
386
  env: childEnvironment(spec),
282
- rows: this.config.rows,
283
- cols: this.config.cols,
284
- graceMs: this.config.disposeGraceMs,
387
+ rows: backend.config.rows,
388
+ cols: backend.config.cols,
389
+ // The provider advertises this as the child's `TERM` (it overwrites
390
+ // whatever `env` carried with it), so it must be the same `dumb` the
391
+ // fish prompt readiness contract is verified against.
392
+ terminalType: 'dumb',
393
+ graceMs: backend.config.disposeGraceMs,
285
394
  signal: spec.signal,
286
395
  })
287
- const session = this.createSession(terminal, this.config)
396
+ let session
397
+ try {
398
+ session = backend.createSession(terminal, backend.config)
399
+ } catch (error) {
400
+ // A failed session factory must not leak the freshly allocated terminal.
401
+ return rejectAfterStartupCleanup(error, () => terminal.terminate())
402
+ }
288
403
  try {
289
- await startupSession(session, this.config.timeoutMs, spec.signal)
404
+ await startupSession(session, backend.config.timeoutMs, spec.signal)
290
405
  return session
291
406
  } catch (error) {
292
- try {
293
- await session.close('PTY startup failed')
294
- } catch (closeError) {
295
- throw new TerminalBackendCleanupError(error, closeError)
296
- }
297
- throw error
407
+ return rejectAfterStartupCleanup(error, () => session.close('PTY startup failed'))
298
408
  }
299
409
  }
300
410
  }
@@ -307,8 +417,8 @@ export class FishTerminalBackend extends BashTerminalBackend {
307
417
  * backend up explicitly. Plugin configuration mirrors the official
308
418
  * `@deepseek-ai/dsh-terminal-bash` Config surface (sans dialect), with fish
309
419
  * defaults: backend type `fish`, `fish --no-config -i` argv.
310
- * @param ctx - the harness context.
311
- * @param config - optional backend configuration.
420
+ * @param {import('@deepseek-ai/cordis').Context} ctx - the harness context.
421
+ * @param {import('@deepseek-ai/dsh-terminal-bash').TerminalLocalConfig} [config] - optional backend configuration.
312
422
  */
313
423
  export function apply(ctx, config = {}) {
314
424
  const resolved = resolveConfig(config)