@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/policy.js ADDED
@@ -0,0 +1,405 @@
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
+ /**
67
+ * One agent as this policy reads it: the harness `Agent` viewed through the two
68
+ * members every registration below keys on — its session id (for warnings) and
69
+ * its agent-scoped context (where the restriction/prompt/tool registrations
70
+ * land). The full runtime `Agent` face is structurally assignable to this view.
71
+ * @typedef {{ id?: string, ctx: import('@deepseek-ai/cordis').Context }} PolicyAgent
72
+ */
73
+
74
+ /**
75
+ * One agent's installed policy: which bash form was detected, the preset parent
76
+ * key it was installed under (the switch/re-entry fast path compares it), and
77
+ * the disposers that must move with `/preset`.
78
+ * @typedef {object} PolicyState
79
+ * @property {'persistent' | 'one-shot'} kind - the bash form this policy swapped out.
80
+ * @property {import('@deepseek-ai/dsh-scope').ScopeKey | undefined} parentKey - the preset parent scope key this state was installed under (`scopeParentOf(agent)`).
81
+ * @property {(() => Promise<void> | undefined) | undefined} persistentFish - the persistent fish tool's disposer, when this state installed one.
82
+ * @property {() => void} restriction - the `deny: ['bash']` restriction disposer.
83
+ * @property {() => void} prompt - the `tool:bash` prompt-shadow disposer.
84
+ */
85
+
86
+ /** Agents currently being reconciled (guards the synchronous tools/change
87
+ * re-entry that restriction/prompt registration and disposal trigger).
88
+ * @type {WeakSet<PolicyAgent>} */
89
+ const installing = new WeakSet()
90
+ /** Agents already warned about an invisible fish tool (warn once per agent).
91
+ * @type {WeakSet<PolicyAgent>} */
92
+ const warned = new WeakSet()
93
+ /** Agents uninstalled via `agent/disposed`: a tools/change fired by the
94
+ * uninstall's own disposals must not re-install a dying agent.
95
+ * @type {WeakSet<PolicyAgent>} */
96
+ const removed = new WeakSet()
97
+ /** Per-agent policy state, including disposers that must move with /preset.
98
+ * @type {WeakMap<PolicyAgent, PolicyState>} */
99
+ const states = new WeakMap()
100
+ /** Live agents with policy installed; traversable so plugin teardown can
101
+ * strip every registration (a WeakMap would be unreachable on teardown).
102
+ * @type {Set<PolicyAgent>} */
103
+ const liveAgents = new Set()
104
+ /** Global reconcile re-entrancy guard: a tools/change arriving while a
105
+ * reconcile round is in flight marks `reconcilePending` instead of starting a
106
+ * nested round; the current round runs exactly one coalesced catch-up round
107
+ * afterwards. */
108
+ let reconciling = false
109
+ let reconcilePending = false
110
+
111
+ /**
112
+ * Narrow one unchecked value to a non-null, non-array object: the shape this
113
+ * policy reads duck-typed tool definitions and disposer results through.
114
+ * @param {unknown} value - the value to inspect.
115
+ * @returns {value is Record<string, unknown>} whether it is a plain object record.
116
+ */
117
+ function isPlainObject(value) {
118
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
119
+ }
120
+
121
+ /**
122
+ * Dispose one registration quietly: `undefined` means nothing was installed,
123
+ * a synchronous throw and a rejected async disposer are both warned instead of
124
+ * breaking the policy round that runs the disposal.
125
+ * @param {(() => unknown) | undefined} disposer - the registration's disposer, when there is one.
126
+ * @param {string} label - the registration's name in the warning.
127
+ * @param {PolicyAgent} agent - the agent the registration belongs to.
128
+ */
129
+ function disposeQuietly(disposer, label, agent) {
130
+ if (disposer === undefined) return
131
+ try {
132
+ const result = disposer()
133
+ // Async disposers (persistent fish cleanup) are awaited out-of-band so a
134
+ // failing teardown is recorded instead of unhandled; a plain `void`
135
+ // disposer returns a non-thenable and needs no wait.
136
+ if (isPlainObject(result) && typeof result.then === 'function') {
137
+ void Promise.resolve(result).catch((error) => {
138
+ console.warn(`${PLUGIN_TAG}: could not dispose ${label} for agent ${agent.id ?? '(unnamed)'}:`, String(error))
139
+ })
140
+ }
141
+ } catch (error) {
142
+ console.warn(`${PLUGIN_TAG}: could not dispose ${label} for agent ${agent.id ?? '(unnamed)'}:`, String(error))
143
+ }
144
+ }
145
+
146
+ /**
147
+ * Drop every registration one policy state owns.
148
+ * @param {PolicyAgent} agent - the agent the state belongs to.
149
+ * @param {PolicyState} state - the installed state to tear down.
150
+ */
151
+ function disposeState(agent, state) {
152
+ disposeQuietly(state.prompt, 'tool:bash prompt shadow', agent)
153
+ disposeQuietly(state.restriction, 'bash restriction', agent)
154
+ disposeQuietly(state.persistentFish, 'persistent fish tool', agent)
155
+ }
156
+
157
+ /**
158
+ * Drop the registrations a preset switch must lift before it probes the target
159
+ * preset's surface (the prompt shadow and the bash restriction only).
160
+ * @param {PolicyAgent} agent - the agent whose transient policy is removed.
161
+ * @param {PolicyState | undefined} state - the previous state, when there is one.
162
+ */
163
+ function removeTransientPolicy(agent, state) {
164
+ disposeQuietly(state?.prompt, 'tool:bash prompt shadow', agent)
165
+ disposeQuietly(state?.restriction, 'bash restriction', agent)
166
+ }
167
+
168
+ /**
169
+ * Detect the official persistent-bash tool signature (`dsh-tool-bash-
170
+ * persistent`): a definition whose parameters carry ONLY `command` and whose
171
+ * output schema is a plain string. The one-shot bash tool (command +
172
+ * description + workdir + timeoutMs + run_in_background +
173
+ * sandbox_permissions…) and every other tool shape fall through.
174
+ * @param {import('@deepseek-ai/dsh-tools').ToolDefinition} definition - the tool definition an agent resolves for `bash`.
175
+ * @returns {boolean} true when the agent's bash is the persistent PTY form.
176
+ */
177
+ export function isPersistentBashForm(definition) {
178
+ if (!isPlainObject(definition)) return false
179
+ const schema = definition.output?.schema
180
+ if (!isPlainObject(schema) || schema.type !== 'string') return false
181
+ const parameters = definition.parameters
182
+ if (!isPlainObject(parameters)) return false
183
+ // Official persistent bash: parameters is a property map holding ONLY
184
+ // `command` (schemastery shape, no JSON-Schema wrapper).
185
+ const keys = Object.keys(parameters)
186
+ if (keys.length === 1 && keys[0] === 'command') return true
187
+ // Defensive: a JSON-Schema-normalized variant with `properties` holding
188
+ // only `command`.
189
+ const properties = parameters.properties
190
+ return isPlainObject(properties) && Object.keys(properties).length === 1 && Object.hasOwn(properties, 'command')
191
+ }
192
+
193
+ /**
194
+ * Remove every policy-owned registration for an agent.
195
+ * @param {PolicyAgent} agent - the agent whose policy is uninstalled.
196
+ */
197
+ export function uninstallFishPolicy(agent) {
198
+ liveAgents.delete(agent)
199
+ removed.add(agent)
200
+ const state = states.get(agent)
201
+ if (state === undefined) return
202
+ states.delete(agent)
203
+ disposeState(agent, state)
204
+ }
205
+
206
+ /**
207
+ * Apply the fish policy to one agent: hide its inherited `bash` tool and
208
+ * shadow the `tool:bash` prompt guidance, leaving only the appropriate `fish`
209
+ * tool. Recomputed on every preset switch (the agent's parent scope key
210
+ * changed); an unchanged preset parent makes the call a no-op so unrelated
211
+ * `tools/change` events cannot churn the restriction or reset the persistent
212
+ * fish. Installation is transactional: nothing is committed to `states`
213
+ * until every registration succeeded, and a failure rolls back only the
214
+ * disposers THIS call created.
215
+ *
216
+ * @param {import('@deepseek-ai/cordis').Context} ctx - the policy plugin's context.
217
+ * @param {PolicyAgent} agent - the live agent (its `ctx` is the agent's scoped context).
218
+ */
219
+ export function installFishPolicy(ctx, agent) {
220
+ if (installing.has(agent)) return
221
+ // The agent was uninstalled (agent/disposed): a tools/change fired by that
222
+ // uninstall's own disposals must not re-install a dying agent.
223
+ if (removed.has(agent)) return
224
+
225
+ installing.add(agent)
226
+ const previous = states.get(agent)
227
+ const parentKey = scopeParentOf(agent)
228
+ try {
229
+ // Fast path: the agent already has a policy installed under the SAME
230
+ // preset parent scope. Nothing relevant changed for this agent, so an
231
+ // unrelated tools/change (host tools, other agents' registrations) must
232
+ // not dispose and re-register anything — that churn is what turned one
233
+ // external change into a factorial dispatch storm across agents, and it
234
+ // would reset a persistent fish (dropping its PTY) for no reason.
235
+ if (previous !== undefined && previous.parentKey === parentKey) return
236
+
237
+ // Preset switch or first install: lift everything this policy owns so the
238
+ // probes below see the target preset's true inherited surface. The old
239
+ // persistent fish is removed BEFORE probing fish visibility — an
240
+ // agent-scope persistent fish would otherwise count as "fish visible" and
241
+ // let a deny-fish allowlist preset pass through with bash still hidden.
242
+ removeTransientPolicy(agent, previous)
243
+ disposeQuietly(previous?.persistentFish, 'persistent fish tool', agent)
244
+
245
+ const bash = ctx.tools.get('bash', agent)
246
+ if (bash === undefined) {
247
+ states.delete(agent)
248
+ return
249
+ }
250
+
251
+ const wantsPersistent = isPersistentBashForm(bash)
252
+ const fish = ctx.tools.get('fish', agent)
253
+ if (fish === undefined) {
254
+ // The preset deliberately excludes the global fish tool (an allowlist),
255
+ // so there is nothing to swap bash for — leave the preset as authored and
256
+ // warn once per agent instead of mutating it.
257
+ if (!warned.has(agent)) {
258
+ warned.add(agent)
259
+ console.warn(
260
+ `${PLUGIN_TAG}: agent ${agent.id ?? '(unnamed)'} has no visible fish tool `
261
+ + '(its preset allowlist excludes the global fish tool); leaving its inherited bash tool in place',
262
+ )
263
+ }
264
+ states.delete(agent)
265
+ return
266
+ }
267
+
268
+ // Transactional install. `persistentFish` is created by THIS call (the
269
+ // previous one, if any, was already removed above as part of the switched-
270
+ // away state), so the failure path below rolls back exactly what this call
271
+ // created and never destroys a registration it reused.
272
+ let persistentFish
273
+ if (wantsPersistent) {
274
+ // The minimal preset's persistent bash is swapped for persistent fish
275
+ // at the agent scope (shadowing the host-global one-shot fish) BEFORE
276
+ // bash is hidden, so minimal sessions keep persistent semantics.
277
+ persistentFish = registerPersistentFish(ctx, agent.ctx)
278
+ }
279
+ /** @type {Array<() => void>} */
280
+ const created = []
281
+ try {
282
+ const restriction = agent.ctx.tools.restrict({ deny: ['bash'] })
283
+ created.push(() => disposeQuietly(restriction, 'bash restriction', agent))
284
+ const prompt = agent.ctx.systemPrompt.section({
285
+ name: 'tool:bash',
286
+ order: agent.ctx.systemPrompt.getSectionOrder('TOOL_BASH'),
287
+ text: '',
288
+ })
289
+ created.push(() => disposeQuietly(prompt, 'tool:bash prompt shadow', agent))
290
+ states.set(agent, {
291
+ kind: wantsPersistent ? 'persistent' : 'one-shot',
292
+ parentKey,
293
+ persistentFish,
294
+ restriction,
295
+ prompt,
296
+ })
297
+ } catch (error) {
298
+ // Roll back in reverse registration order, then drop this call's new
299
+ // persistent fish. The previous state's entries were already removed
300
+ // above, so `states` ends up consistent: no restriction, no shadow, no
301
+ // persistent fish — bash stays visible and the failure is surfaced.
302
+ for (let index = created.length - 1; index >= 0; index -= 1) created[index]?.()
303
+ disposeQuietly(persistentFish, 'persistent fish tool', agent)
304
+ throw error
305
+ }
306
+ } catch (error) {
307
+ states.delete(agent)
308
+ console.warn(`${PLUGIN_TAG}: could not hide bash for agent ${agent.id ?? '(unnamed)'}:`, String(error))
309
+ } finally {
310
+ installing.delete(agent)
311
+ }
312
+ }
313
+
314
+ /**
315
+ * Apply the policy to every live agent (no-op when the agents service is
316
+ * absent). Re-entrant calls (a registration/disposal mid-round fires
317
+ * `tools/change` synchronously) are absorbed into at most ONE coalesced
318
+ * catch-up round, so an external change dispatches a small constant number
319
+ * of times regardless of how many agents are live.
320
+ * @param {import('@deepseek-ai/cordis').Context} ctx - the policy plugin's context (the `agents` service is resolved from it).
321
+ * @param {boolean} [catchUp] - whether this round is the coalesced catch-up (never re-triggers one).
322
+ */
323
+ function reconcile(ctx, catchUp = false) {
324
+ if (reconciling) {
325
+ reconcilePending = true
326
+ return
327
+ }
328
+ reconciling = true
329
+ try {
330
+ for (const agent of ctx.get('agents')?.list() ?? []) {
331
+ liveAgents.add(agent)
332
+ try {
333
+ installFishPolicy(ctx, agent)
334
+ } catch (error) {
335
+ console.warn(`${PLUGIN_TAG}: policy reconcile failed for agent ${agent.id ?? '(unnamed)'}:`, String(error))
336
+ }
337
+ }
338
+ } finally {
339
+ reconciling = false
340
+ }
341
+ if (reconcilePending && !catchUp) {
342
+ // One coalesced catch-up round for changes that arrived mid-round (e.g.
343
+ // another plugin's listener mutated the tool surface). The catch-up round
344
+ // itself never re-triggers: every installed agent fast-paths on its
345
+ // parent key, so dispatch count stays bounded.
346
+ reconcilePending = false
347
+ reconcile(ctx, true)
348
+ } else {
349
+ reconcilePending = false
350
+ }
351
+ }
352
+
353
+ /**
354
+ * Cordis plugin entry: install the per-agent fish policy at the agent
355
+ * publication edge and on every tools/change, cover agents that already
356
+ * exist (plugin hot-reload), and strip every live-agent registration on
357
+ * plugin teardown.
358
+ * @param {import('@deepseek-ai/cordis').Context} ctx - the harness context.
359
+ */
360
+ export function apply(ctx) {
361
+ // Set while this plugin's teardown runs so the listeners below (still
362
+ // registered — they were created before the teardown effect, which cordis
363
+ // disposes first) cannot re-install a policy being torn down.
364
+ let disposed = false
365
+ // `agent/created` fires synchronously at the agent publication boundary
366
+ // (before the agent's first turn); a synchronous listener error would
367
+ // propagate into the publish path, so every failure is contained here.
368
+ ctx.on('agent/created', ({ agent }) => {
369
+ if (disposed) return
370
+ liveAgents.add(agent)
371
+ try {
372
+ installFishPolicy(ctx, agent)
373
+ } catch (error) {
374
+ console.warn(`${PLUGIN_TAG}: agent/created policy install failed for ${agent.id ?? '(unnamed)'}:`, String(error))
375
+ }
376
+ })
377
+ ctx.on('agent/disposed', ({ agent }) => {
378
+ if (disposed) return
379
+ try {
380
+ uninstallFishPolicy(agent)
381
+ } catch (error) {
382
+ console.warn(`${PLUGIN_TAG}: agent/disposed policy cleanup failed for ${agent.id ?? '(unnamed)'}:`, String(error))
383
+ }
384
+ })
385
+ ctx.on('tools/change', () => {
386
+ if (disposed) return
387
+ reconcile(ctx)
388
+ })
389
+ // Plugin-owned teardown (unload/disable/HMR): registered LAST so cordis
390
+ // disposes it FIRST (effects run in reverse registration order) — while
391
+ // this loop's own tools/change events are still delivered, `disposed`
392
+ // above makes the listeners no-ops, so uninstalling cannot re-install.
393
+ ctx.effect(() => () => {
394
+ disposed = true
395
+ for (const agent of [...liveAgents]) {
396
+ try {
397
+ uninstallFishPolicy(agent)
398
+ } catch (error) {
399
+ console.warn(`${PLUGIN_TAG}: policy teardown failed for agent ${agent.id ?? '(unnamed)'}:`, String(error))
400
+ }
401
+ }
402
+ liveAgents.clear()
403
+ }, 'fish preset policy teardown')
404
+ reconcile(ctx)
405
+ }