@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/README.md +158 -41
- package/README.zh.md +63 -0
- package/cordis.patch.yml +39 -32
- package/index.js +60 -33
- package/job-kind.d.ts +24 -0
- package/local.js +13 -14
- package/package.json +52 -14
- package/persistent.js +832 -0
- package/policy.js +405 -0
- package/terminal-fish.js +427 -0
- package/tool.js +546 -112
- package/presets/fish/agent.cordis.yml +0 -241
package/terminal-fish.js
ADDED
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Library: the persistent `fish` PTY backend for DeepSeek Harness — a
|
|
3
|
+
* `BashTerminalBackend` subclass that spawns `fish --no-config -i`
|
|
4
|
+
* (config-overridable) instead of bash, with a fish prompt that speaks the
|
|
5
|
+
* shared `LocalPtySession` readiness contract (OSC `133;D;<status>` + a
|
|
6
|
+
* `dsh> ` prompt tail), and the same sandbox confinement and mode fence as
|
|
7
|
+
* the official bash backend.
|
|
8
|
+
*
|
|
9
|
+
* The readiness contract was verified against real fish 4.0.0: with
|
|
10
|
+
* `TERM=dumb` (the backend's child environment, same as official bash),
|
|
11
|
+
* fish's reader emits no cursor/redraw sequences after the prompt text, so
|
|
12
|
+
* the sanitizer sees exactly `dsh> ` after the OSC marker and readiness
|
|
13
|
+
* settles promptly. The prompt setup is submitted as ONE line (semicolon
|
|
14
|
+
* function bodies) and builds the ESC/BEL bytes at runtime through fish
|
|
15
|
+
* `printf` escapes (`\x1b`, `\x07`), mirroring the official pwsh prompt's
|
|
16
|
+
* runtime-escape approach — raw ESC bytes in submitted PTY input are
|
|
17
|
+
* unreliable under a line editor.
|
|
18
|
+
*
|
|
19
|
+
* This module is NOT mounted by the bundle patch (there is no `fish-terminal`
|
|
20
|
+
* host row): the `terminals` service is entry-local to the `minimal` preset
|
|
21
|
+
* and invisible at the host/agent plane, so a patch row would stay pending
|
|
22
|
+
* forever in host-plane profiles. Instead the policy's persistent tool
|
|
23
|
+
* (`persistent.js`) constructs one `FishTerminalBackend` itself and drives
|
|
24
|
+
* its sessions directly. `apply` below is kept for direct assembly and
|
|
25
|
+
* tests (a context that already mounts the `terminals` registry).
|
|
26
|
+
*
|
|
27
|
+
* `@deepseek-ai/dsh-terminal-bash` does not export its private
|
|
28
|
+
* `ensureSandboxModeFence`/`spawnArgv`/`startupSession` helpers, so the
|
|
29
|
+
* minimal pieces this backend needs are copied here (marked with their
|
|
30
|
+
* official source) rather than monkey-patching the official module.
|
|
31
|
+
*
|
|
32
|
+
* @module @banbolee/dsh-fish-shell/terminal-fish
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import { BashTerminalBackend } from '@deepseek-ai/dsh-terminal-bash'
|
|
36
|
+
import { TerminalBackendCleanupError } from '@deepseek-ai/dsh-terminal'
|
|
37
|
+
|
|
38
|
+
/** Cordis plugin name (used only for direct assembly/tests; the bundle patch
|
|
39
|
+
* does NOT mount this plugin row — see the module doc). */
|
|
40
|
+
export const name = 'fish-terminal'
|
|
41
|
+
/** Required services: terminal registry, shared confinement policy, projection registry, and process substrate. */
|
|
42
|
+
export const inject = [
|
|
43
|
+
'terminals',
|
|
44
|
+
'sandboxPolicy',
|
|
45
|
+
'sessionProjections',
|
|
46
|
+
'subprocess',
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
/** Exact printable prompt emitted after the private marker (shared with the
|
|
50
|
+
* official bash/pwsh backends' readiness contract). */
|
|
51
|
+
const CONTROLLED_PROMPT = 'dsh> '
|
|
52
|
+
/** Default fish executable (resolved on PATH by the subprocess provider). */
|
|
53
|
+
const DEFAULT_FISH_SHELL = 'fish'
|
|
54
|
+
/** Default fish arguments: interactive, without user config interference.
|
|
55
|
+
* `--no-config` exists since fish 3.3 (the README is POSIX-only anyway) and
|
|
56
|
+
* avoids a user `fish_prompt`/alias shadowing the controlled prompt. */
|
|
57
|
+
const DEFAULT_FISH_ARGS = ['--no-config', '-i']
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Defaults for the shared PTY/readiness surface, mirroring the official
|
|
61
|
+
* `@deepseek-ai/dsh-terminal-bash` Config schema (the official module
|
|
62
|
+
* materializes them through schemastery defaults; this backend has no
|
|
63
|
+
* schemastery dependency, so defaulting is explicit here).
|
|
64
|
+
*/
|
|
65
|
+
const DEFAULT_PTY_CONFIG = {
|
|
66
|
+
rows: 40,
|
|
67
|
+
cols: 160,
|
|
68
|
+
scrollbackLines: 1e4,
|
|
69
|
+
scrollbackMaxBytes: 4 * 1024 * 1024,
|
|
70
|
+
maxReadBytes: 256 * 1024,
|
|
71
|
+
pollIntervalMs: 50,
|
|
72
|
+
exactProbeAfterMs: 150,
|
|
73
|
+
idleSilenceMs: 3e3,
|
|
74
|
+
handoffGraceMs: 500,
|
|
75
|
+
timeoutMs: 3e4,
|
|
76
|
+
disposeGraceMs: 3e3,
|
|
77
|
+
}
|
|
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
|
+
|
|
129
|
+
/**
|
|
130
|
+
* One-line fish prompt setup submitted before readiness: suppresses the
|
|
131
|
+
* greeting and right prompt, and defines a prompt that emits the shared OSC
|
|
132
|
+
* `133;D;<status>` + BEL marker followed by exactly `dsh> `. ESC/BEL are
|
|
133
|
+
* built at runtime via fish `printf` escapes (no raw control bytes in
|
|
134
|
+
* submitted input; verified against fish 4.0.0).
|
|
135
|
+
*/
|
|
136
|
+
export const FISH_PROMPT_SETUP = 'function fish_greeting; end; function fish_prompt; printf \'\\x1b]133;D;%s\\x07\' $status; echo -n \'dsh> \'; end; function fish_right_prompt; end;'
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Resolve the effective fish shell specification: an unset or empty
|
|
140
|
+
* `shellPath`/`shellArgs` selects the defaults, a non-empty explicit value
|
|
141
|
+
* wins. Mirrors the official `resolveConfig` (minus dialect selection).
|
|
142
|
+
* @param {import('@deepseek-ai/dsh-terminal-bash').TerminalLocalConfig} config - the plugin configuration.
|
|
143
|
+
* @returns {ResolvedBackendConfig} the fully resolved configuration.
|
|
144
|
+
*/
|
|
145
|
+
export function resolveConfig(config) {
|
|
146
|
+
return {
|
|
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'),
|
|
156
|
+
shellPath: config.shellPath !== undefined && config.shellPath.length > 0 ? config.shellPath : DEFAULT_FISH_SHELL,
|
|
157
|
+
shellArgs: config.shellArgs !== undefined && config.shellArgs.length > 0 ? config.shellArgs : DEFAULT_FISH_ARGS,
|
|
158
|
+
...DEFAULT_PTY_CONFIG,
|
|
159
|
+
...Object.fromEntries(Object.entries(config).filter(([key, value]) => key in DEFAULT_PTY_CONFIG && value !== undefined)),
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Validate the effective configuration (mirrors the official
|
|
165
|
+
* `validateConfig`).
|
|
166
|
+
* @param {ResolvedBackendConfig} config - the fully resolved configuration.
|
|
167
|
+
* @returns {void} throws on a value the backend cannot run with.
|
|
168
|
+
*/
|
|
169
|
+
export function validateConfig(config) {
|
|
170
|
+
const resolved = config
|
|
171
|
+
if (resolved.backendType.length === 0) throw new Error('terminal-fish: backendType must be non-empty')
|
|
172
|
+
if (resolved.shellPath.length === 0) throw new Error('terminal-fish: shellPath must be non-empty')
|
|
173
|
+
for (const [field, value] of Object.entries(resolved)) {
|
|
174
|
+
if (typeof value === 'number' && (!Number.isSafeInteger(value) || value <= 0)) {
|
|
175
|
+
throw new Error(`terminal-fish: ${field} must be a positive safe integer`)
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
if (resolved.maxReadBytes > resolved.scrollbackMaxBytes) throw new Error('terminal-fish: maxReadBytes must not exceed scrollbackMaxBytes')
|
|
179
|
+
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')
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Fish child environment: the official bash `childEnvironment` common set
|
|
184
|
+
* (TERM=dumb so the reader emits no cursor/redraw sequences, PAGER=cat,
|
|
185
|
+
* harness identity facts) minus the bash-only PS1/PROMPT_COMMAND.
|
|
186
|
+
* @param {import('@deepseek-ai/dsh-terminal').TerminalBackendSpawnSpec} spec - the spawn specification.
|
|
187
|
+
* @returns {Record<string, string>} the child environment.
|
|
188
|
+
*/
|
|
189
|
+
export function childEnvironment(spec) {
|
|
190
|
+
return {
|
|
191
|
+
TERM: 'dumb',
|
|
192
|
+
PAGER: 'cat',
|
|
193
|
+
GIT_PAGER: 'cat',
|
|
194
|
+
DSH_SHELL: '1',
|
|
195
|
+
DSH_SESSION_ID: spec.owner.id,
|
|
196
|
+
DSH_PTY_SESSION_ID: spec.sessionId,
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Copied from the official `@deepseek-ai/dsh-terminal-bash` (not exported
|
|
201
|
+
// there): a per-owner fence refusing `sandbox/mode` changes while persistent
|
|
202
|
+
// terminal sessions are open or being created.
|
|
203
|
+
//
|
|
204
|
+
// Self-managed adaptation: the official copy asks the terminals registry
|
|
205
|
+
// (`state.pty.hasOwnerActivity(owner)`) whether the owner has activity, but
|
|
206
|
+
// the policy's persistent tool drives sessions WITHOUT a registry. Activity
|
|
207
|
+
// checkers are therefore injectable and AGGREGATED per owner: every
|
|
208
|
+
// `registerPersistentFish` manager that spawned for an owner contributes its
|
|
209
|
+
// own checker (the manager's owner→session tracking), and the fence refuses
|
|
210
|
+
// the mode change while ANY checker reports activity. Aggregation matters
|
|
211
|
+
// because managers can churn (persistent→one-shot→persistent): a single-slot
|
|
212
|
+
// overwrite would silently forget the first manager's still-open sessions.
|
|
213
|
+
// A call without a checker (the backend's own fence probe on every spawn)
|
|
214
|
+
// adds nothing, so it cannot clobber policy checkers; when no checker was
|
|
215
|
+
// ever contributed the fence falls back to the terminals registry probe
|
|
216
|
+
// (`ctx.terminals` may be absent — missing services resolve to `undefined`
|
|
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> }>} */
|
|
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
|
+
*/
|
|
229
|
+
export function ensureSandboxModeFence(ctx, owner, hasActivity) {
|
|
230
|
+
let state = sandboxModeFences.get(owner)
|
|
231
|
+
if (state === undefined) {
|
|
232
|
+
state = { ctx, checkers: new Set() }
|
|
233
|
+
sandboxModeFences.set(owner, state)
|
|
234
|
+
owner.ctx.on('internal/dispatch', (_mode, eventName, args) => {
|
|
235
|
+
if (eventName !== 'session/event') return
|
|
236
|
+
const [session, event] = args
|
|
237
|
+
if (session !== owner.session || event.type !== 'sandbox/mode') return
|
|
238
|
+
const current = sandboxModeFences.get(owner)
|
|
239
|
+
if (current === undefined) return
|
|
240
|
+
const currentMode = current.ctx.get('sessionProjections')?.stateOf(session, 'sandboxMode') ?? current.ctx.get('sandboxPolicy')?.defaultMode
|
|
241
|
+
if (event.data.mode === currentMode) return
|
|
242
|
+
const active = current.checkers.size > 0
|
|
243
|
+
? [...current.checkers].some((check) => check())
|
|
244
|
+
: current.ctx.get('terminals')?.hasOwnerActivity(owner) ?? false
|
|
245
|
+
if (active) throw new Error(`cannot change sandbox mode from "${currentMode}" to "${event.data.mode}" while persistent terminal sessions are open or being created; wait for creation to settle and close them first`)
|
|
246
|
+
}, { global: true })
|
|
247
|
+
} else {
|
|
248
|
+
state.ctx = ctx
|
|
249
|
+
}
|
|
250
|
+
if (hasActivity !== undefined) state.checkers.add(hasActivity)
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Confine the fish argv through the shared sandbox policy, exactly like the
|
|
255
|
+
* official `spawnArgv` (which is private to `@deepseek-ai/dsh-terminal-bash`
|
|
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.
|
|
265
|
+
*/
|
|
266
|
+
export async function spawnArgv(ctx, config, policy, signal) {
|
|
267
|
+
const argv = [config.shellPath, ...config.shellArgs]
|
|
268
|
+
if (policy.mode === 'danger-full-access') return argv
|
|
269
|
+
const sandbox = ctx.get('sandbox')
|
|
270
|
+
if (sandbox === undefined) throw new Error(`terminal-fish: sandbox mode "${policy.mode}" requires a ctx.sandbox provider in the execution world`)
|
|
271
|
+
return (await sandbox.confine(argv, {
|
|
272
|
+
...policy,
|
|
273
|
+
mode: policy.mode,
|
|
274
|
+
}, signal)).argv
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Bring the fish session to readiness: submit the prompt setup once, then
|
|
279
|
+
* poll with empty sends until the fish prompt satisfies the shared
|
|
280
|
+
* readiness contract (OSC `133;D;` + `dsh> ` tail). Mirrors the official
|
|
281
|
+
* `startupSession` pwsh branch, with a startup deadline race.
|
|
282
|
+
* @param {PersistentPtySession} session - the backend session to bring up.
|
|
283
|
+
* @param {number} timeoutMs - readiness deadline.
|
|
284
|
+
* @param {AbortSignal} [signal] - optional cancellation.
|
|
285
|
+
*/
|
|
286
|
+
export async function startupSession(session, timeoutMs, signal) {
|
|
287
|
+
/** @type {import('@deepseek-ai/dsh-terminal').TerminalSendOperation | undefined} */
|
|
288
|
+
let startupOperation
|
|
289
|
+
const start = async () => {
|
|
290
|
+
let viewport = ''
|
|
291
|
+
for (;;) {
|
|
292
|
+
const first = viewport.length === 0
|
|
293
|
+
startupOperation = session.startSend({
|
|
294
|
+
text: first ? FISH_PROMPT_SETUP : '',
|
|
295
|
+
submit: first,
|
|
296
|
+
...(signal !== undefined ? { signal } : {}),
|
|
297
|
+
})
|
|
298
|
+
const result = await startupOperation.done
|
|
299
|
+
if (result.waitReason === 'session_exit') throw new Error('PTY shell exited during startup')
|
|
300
|
+
if (result.waitReason === 'timeout') throw new Error('PTY shell did not reach readiness before startup timeout')
|
|
301
|
+
viewport = result.viewport
|
|
302
|
+
if (result.waitReason === 'stdin_read') break
|
|
303
|
+
}
|
|
304
|
+
session.motd = viewport
|
|
305
|
+
}
|
|
306
|
+
const races = []
|
|
307
|
+
let onAbort
|
|
308
|
+
if (signal !== undefined) {
|
|
309
|
+
const aborted = Promise.withResolvers()
|
|
310
|
+
onAbort = () => {
|
|
311
|
+
aborted.reject(signal.reason)
|
|
312
|
+
}
|
|
313
|
+
signal.addEventListener('abort', onAbort, { once: true })
|
|
314
|
+
races.push(aborted.promise)
|
|
315
|
+
}
|
|
316
|
+
const deadline = Promise.withResolvers()
|
|
317
|
+
const deadlineTimer = setTimeout(() => {
|
|
318
|
+
startupOperation?.cancel()
|
|
319
|
+
deadline.reject(new Error('PTY shell did not reach readiness before startup timeout'))
|
|
320
|
+
}, timeoutMs)
|
|
321
|
+
races.push(deadline.promise)
|
|
322
|
+
try {
|
|
323
|
+
signal?.throwIfAborted()
|
|
324
|
+
await Promise.race([start(), ...races])
|
|
325
|
+
} finally {
|
|
326
|
+
clearTimeout(deadlineTimer)
|
|
327
|
+
if (signal !== undefined && onAbort !== undefined) signal.removeEventListener('abort', onAbort)
|
|
328
|
+
}
|
|
329
|
+
}
|
|
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
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Local fish PTY backend registered under the configured type (`fish`):
|
|
352
|
+
* spawns `fish --no-config -i` (config-overridable) with the fish child
|
|
353
|
+
* environment, confined through the shared sandbox policy, and brings it to
|
|
354
|
+
* readiness with the fish prompt setup. Everything else (bounded output,
|
|
355
|
+
* readiness polling, terminal-protocol replies, cleanup) is inherited from
|
|
356
|
+
* `BashTerminalBackend`/`LocalPtySession`.
|
|
357
|
+
*/
|
|
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
|
+
*/
|
|
366
|
+
async spawn(spec) {
|
|
367
|
+
spec.signal?.throwIfAborted()
|
|
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)
|
|
373
|
+
// Resolve services through `ctx.get` (never property access): the
|
|
374
|
+
// backend may be constructed with a plugin context whose inject list
|
|
375
|
+
// does not declare these services (the policy plugin's ctx), and Cordis
|
|
376
|
+
// property access would throw "cannot get property … without inject".
|
|
377
|
+
// `ctx.get` resolves any registered service regardless of inject.
|
|
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()
|
|
382
|
+
if (argv[0] === undefined) throw new Error('terminal-fish: sandbox returned empty argv')
|
|
383
|
+
const terminal = await backend.spawnTerminal({
|
|
384
|
+
argv,
|
|
385
|
+
cwd: spec.cwd ?? policy.workspaceRoot,
|
|
386
|
+
env: childEnvironment(spec),
|
|
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,
|
|
394
|
+
signal: spec.signal,
|
|
395
|
+
})
|
|
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
|
+
}
|
|
403
|
+
try {
|
|
404
|
+
await startupSession(session, backend.config.timeoutMs, spec.signal)
|
|
405
|
+
return session
|
|
406
|
+
} catch (error) {
|
|
407
|
+
return rejectAfterStartupCleanup(error, () => session.close('PTY startup failed'))
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Register the local fish PTY backend in a `terminals` registry. The bundle
|
|
414
|
+
* patch does NOT mount this plugin (host-plane profiles have no `terminals`
|
|
415
|
+
* service); this entry point is for direct assembly and tests — and for the
|
|
416
|
+
* real `minimal` preset composition, whose entry-local registry can pick the
|
|
417
|
+
* backend up explicitly. Plugin configuration mirrors the official
|
|
418
|
+
* `@deepseek-ai/dsh-terminal-bash` Config surface (sans dialect), with fish
|
|
419
|
+
* defaults: backend type `fish`, `fish --no-config -i` argv.
|
|
420
|
+
* @param {import('@deepseek-ai/cordis').Context} ctx - the harness context.
|
|
421
|
+
* @param {import('@deepseek-ai/dsh-terminal-bash').TerminalLocalConfig} [config] - optional backend configuration.
|
|
422
|
+
*/
|
|
423
|
+
export function apply(ctx, config = {}) {
|
|
424
|
+
const resolved = resolveConfig(config)
|
|
425
|
+
validateConfig(resolved)
|
|
426
|
+
ctx.terminals.registerBackend(new FishTerminalBackend(ctx, resolved))
|
|
427
|
+
}
|