@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/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
|
+
}
|