@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/README.md +151 -41
- package/cordis.patch.yml +39 -32
- package/package.json +40 -4
- package/persistent.js +639 -0
- package/policy.js +346 -0
- package/terminal-fish.js +317 -0
- package/tool.js +347 -111
- package/presets/fish/agent.cordis.yml +0 -241
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
|
+
}
|
package/terminal-fish.js
ADDED
|
@@ -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
|
+
}
|