@banbolee/dsh-fish-shell 0.4.1 → 0.5.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 ADDED
@@ -0,0 +1,346 @@
1
+ /**
2
+ * Per-agent fish policy for DeepSeek Harness: whatever agent preset a session
3
+ * uses (standard, ptc, cordis, minimal, or a third-party preset), its agent
4
+ * must see the host-global `fish` tool and never a `bash` tool inherited from
5
+ * the preset's standing scope.
6
+ *
7
+ * The bundle's patch (`cordis.patch.yml`) disables the host bash executor and
8
+ * host `tool-bash`, mounts the fish executor and the host-global `fish` tool,
9
+ * and NO LONGER changes the preset roster default. Presets keep registering
10
+ * their own `bash`-named tool (standard/minimal do), and the model would see
11
+ * a lying "bash" card that actually executes fish — or, under `minimal`, a
12
+ * real persistent PTY bash. This plugin fixes that at the agent boundary:
13
+ *
14
+ * - `agent/created` (fired synchronously at the agent publication edge,
15
+ * before its first turn — the same hook dsh-tool-subagent uses) applies
16
+ * the policy to the new agent.
17
+ * - `tools/change` (fired after any preset recompose, e.g. `/preset`)
18
+ * reconciles every live agent. The reconcile is re-entrancy-guarded: the
19
+ * policy's own restriction/prompt/persistent registrations and disposals
20
+ * synchronously fire `tools/change` themselves, and without a guard one
21
+ * external change cascades into a factorial dispatch storm across agents.
22
+ * Events arriving mid-round are coalesced into at most ONE catch-up
23
+ * round, and agents whose preset parent scope is unchanged are skipped
24
+ * entirely (their installed state — including the persistent fish tool —
25
+ * is left untouched).
26
+ * - When the inherited `bash` tool is the PERSISTENT form (the official
27
+ * `dsh-tool-bash-persistent` signature: parameters hold only `command`
28
+ * and the output schema is a plain string — the `minimal` preset), the
29
+ * policy first registers a persistent `fish` tool at the agent scope
30
+ * (`persistent.js`), which shadows the host-global one-shot fish, so
31
+ * minimal sessions keep their persistent semantics (cwd, variables,
32
+ * functions) in fish. On a preset switch the previous persistent fish is
33
+ * removed BEFORE probing fish visibility: an agent-scope persistent fish
34
+ * must never count as "inherited fish visible", or a switch into a
35
+ * deny-fish allowlist preset would wrongly keep bash hidden.
36
+ * - `tools.restrict({ deny: ['bash'] })` through `agent.ctx` filters the
37
+ * bash the agent INHERITS from the global and preset-ancestor layers.
38
+ * - The preset's static `tool:bash` prompt section is not removed by the
39
+ * restriction, so an empty agent-scoped section with the same name
40
+ * shadows it (scoped sections shadow ancestor/global ones). Installation
41
+ * is transactional: a failure (e.g. a third-party agent that registered
42
+ * its own `tool:bash` section) rolls back everything THIS call created,
43
+ * so no half-installed restriction leaks.
44
+ * - Plugin teardown (unload/disable/HMR) strips the policy from every live
45
+ * agent: the bash restriction, the `tool:bash` shadow, and any
46
+ * persistent fish registration are removed so nothing outlives the
47
+ * plugin.
48
+ *
49
+ * The plugin uses the injected `tools` / `systemPrompt` services and
50
+ * `ctx.get('agents')`; `persistent.js` (imported only for the persistent
51
+ * form) brings in `@deepseek-ai/dsh-tools`' `defineTool`, and
52
+ * `@deepseek-ai/dsh-scope`'s `scopeParentOf` identifies the preset standing
53
+ * scope an agent's policy was installed under.
54
+ *
55
+ * @module @banbolee/dsh-fish-shell/policy
56
+ */
57
+
58
+ import { scopeParentOf } from '@deepseek-ai/dsh-scope'
59
+ import { registerPersistentFish } from './persistent.js'
60
+
61
+ export const name = 'fish-preset-policy'
62
+ export const inject = ['tools', 'systemPrompt']
63
+
64
+ const PLUGIN_TAG = '@banbolee/dsh-fish-shell'
65
+
66
+ /** Agents currently being reconciled (guards the synchronous tools/change
67
+ * re-entry that restriction/prompt registration and disposal trigger). */
68
+ const installing = new WeakSet()
69
+ /** Agents already warned about an invisible fish tool (warn once per agent). */
70
+ const warned = new WeakSet()
71
+ /** Agents uninstalled via `agent/disposed`: a tools/change fired by the
72
+ * uninstall's own disposals must not re-install a dying agent. */
73
+ const removed = new WeakSet()
74
+ /** Per-agent policy state, including disposers that must move with /preset. */
75
+ const states = new WeakMap()
76
+ /** Live agents with policy installed; traversable so plugin teardown can
77
+ * strip every registration (a WeakMap would be unreachable on teardown). */
78
+ const liveAgents = new Set()
79
+ /** Global reconcile re-entrancy guard: a tools/change arriving while a
80
+ * reconcile round is in flight marks `reconcilePending` instead of starting a
81
+ * nested round; the current round runs exactly one coalesced catch-up round
82
+ * afterwards. */
83
+ let reconciling = false
84
+ let reconcilePending = false
85
+
86
+ function isPlainObject(value) {
87
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
88
+ }
89
+
90
+ function disposeQuietly(disposer, label, agent) {
91
+ if (disposer === undefined) return
92
+ try {
93
+ const result = disposer()
94
+ // 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') {
97
+ void Promise.resolve(result).catch((error) => {
98
+ console.warn(`${PLUGIN_TAG}: could not dispose ${label} for agent ${agent.id ?? '(unnamed)'}:`, String(error))
99
+ })
100
+ }
101
+ } catch (error) {
102
+ console.warn(`${PLUGIN_TAG}: could not dispose ${label} for agent ${agent.id ?? '(unnamed)'}:`, String(error))
103
+ }
104
+ }
105
+
106
+ function disposeState(agent, state) {
107
+ disposeQuietly(state.prompt, 'tool:bash prompt shadow', agent)
108
+ disposeQuietly(state.restriction, 'bash restriction', agent)
109
+ disposeQuietly(state.persistentFish, 'persistent fish tool', agent)
110
+ }
111
+
112
+ function removeTransientPolicy(agent, state) {
113
+ disposeQuietly(state?.prompt, 'tool:bash prompt shadow', agent)
114
+ disposeQuietly(state?.restriction, 'bash restriction', agent)
115
+ }
116
+
117
+ /**
118
+ * Detect the official persistent-bash tool signature (`dsh-tool-bash-
119
+ * persistent`): a definition whose parameters carry ONLY `command` and whose
120
+ * output schema is a plain string. The one-shot bash tool (command +
121
+ * description + workdir + timeoutMs + run_in_background +
122
+ * 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.
125
+ */
126
+ export function isPersistentBashForm(definition) {
127
+ if (!isPlainObject(definition)) return false
128
+ const schema = definition.output?.schema
129
+ if (!isPlainObject(schema) || schema.type !== 'string') return false
130
+ const parameters = definition.parameters
131
+ if (!isPlainObject(parameters)) return false
132
+ // Official persistent bash: parameters is a property map holding ONLY
133
+ // `command` (schemastery shape, no JSON-Schema wrapper).
134
+ const keys = Object.keys(parameters)
135
+ if (keys.length === 1 && keys[0] === 'command') return true
136
+ // Defensive: a JSON-Schema-normalized variant with `properties` holding
137
+ // only `command`.
138
+ const properties = parameters.properties
139
+ return isPlainObject(properties) && Object.keys(properties).length === 1 && Object.hasOwn(properties, 'command')
140
+ }
141
+
142
+ /** Remove every policy-owned registration for an agent. */
143
+ export function uninstallFishPolicy(agent) {
144
+ liveAgents.delete(agent)
145
+ removed.add(agent)
146
+ const state = states.get(agent)
147
+ if (state === undefined) return
148
+ states.delete(agent)
149
+ disposeState(agent, state)
150
+ }
151
+
152
+ /**
153
+ * Apply the fish policy to one agent: hide its inherited `bash` tool and
154
+ * shadow the `tool:bash` prompt guidance, leaving only the appropriate `fish`
155
+ * tool. Recomputed on every preset switch (the agent's parent scope key
156
+ * changed); an unchanged preset parent makes the call a no-op so unrelated
157
+ * `tools/change` events cannot churn the restriction or reset the persistent
158
+ * fish. Installation is transactional: nothing is committed to `states`
159
+ * until every registration succeeded, and a failure rolls back only the
160
+ * disposers THIS call created.
161
+ *
162
+ * @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).
164
+ */
165
+ export function installFishPolicy(ctx, agent) {
166
+ if (installing.has(agent)) return
167
+ // The agent was uninstalled (agent/disposed): a tools/change fired by that
168
+ // uninstall's own disposals must not re-install a dying agent.
169
+ if (removed.has(agent)) return
170
+
171
+ installing.add(agent)
172
+ const previous = states.get(agent)
173
+ const parentKey = scopeParentOf(agent)
174
+ try {
175
+ // Fast path: the agent already has a policy installed under the SAME
176
+ // preset parent scope. Nothing relevant changed for this agent, so an
177
+ // unrelated tools/change (host tools, other agents' registrations) must
178
+ // not dispose and re-register anything — that churn is what turned one
179
+ // external change into a factorial dispatch storm across agents, and it
180
+ // would reset a persistent fish (dropping its PTY) for no reason.
181
+ if (previous !== undefined && previous.parentKey === parentKey) return
182
+
183
+ // Preset switch or first install: lift everything this policy owns so the
184
+ // probes below see the target preset's true inherited surface. The old
185
+ // persistent fish is removed BEFORE probing fish visibility — an
186
+ // agent-scope persistent fish would otherwise count as "fish visible" and
187
+ // let a deny-fish allowlist preset pass through with bash still hidden.
188
+ removeTransientPolicy(agent, previous)
189
+ disposeQuietly(previous?.persistentFish, 'persistent fish tool', agent)
190
+
191
+ const bash = ctx.tools.get('bash', agent)
192
+ if (bash === undefined) {
193
+ states.delete(agent)
194
+ return
195
+ }
196
+
197
+ const wantsPersistent = isPersistentBashForm(bash)
198
+ const fish = ctx.tools.get('fish', agent)
199
+ if (fish === undefined) {
200
+ // The preset deliberately excludes the global fish tool (an allowlist),
201
+ // so there is nothing to swap bash for — leave the preset as authored and
202
+ // warn once per agent instead of mutating it.
203
+ if (!warned.has(agent)) {
204
+ warned.add(agent)
205
+ console.warn(
206
+ `${PLUGIN_TAG}: agent ${agent.id ?? '(unnamed)'} has no visible fish tool `
207
+ + '(its preset allowlist excludes the global fish tool); leaving its inherited bash tool in place',
208
+ )
209
+ }
210
+ states.delete(agent)
211
+ return
212
+ }
213
+
214
+ // Transactional install. `persistentFish` is created by THIS call (the
215
+ // previous one, if any, was already removed above as part of the switched-
216
+ // away state), so the failure path below rolls back exactly what this call
217
+ // created and never destroys a registration it reused.
218
+ let persistentFish
219
+ if (wantsPersistent) {
220
+ // The minimal preset's persistent bash is swapped for persistent fish
221
+ // at the agent scope (shadowing the host-global one-shot fish) BEFORE
222
+ // bash is hidden, so minimal sessions keep persistent semantics.
223
+ persistentFish = registerPersistentFish(ctx, agent.ctx)
224
+ }
225
+ const created = []
226
+ try {
227
+ const restriction = agent.ctx.tools.restrict({ deny: ['bash'] })
228
+ created.push(() => disposeQuietly(restriction, 'bash restriction', agent))
229
+ const prompt = agent.ctx.systemPrompt.section({
230
+ name: 'tool:bash',
231
+ order: agent.ctx.systemPrompt.getSectionOrder('TOOL_BASH'),
232
+ text: '',
233
+ })
234
+ created.push(() => disposeQuietly(prompt, 'tool:bash prompt shadow', agent))
235
+ states.set(agent, {
236
+ kind: wantsPersistent ? 'persistent' : 'one-shot',
237
+ parentKey,
238
+ persistentFish,
239
+ restriction,
240
+ prompt,
241
+ })
242
+ } catch (error) {
243
+ // Roll back in reverse registration order, then drop this call's new
244
+ // persistent fish. The previous state's entries were already removed
245
+ // above, so `states` ends up consistent: no restriction, no shadow, no
246
+ // persistent fish — bash stays visible and the failure is surfaced.
247
+ for (let index = created.length - 1; index >= 0; index -= 1) created[index]()
248
+ disposeQuietly(persistentFish, 'persistent fish tool', agent)
249
+ throw error
250
+ }
251
+ } catch (error) {
252
+ states.delete(agent)
253
+ console.warn(`${PLUGIN_TAG}: could not hide bash for agent ${agent.id ?? '(unnamed)'}:`, String(error))
254
+ } finally {
255
+ installing.delete(agent)
256
+ }
257
+ }
258
+
259
+ /** Apply the policy to every live agent (no-op when the agents service is
260
+ * absent). Re-entrant calls (a registration/disposal mid-round fires
261
+ * `tools/change` synchronously) are absorbed into at most ONE coalesced
262
+ * catch-up round, so an external change dispatches a small constant number
263
+ * of times regardless of how many agents are live. */
264
+ function reconcile(ctx, catchUp = false) {
265
+ if (reconciling) {
266
+ reconcilePending = true
267
+ return
268
+ }
269
+ reconciling = true
270
+ try {
271
+ for (const agent of ctx.get('agents')?.list() ?? []) {
272
+ liveAgents.add(agent)
273
+ try {
274
+ installFishPolicy(ctx, agent)
275
+ } catch (error) {
276
+ console.warn(`${PLUGIN_TAG}: policy reconcile failed for agent ${agent.id ?? '(unnamed)'}:`, String(error))
277
+ }
278
+ }
279
+ } finally {
280
+ reconciling = false
281
+ }
282
+ if (reconcilePending && !catchUp) {
283
+ // One coalesced catch-up round for changes that arrived mid-round (e.g.
284
+ // another plugin's listener mutated the tool surface). The catch-up round
285
+ // itself never re-triggers: every installed agent fast-paths on its
286
+ // parent key, so dispatch count stays bounded.
287
+ reconcilePending = false
288
+ reconcile(ctx, true)
289
+ } else {
290
+ reconcilePending = false
291
+ }
292
+ }
293
+
294
+ /**
295
+ * Cordis plugin entry: install the per-agent fish policy at the agent
296
+ * publication edge and on every tools/change, cover agents that already
297
+ * exist (plugin hot-reload), and strip every live-agent registration on
298
+ * plugin teardown.
299
+ * @param {import('@deepseek-ai/cordis').Context} ctx - the harness context.
300
+ */
301
+ export function apply(ctx) {
302
+ // Set while this plugin's teardown runs so the listeners below (still
303
+ // registered — they were created before the teardown effect, which cordis
304
+ // disposes first) cannot re-install a policy being torn down.
305
+ let disposed = false
306
+ // `agent/created` fires synchronously at the agent publication boundary
307
+ // (before the agent's first turn); a synchronous listener error would
308
+ // propagate into the publish path, so every failure is contained here.
309
+ ctx.on('agent/created', ({ agent }) => {
310
+ if (disposed) return
311
+ liveAgents.add(agent)
312
+ try {
313
+ installFishPolicy(ctx, agent)
314
+ } catch (error) {
315
+ console.warn(`${PLUGIN_TAG}: agent/created policy install failed for ${agent.id ?? '(unnamed)'}:`, String(error))
316
+ }
317
+ })
318
+ ctx.on('agent/disposed', ({ agent }) => {
319
+ if (disposed) return
320
+ try {
321
+ uninstallFishPolicy(agent)
322
+ } catch (error) {
323
+ console.warn(`${PLUGIN_TAG}: agent/disposed policy cleanup failed for ${agent.id ?? '(unnamed)'}:`, String(error))
324
+ }
325
+ })
326
+ ctx.on('tools/change', () => {
327
+ if (disposed) return
328
+ reconcile(ctx)
329
+ })
330
+ // Plugin-owned teardown (unload/disable/HMR): registered LAST so cordis
331
+ // disposes it FIRST (effects run in reverse registration order) — while
332
+ // this loop's own tools/change events are still delivered, `disposed`
333
+ // above makes the listeners no-ops, so uninstalling cannot re-install.
334
+ ctx.effect(() => () => {
335
+ disposed = true
336
+ for (const agent of [...liveAgents]) {
337
+ try {
338
+ uninstallFishPolicy(agent)
339
+ } catch (error) {
340
+ console.warn(`${PLUGIN_TAG}: policy teardown failed for agent ${agent.id ?? '(unnamed)'}:`, String(error))
341
+ }
342
+ }
343
+ liveAgents.clear()
344
+ }, 'fish preset policy teardown')
345
+ reconcile(ctx)
346
+ }
@@ -0,0 +1,317 @@
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
+ * One-line fish prompt setup submitted before readiness: suppresses the
81
+ * greeting and right prompt, and defines a prompt that emits the shared OSC
82
+ * `133;D;<status>` + BEL marker followed by exactly `dsh> `. ESC/BEL are
83
+ * built at runtime via fish `printf` escapes (no raw control bytes in
84
+ * submitted input; verified against fish 4.0.0).
85
+ */
86
+ 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;'
87
+
88
+ /**
89
+ * Resolve the effective fish shell specification: an unset or empty
90
+ * `shellPath`/`shellArgs` selects the defaults, a non-empty explicit value
91
+ * wins. Mirrors the official `resolveConfig` (minus dialect selection).
92
+ * @param config - the plugin configuration.
93
+ * @returns the fully resolved configuration.
94
+ */
95
+ export function resolveConfig(config) {
96
+ return {
97
+ backendType: config.backendType?.length > 0 ? config.backendType : 'fish',
98
+ shellPath: config.shellPath !== undefined && config.shellPath.length > 0 ? config.shellPath : DEFAULT_FISH_SHELL,
99
+ shellArgs: config.shellArgs !== undefined && config.shellArgs.length > 0 ? config.shellArgs : DEFAULT_FISH_ARGS,
100
+ ...DEFAULT_PTY_CONFIG,
101
+ ...Object.fromEntries(Object.entries(config).filter(([key]) => key in DEFAULT_PTY_CONFIG && config[key] !== undefined)),
102
+ }
103
+ }
104
+
105
+ /**
106
+ * Validate the effective configuration (mirrors the official
107
+ * `validateConfig`).
108
+ * @param config - the fully resolved configuration.
109
+ * @returns the narrowed configuration.
110
+ */
111
+ export function validateConfig(config) {
112
+ const resolved = config
113
+ if (resolved.backendType.length === 0) throw new Error('terminal-fish: backendType must be non-empty')
114
+ if (resolved.shellPath.length === 0) throw new Error('terminal-fish: shellPath must be non-empty')
115
+ for (const [field, value] of Object.entries(resolved)) {
116
+ if (typeof value === 'number' && (!Number.isSafeInteger(value) || value <= 0)) {
117
+ throw new Error(`terminal-fish: ${field} must be a positive safe integer`)
118
+ }
119
+ }
120
+ if (resolved.maxReadBytes > resolved.scrollbackMaxBytes) throw new Error('terminal-fish: maxReadBytes must not exceed scrollbackMaxBytes')
121
+ 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')
122
+ }
123
+
124
+ /**
125
+ * Fish child environment: the official bash `childEnvironment` common set
126
+ * (TERM=dumb so the reader emits no cursor/redraw sequences, PAGER=cat,
127
+ * harness identity facts) minus the bash-only PS1/PROMPT_COMMAND.
128
+ * @param spec - the spawn specification.
129
+ * @returns the child environment.
130
+ */
131
+ export function childEnvironment(spec) {
132
+ return {
133
+ TERM: 'dumb',
134
+ PAGER: 'cat',
135
+ GIT_PAGER: 'cat',
136
+ DSH_SHELL: '1',
137
+ DSH_SESSION_ID: spec.owner.id,
138
+ DSH_PTY_SESSION_ID: spec.sessionId,
139
+ }
140
+ }
141
+
142
+ // Copied from the official `@deepseek-ai/dsh-terminal-bash` (not exported
143
+ // there): a per-owner fence refusing `sandbox/mode` changes while persistent
144
+ // terminal sessions are open or being created.
145
+ //
146
+ // Self-managed adaptation: the official copy asks the terminals registry
147
+ // (`state.pty.hasOwnerActivity(owner)`) whether the owner has activity, but
148
+ // the policy's persistent tool drives sessions WITHOUT a registry. Activity
149
+ // checkers are therefore injectable and AGGREGATED per owner: every
150
+ // `registerPersistentFish` manager that spawned for an owner contributes its
151
+ // own checker (the manager's owner→session tracking), and the fence refuses
152
+ // the mode change while ANY checker reports activity. Aggregation matters
153
+ // because managers can churn (persistent→one-shot→persistent): a single-slot
154
+ // overwrite would silently forget the first manager's still-open sessions.
155
+ // A call without a checker (the backend's own fence probe on every spawn)
156
+ // adds nothing, so it cannot clobber policy checkers; when no checker was
157
+ // ever contributed the fence falls back to the terminals registry probe
158
+ // (`ctx.terminals` may be absent — missing services resolve to `undefined`
159
+ // on a Cordis context, never a throw).
160
+ const sandboxModeFences = new WeakMap()
161
+ export function ensureSandboxModeFence(ctx, owner, hasActivity) {
162
+ let state = sandboxModeFences.get(owner)
163
+ if (state === undefined) {
164
+ state = { ctx, checkers: new Set() }
165
+ sandboxModeFences.set(owner, state)
166
+ owner.ctx.on('internal/dispatch', (_mode, eventName, args) => {
167
+ if (eventName !== 'session/event') return
168
+ const [session, event] = args
169
+ if (session !== owner.session || event.type !== 'sandbox/mode') return
170
+ const current = sandboxModeFences.get(owner)
171
+ if (current === undefined) return
172
+ const currentMode = current.ctx.get('sessionProjections')?.stateOf(session, 'sandboxMode') ?? current.ctx.get('sandboxPolicy')?.defaultMode
173
+ if (event.data.mode === currentMode) return
174
+ const active = current.checkers.size > 0
175
+ ? [...current.checkers].some((check) => check())
176
+ : current.ctx.get('terminals')?.hasOwnerActivity(owner) ?? false
177
+ 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`)
178
+ }, { global: true })
179
+ } else {
180
+ state.ctx = ctx
181
+ }
182
+ if (hasActivity !== undefined) state.checkers.add(hasActivity)
183
+ }
184
+
185
+ /**
186
+ * Confine the fish argv through the shared sandbox policy, exactly like the
187
+ * 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.
193
+ */
194
+ export function spawnArgv(ctx, config, policy) {
195
+ const argv = [config.shellPath, ...config.shellArgs]
196
+ if (policy.mode === 'danger-full-access') return argv
197
+ const sandbox = ctx.get('sandbox')
198
+ 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, {
200
+ ...policy,
201
+ mode: policy.mode,
202
+ }).argv
203
+ }
204
+
205
+ /**
206
+ * Bring the fish session to readiness: submit the prompt setup once, then
207
+ * poll with empty sends until the fish prompt satisfies the shared
208
+ * readiness contract (OSC `133;D;` + `dsh> ` tail). Mirrors the official
209
+ * `startupSession` pwsh branch, with a startup deadline race.
210
+ * @param session - the backend session.
211
+ * @param timeoutMs - readiness deadline.
212
+ * @param signal - optional cancellation.
213
+ */
214
+ export async function startupSession(session, timeoutMs, signal) {
215
+ let startupOperation
216
+ const start = async () => {
217
+ let viewport = ''
218
+ for (;;) {
219
+ const first = viewport.length === 0
220
+ startupOperation = session.startSend({
221
+ text: first ? FISH_PROMPT_SETUP : '',
222
+ submit: first,
223
+ ...(signal !== undefined ? { signal } : {}),
224
+ })
225
+ const result = await startupOperation.done
226
+ if (result.waitReason === 'session_exit') throw new Error('PTY shell exited during startup')
227
+ if (result.waitReason === 'timeout') throw new Error('PTY shell did not reach readiness before startup timeout')
228
+ viewport = result.viewport
229
+ if (result.waitReason === 'stdin_read') break
230
+ }
231
+ session.motd = viewport
232
+ }
233
+ const races = []
234
+ let onAbort
235
+ if (signal !== undefined) {
236
+ const aborted = Promise.withResolvers()
237
+ onAbort = () => {
238
+ aborted.reject(signal.reason)
239
+ }
240
+ signal.addEventListener('abort', onAbort, { once: true })
241
+ races.push(aborted.promise)
242
+ }
243
+ const deadline = Promise.withResolvers()
244
+ const deadlineTimer = setTimeout(() => {
245
+ startupOperation?.cancel()
246
+ deadline.reject(new Error('PTY shell did not reach readiness before startup timeout'))
247
+ }, timeoutMs)
248
+ races.push(deadline.promise)
249
+ try {
250
+ signal?.throwIfAborted()
251
+ await Promise.race([start(), ...races])
252
+ } finally {
253
+ clearTimeout(deadlineTimer)
254
+ if (signal !== undefined && onAbort !== undefined) signal.removeEventListener('abort', onAbort)
255
+ }
256
+ }
257
+
258
+ /**
259
+ * Local fish PTY backend registered under the configured type (`fish`):
260
+ * spawns `fish --no-config -i` (config-overridable) with the fish child
261
+ * environment, confined through the shared sandbox policy, and brings it to
262
+ * readiness with the fish prompt setup. Everything else (bounded output,
263
+ * readiness polling, terminal-protocol replies, cleanup) is inherited from
264
+ * `BashTerminalBackend`/`LocalPtySession`.
265
+ */
266
+ export class FishTerminalBackend extends BashTerminalBackend {
267
+ async spawn(spec) {
268
+ spec.signal?.throwIfAborted()
269
+ ensureSandboxModeFence(this.ctx, spec.owner)
270
+ // Resolve services through `ctx.get` (never property access): the
271
+ // backend may be constructed with a plugin context whose inject list
272
+ // does not declare these services (the policy plugin's ctx), and Cordis
273
+ // property access would throw "cannot get property … without inject".
274
+ // `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)
277
+ if (argv[0] === undefined) throw new Error('terminal-fish: sandbox returned empty argv')
278
+ const terminal = await this.spawnTerminal({
279
+ argv,
280
+ cwd: spec.cwd ?? policy.workspaceRoot,
281
+ env: childEnvironment(spec),
282
+ rows: this.config.rows,
283
+ cols: this.config.cols,
284
+ graceMs: this.config.disposeGraceMs,
285
+ signal: spec.signal,
286
+ })
287
+ const session = this.createSession(terminal, this.config)
288
+ try {
289
+ await startupSession(session, this.config.timeoutMs, spec.signal)
290
+ return session
291
+ } catch (error) {
292
+ try {
293
+ await session.close('PTY startup failed')
294
+ } catch (closeError) {
295
+ throw new TerminalBackendCleanupError(error, closeError)
296
+ }
297
+ throw error
298
+ }
299
+ }
300
+ }
301
+
302
+ /**
303
+ * Register the local fish PTY backend in a `terminals` registry. The bundle
304
+ * patch does NOT mount this plugin (host-plane profiles have no `terminals`
305
+ * service); this entry point is for direct assembly and tests — and for the
306
+ * real `minimal` preset composition, whose entry-local registry can pick the
307
+ * backend up explicitly. Plugin configuration mirrors the official
308
+ * `@deepseek-ai/dsh-terminal-bash` Config surface (sans dialect), with fish
309
+ * defaults: backend type `fish`, `fish --no-config -i` argv.
310
+ * @param ctx - the harness context.
311
+ * @param config - optional backend configuration.
312
+ */
313
+ export function apply(ctx, config = {}) {
314
+ const resolved = resolveConfig(config)
315
+ validateConfig(resolved)
316
+ ctx.terminals.registerBackend(new FishTerminalBackend(ctx, resolved))
317
+ }