@banbolee/dsh-fish-shell 0.7.0 → 0.8.1

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.
Files changed (58) hide show
  1. package/CHANGELOG.md +91 -0
  2. package/README.md +318 -13
  3. package/README.zh.md +101 -4
  4. package/THIRD-PARTY-NOTICES.md +91 -0
  5. package/cordis.patch.yml +56 -0
  6. package/node_modules/@deepseek-ai/cosmokit/LICENSE +21 -0
  7. package/node_modules/@deepseek-ai/cosmokit/README.md +30 -0
  8. package/node_modules/@deepseek-ai/cosmokit/lib/index.js +418 -0
  9. package/node_modules/@deepseek-ai/cosmokit/lib/types/array.d.ts +15 -0
  10. package/node_modules/@deepseek-ai/cosmokit/lib/types/array.d.ts.map +1 -0
  11. package/node_modules/@deepseek-ai/cosmokit/lib/types/index.d.ts +13 -0
  12. package/node_modules/@deepseek-ai/cosmokit/lib/types/index.d.ts.map +1 -0
  13. package/node_modules/@deepseek-ai/cosmokit/lib/types/misc.d.ts +41 -0
  14. package/node_modules/@deepseek-ai/cosmokit/lib/types/misc.d.ts.map +1 -0
  15. package/node_modules/@deepseek-ai/cosmokit/lib/types/string.d.ts +89 -0
  16. package/node_modules/@deepseek-ai/cosmokit/lib/types/string.d.ts.map +1 -0
  17. package/node_modules/@deepseek-ai/cosmokit/lib/types/time.d.ts +19 -0
  18. package/node_modules/@deepseek-ai/cosmokit/lib/types/time.d.ts.map +1 -0
  19. package/node_modules/@deepseek-ai/cosmokit/lib/types/types.d.ts +42 -0
  20. package/node_modules/@deepseek-ai/cosmokit/lib/types/types.d.ts.map +1 -0
  21. package/node_modules/@deepseek-ai/cosmokit/lib/types/volatile.d.ts +40 -0
  22. package/node_modules/@deepseek-ai/cosmokit/lib/types/volatile.d.ts.map +1 -0
  23. package/node_modules/@deepseek-ai/cosmokit/package.json +32 -0
  24. package/node_modules/@deepseek-ai/cosmokit/src/array.ts +42 -0
  25. package/node_modules/@deepseek-ai/cosmokit/src/index.ts +12 -0
  26. package/node_modules/@deepseek-ai/cosmokit/src/misc.ts +78 -0
  27. package/node_modules/@deepseek-ai/cosmokit/src/string.ts +113 -0
  28. package/node_modules/@deepseek-ai/cosmokit/src/time.ts +92 -0
  29. package/node_modules/@deepseek-ai/cosmokit/src/types.ts +168 -0
  30. package/node_modules/@deepseek-ai/cosmokit/src/volatile.ts +86 -0
  31. package/node_modules/@deepseek-ai/dsh-tool-terminal/LICENSE +21 -0
  32. package/node_modules/@deepseek-ai/dsh-tool-terminal/README.i18n.yaml +100 -0
  33. package/node_modules/@deepseek-ai/dsh-tool-terminal/README.md +186 -0
  34. package/node_modules/@deepseek-ai/dsh-tool-terminal/README.zh.md +186 -0
  35. package/node_modules/@deepseek-ai/dsh-tool-terminal/lib/index.js +646 -0
  36. package/node_modules/@deepseek-ai/dsh-tool-terminal/lib/types/background.d.ts +20 -0
  37. package/node_modules/@deepseek-ai/dsh-tool-terminal/lib/types/index.d.ts +32 -0
  38. package/node_modules/@deepseek-ai/dsh-tool-terminal/lib/types/render.d.ts +81 -0
  39. package/node_modules/@deepseek-ai/dsh-tool-terminal/package.json +63 -0
  40. package/node_modules/@deepseek-ai/schemastery/LICENSE +21 -0
  41. package/node_modules/@deepseek-ai/schemastery/README.md +393 -0
  42. package/node_modules/@deepseek-ai/schemastery/lib/index.cjs +635 -0
  43. package/node_modules/@deepseek-ai/schemastery/lib/index.mjs +635 -0
  44. package/node_modules/@deepseek-ai/schemastery/lib/types/index.d.ts +211 -0
  45. package/node_modules/@deepseek-ai/schemastery/lib/types/index.d.ts.map +1 -0
  46. package/node_modules/@deepseek-ai/schemastery/package.json +39 -0
  47. package/node_modules/@deepseek-ai/schemastery/src/index.ts +962 -0
  48. package/node_modules/@standard-schema/spec/LICENSE +21 -0
  49. package/node_modules/@standard-schema/spec/README.md +198 -0
  50. package/node_modules/@standard-schema/spec/dist/index.cjs +18 -0
  51. package/node_modules/@standard-schema/spec/dist/index.d.cts +119 -0
  52. package/node_modules/@standard-schema/spec/dist/index.d.ts +119 -0
  53. package/node_modules/@standard-schema/spec/dist/index.js +0 -0
  54. package/node_modules/@standard-schema/spec/package.json +52 -0
  55. package/package.json +56 -42
  56. package/terminal-tools.js +259 -0
  57. package/terminal.js +297 -0
  58. package/tool.js +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@banbolee/dsh-fish-shell",
3
- "version": "0.7.0",
3
+ "version": "0.8.1",
4
4
  "description": "Fish shell executors and tool for DeepSeek Harness: run commands with fish instead of bash.",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -9,8 +9,10 @@
9
9
  "./local": "./local.js",
10
10
  "./tool": "./tool.js",
11
11
  "./policy": "./policy.js",
12
+ "./terminal": "./terminal.js",
12
13
  "./terminal-fish": "./terminal-fish.js",
13
14
  "./persistent": "./persistent.js",
15
+ "./terminal-tools": "./terminal-tools.js",
14
16
  "./package.json": "./package.json"
15
17
  },
16
18
  "files": [
@@ -18,62 +20,73 @@
18
20
  "local.js",
19
21
  "tool.js",
20
22
  "policy.js",
23
+ "terminal.js",
21
24
  "terminal-fish.js",
22
25
  "persistent.js",
26
+ "terminal-tools.js",
23
27
  "job-kind.d.ts",
24
28
  "cordis.patch.yml",
25
29
  "README.md",
26
- "README.zh.md"
30
+ "README.zh.md",
31
+ "THIRD-PARTY-NOTICES.md",
32
+ "CHANGELOG.md"
27
33
  ],
28
34
  "dsh": {
29
35
  "bundle": {
30
36
  "patch": "./cordis.patch.yml"
31
37
  }
32
38
  },
39
+ "dependencies": {
40
+ "@deepseek-ai/dsh-tool-terminal": "0.2.0-rc.2"
41
+ },
42
+ "bundledDependencies": [
43
+ "@deepseek-ai/dsh-tool-terminal"
44
+ ],
33
45
  "peerDependencies": {
34
46
  "@deepseek-ai/cordis": "~4.0.4",
35
- "@deepseek-ai/dsh-agent": "^0.2.0-rc.1",
36
- "@deepseek-ai/dsh-bash-local": "^0.2.0-rc.1",
37
- "@deepseek-ai/dsh-bash-sandbox": "^0.2.0-rc.1",
38
- "@deepseek-ai/dsh-home-paths": "^0.2.0-rc.1",
39
- "@deepseek-ai/dsh-jobs": "^0.2.0-rc.1",
40
- "@deepseek-ai/dsh-llm": "^0.2.0-rc.1",
41
- "@deepseek-ai/dsh-sandbox": "^0.2.0-rc.1",
42
- "@deepseek-ai/dsh-sandbox-policy": "^0.2.0-rc.1",
43
- "@deepseek-ai/dsh-scope": "^0.2.0-rc.1",
44
- "@deepseek-ai/dsh-session-projection": "^0.2.0-rc.1",
45
- "@deepseek-ai/dsh-shell": "^0.2.0-rc.1",
46
- "@deepseek-ai/dsh-shell-env": "^0.2.0-rc.1",
47
- "@deepseek-ai/dsh-subprocess": "^0.2.0-rc.1",
48
- "@deepseek-ai/dsh-system-prompt": "^0.2.0-rc.1",
49
- "@deepseek-ai/dsh-terminal": "^0.2.0-rc.1",
50
- "@deepseek-ai/dsh-terminal-bash": "^0.2.0-rc.1",
51
- "@deepseek-ai/dsh-tools": "^0.2.0-rc.1",
52
- "@deepseek-ai/dsh-user-approval": "^0.2.0-rc.1"
47
+ "@deepseek-ai/dsh-agent": "^0.2.0-rc.2",
48
+ "@deepseek-ai/dsh-bash-local": "^0.2.0-rc.2",
49
+ "@deepseek-ai/dsh-bash-sandbox": "^0.2.0-rc.2",
50
+ "@deepseek-ai/dsh-home-paths": "^0.2.0-rc.2",
51
+ "@deepseek-ai/dsh-jobs": "^0.2.0-rc.2",
52
+ "@deepseek-ai/dsh-llm": "^0.2.0-rc.2",
53
+ "@deepseek-ai/dsh-pwsh-local": "^0.2.0-rc.2",
54
+ "@deepseek-ai/dsh-sandbox": "^0.2.0-rc.2",
55
+ "@deepseek-ai/dsh-sandbox-policy": "^0.2.0-rc.2",
56
+ "@deepseek-ai/dsh-scope": "^0.2.0-rc.2",
57
+ "@deepseek-ai/dsh-session-projection": "^0.2.0-rc.2",
58
+ "@deepseek-ai/dsh-shell": "^0.2.0-rc.2",
59
+ "@deepseek-ai/dsh-shell-env": "^0.2.0-rc.2",
60
+ "@deepseek-ai/dsh-subprocess": "^0.2.0-rc.2",
61
+ "@deepseek-ai/dsh-system-prompt": "^0.2.0-rc.2",
62
+ "@deepseek-ai/dsh-terminal": "^0.2.0-rc.2",
63
+ "@deepseek-ai/dsh-terminal-bash": "^0.2.0-rc.2",
64
+ "@deepseek-ai/dsh-tools": "^0.2.0-rc.2",
65
+ "@deepseek-ai/dsh-user-approval": "^0.2.0-rc.2"
53
66
  },
54
67
  "devDependencies": {
55
68
  "@deepseek-ai/cordis": "~4.0.4",
56
- "@deepseek-ai/dsh-agent": "^0.2.0-rc.1",
57
- "@deepseek-ai/dsh-bash-local": "^0.2.0-rc.1",
58
- "@deepseek-ai/dsh-bash-sandbox": "^0.2.0-rc.1",
59
- "@deepseek-ai/dsh-home-paths": "^0.2.0-rc.1",
60
- "@deepseek-ai/dsh-invariants": "^0.2.0-rc.1",
61
- "@deepseek-ai/dsh-jobs": "^0.2.0-rc.1",
62
- "@deepseek-ai/dsh-llm": "^0.2.0-rc.1",
63
- "@deepseek-ai/dsh-pwsh-local": "^0.2.0-rc.1",
64
- "@deepseek-ai/dsh-sandbox": "^0.2.0-rc.1",
65
- "@deepseek-ai/dsh-sandbox-policy": "^0.2.0-rc.1",
66
- "@deepseek-ai/dsh-scope": "^0.2.0-rc.1",
67
- "@deepseek-ai/dsh-session-projection": "^0.2.0-rc.1",
68
- "@deepseek-ai/dsh-shell": "^0.2.0-rc.1",
69
- "@deepseek-ai/dsh-shell-env": "^0.2.0-rc.1",
70
- "@deepseek-ai/dsh-subprocess": "^0.2.0-rc.1",
71
- "@deepseek-ai/dsh-subprocess-local": "^0.2.0-rc.1",
72
- "@deepseek-ai/dsh-system-prompt": "^0.2.0-rc.1",
73
- "@deepseek-ai/dsh-terminal": "^0.2.0-rc.1",
74
- "@deepseek-ai/dsh-terminal-bash": "^0.2.0-rc.1",
75
- "@deepseek-ai/dsh-tools": "^0.2.0-rc.1",
76
- "@deepseek-ai/dsh-user-approval": "^0.2.0-rc.1",
69
+ "@deepseek-ai/dsh-agent": "^0.2.0-rc.2",
70
+ "@deepseek-ai/dsh-bash-local": "^0.2.0-rc.2",
71
+ "@deepseek-ai/dsh-bash-sandbox": "^0.2.0-rc.2",
72
+ "@deepseek-ai/dsh-home-paths": "^0.2.0-rc.2",
73
+ "@deepseek-ai/dsh-invariants": "^0.2.0-rc.2",
74
+ "@deepseek-ai/dsh-jobs": "^0.2.0-rc.2",
75
+ "@deepseek-ai/dsh-llm": "^0.2.0-rc.2",
76
+ "@deepseek-ai/dsh-pwsh-local": "^0.2.0-rc.2",
77
+ "@deepseek-ai/dsh-sandbox": "^0.2.0-rc.2",
78
+ "@deepseek-ai/dsh-sandbox-policy": "^0.2.0-rc.2",
79
+ "@deepseek-ai/dsh-scope": "^0.2.0-rc.2",
80
+ "@deepseek-ai/dsh-session-projection": "^0.2.0-rc.2",
81
+ "@deepseek-ai/dsh-shell": "^0.2.0-rc.2",
82
+ "@deepseek-ai/dsh-shell-env": "^0.2.0-rc.2",
83
+ "@deepseek-ai/dsh-subprocess": "^0.2.0-rc.2",
84
+ "@deepseek-ai/dsh-subprocess-local": "^0.2.0-rc.2",
85
+ "@deepseek-ai/dsh-system-prompt": "^0.2.0-rc.2",
86
+ "@deepseek-ai/dsh-terminal": "^0.2.0-rc.2",
87
+ "@deepseek-ai/dsh-terminal-bash": "^0.2.0-rc.2",
88
+ "@deepseek-ai/dsh-tools": "^0.2.0-rc.2",
89
+ "@deepseek-ai/dsh-user-approval": "^0.2.0-rc.2",
77
90
  "yaml": "^2.8.0"
78
91
  },
79
92
  "keywords": [
@@ -86,7 +99,8 @@
86
99
  "license": "MIT",
87
100
  "publishConfig": {
88
101
  "access": "public",
89
- "registry": "https://registry.npmjs.org"
102
+ "registry": "https://registry.npmjs.org",
103
+ "provenance": true
90
104
  },
91
105
  "engines": {
92
106
  "node": ">=22"
@@ -0,0 +1,259 @@
1
+ /**
2
+ * `terminalTools` policy for the L2 interactive terminal surface of
3
+ * `@banbolee/dsh-fish-shell` — the D6 switch, `'allow' | 'deny'`.
4
+ *
5
+ * The bundle's patch appends a `fish-terminal-group` insert row that mounts
6
+ * the official `@deepseek-ai/dsh-terminal` registry, the official
7
+ * `@deepseek-ai/dsh-terminal-bash` backend driven with fish argv
8
+ * (`--no-config -i -C <prompt setup>`), and the official
9
+ * `@deepseek-ai/dsh-tool-terminal`, whose six `terminal_*` tools register
10
+ * into the HOST tool registry (the group isolates `terminals`, not `tools`).
11
+ * This module is the only entry the plugin itself adds for L2: a per-agent
12
+ * switch that can take those six tools away again.
13
+ *
14
+ * - `terminalTools: 'allow'` (the default, matching every other tool in the
15
+ * deployment) does nothing at all — `apply` returns before registering a
16
+ * single listener, so an allowed mount has zero runtime footprint.
17
+ * - `terminalTools: 'deny'` restricts the six tools for every live agent at
18
+ * the `agent/created` publication edge and on every `tools/change`. The
19
+ * official `tools.restrict` is the only enforcement point, and it REJECTS
20
+ * names that are not registered at that moment, so an attempt made before
21
+ * the terminal tools exist fails and is retried on the next
22
+ * `tools/change` (the registry announces every registration and scoped
23
+ * restriction through that event).
24
+ *
25
+ * Every `apply()` call owns its ENTIRE state — its own live-agent set, its own
26
+ * per-agent disposers, its own reconcile flags, and its own
27
+ * installing/warned/removed bookkeeping. Two mounts sharing one context (two
28
+ * bundle rows) or two profiles living in one process therefore cannot dedupe
29
+ * into, lift, or orphan each other's restrictions: each mount installs its own
30
+ * restriction and that mount alone lifts it on teardown, so no restriction
31
+ * outlives the mount that owns it.
32
+ *
33
+ * The governed names are the EXPLICIT six-name enumeration measured on a real
34
+ * profile (`TERMINAL_TOOL_NAMES`); a `terminal_*` wildcard is deliberately not
35
+ * used, so an upstream seventh tool cannot silently pass the policy — the
36
+ * contract test compares this constant with the bundled
37
+ * `@deepseek-ai/dsh-tool-terminal` surface instead.
38
+ *
39
+ * Boundaries: this policy registers no tool, wraps/replaces no host object,
40
+ * monkey-patches nothing, and offers no privilege-escalation entry point. It
41
+ * only narrows an agent's tool surface through the public
42
+ * `agent.ctx.tools.restrict({ deny })` API. The interactive sessions
43
+ * themselves inherit the host's confinement (a `workspace-write` profile is
44
+ * fenced by the host sandbox).
45
+ *
46
+ * @module @banbolee/dsh-fish-shell/terminal-tools
47
+ */
48
+
49
+ /** Cordis plugin name (the patch row id is `fish-terminal-tools`). */
50
+ export const name = 'fish-terminal-tools-policy'
51
+ /** Required service: the tool registry the six names are restricted in. */
52
+ export const inject = ['tools']
53
+
54
+ /**
55
+ * The six `terminal_*` tools mounted by the bundle's `fish-terminal-group`,
56
+ * enumerated explicitly (never a `terminal_*` pattern): `terminal_open`,
57
+ * `terminal_send`, `terminal_read`, `terminal_signal`, `terminal_close`,
58
+ * `terminal_list`.
59
+ */
60
+ export const TERMINAL_TOOL_NAMES = Object.freeze([
61
+ 'terminal_open',
62
+ 'terminal_send',
63
+ 'terminal_read',
64
+ 'terminal_signal',
65
+ 'terminal_close',
66
+ 'terminal_list',
67
+ ])
68
+
69
+ /**
70
+ * Plugin configuration schema: one switch. A plain object implementing the
71
+ * standard-schema interface (no external validator): an absent
72
+ * `terminalTools` defaults to `allow`; any value other than `allow`/`deny` is
73
+ * rejected at mount time.
74
+ */
75
+ export const Config = {
76
+ '~standard': {
77
+ version: /** @type {1} */ (1),
78
+ vendor: '@banbolee/dsh-fish-shell/terminal-tools',
79
+ /**
80
+ * Normalize one mount's raw configuration.
81
+ * @param {unknown} value
82
+ */
83
+ validate(value) {
84
+ const mode = /** @type {{ terminalTools?: unknown }} */ (value ?? {}).terminalTools ?? 'allow'
85
+ if (mode !== 'allow' && mode !== 'deny') {
86
+ throw new TypeError("banbo-fish-terminal-tools config.terminalTools must be 'allow' or 'deny'")
87
+ }
88
+ return { value: { terminalTools: mode } }
89
+ },
90
+ },
91
+ }
92
+
93
+ const PLUGIN_TAG = '@banbolee/dsh-fish-shell'
94
+
95
+ /**
96
+ * One agent as this policy reads it: the harness `Agent` viewed through the
97
+ * two members it keys on — its session id (for warnings) and its agent-scoped
98
+ * context (where the restriction lands). The full runtime `Agent` face is
99
+ * structurally assignable to this view.
100
+ * @typedef {{ id?: string, ctx: import('@deepseek-ai/cordis').Context }} PolicyAgent
101
+ */
102
+
103
+ /**
104
+ * Mount one `deny` policy on `ctx`, owning every piece of state it needs.
105
+ * Nothing in here is shared with another mount of this module.
106
+ * @param {import('@deepseek-ai/cordis').Context} ctx - the harness context this mount lives on.
107
+ */
108
+ function mountDenyPolicy(ctx) {
109
+ /** Agents this mount is currently restricting (guards the synchronous
110
+ * `tools/change` re-entry that `tools.restrict` itself fires).
111
+ * @type {WeakSet<PolicyAgent>} */
112
+ const installing = new WeakSet()
113
+ /** Agents this mount already warned about a failed restriction (warn once
114
+ * per agent per mount).
115
+ * @type {WeakSet<PolicyAgent>} */
116
+ const warned = new WeakSet()
117
+ /** Agents this mount saw disposed: a `tools/change` fired by the uninstall's
118
+ * own disposer must not re-restrict a dying agent.
119
+ * @type {WeakSet<PolicyAgent>} */
120
+ const removed = new WeakSet()
121
+ /** The exact restriction disposer THIS mount installed per agent.
122
+ * @type {WeakMap<PolicyAgent, () => void>} */
123
+ const restrictions = new WeakMap()
124
+ /** This mount's agents with a restriction installed; traversable so its own
125
+ * teardown lifts exactly its own restrictions (a WeakMap would be
126
+ * unreachable).
127
+ * @type {Set<PolicyAgent>} */
128
+ const liveAgents = new Set()
129
+ /** This mount's reconcile re-entrancy guard: a `tools/change` arriving while
130
+ * a reconcile round is in flight marks `reconcilePending` instead of starting
131
+ * a nested round; the round in flight runs exactly one coalesced catch-up
132
+ * round afterwards. */
133
+ let reconciling = false
134
+ let reconcilePending = false
135
+ /** Set while THIS mount's teardown runs so its own listeners (still
136
+ * registered — they were created before the teardown effect, which cordis
137
+ * disposes first) cannot re-install a restriction being lifted. */
138
+ let disposed = false
139
+
140
+ /**
141
+ * Deny the six terminal tools for one agent, once. A failure (the tools are
142
+ * not registered in this profile yet, so `tools.restrict` rejects the unknown
143
+ * names) is warned once per agent and retried on the next `tools/change`.
144
+ * @param {PolicyAgent} agent - the live agent (its `ctx` is the agent's scoped context).
145
+ */
146
+ const install = (agent) => {
147
+ if (installing.has(agent) || removed.has(agent)) return
148
+ if (restrictions.has(agent)) return
149
+ installing.add(agent)
150
+ try {
151
+ // A fresh array: the frozen constant stays the policy's single source of
152
+ // names, and the toolbox never receives the shared instance.
153
+ const restriction = agent.ctx.tools.restrict({ deny: [...TERMINAL_TOOL_NAMES] })
154
+ restrictions.set(agent, restriction)
155
+ } catch (error) {
156
+ if (!warned.has(agent)) {
157
+ warned.add(agent)
158
+ console.warn(
159
+ `${PLUGIN_TAG}: could not deny the terminal tools for agent ${agent.id ?? '(unnamed)'} yet `
160
+ + '(they are not registered in this profile so far); retrying on the next tools/change:',
161
+ String(error),
162
+ )
163
+ }
164
+ } finally {
165
+ installing.delete(agent)
166
+ }
167
+ }
168
+
169
+ /**
170
+ * Lift one agent's restriction, if THIS mount installed one. The exact
171
+ * disposer is removed from the state BEFORE it runs: lifting a restriction
172
+ * synchronously fires `tools/change` as well.
173
+ * @param {PolicyAgent} agent - the agent whose restriction is lifted.
174
+ * @param {string} label - the operation name in the warning.
175
+ */
176
+ const lift = (agent, label) => {
177
+ const restriction = restrictions.get(agent)
178
+ if (restriction === undefined) return
179
+ restrictions.delete(agent)
180
+ try {
181
+ restriction()
182
+ } catch (error) {
183
+ console.warn(`${PLUGIN_TAG}: could not ${label} terminal tools for agent ${agent.id ?? '(unnamed)'}:`, String(error))
184
+ }
185
+ }
186
+
187
+ /**
188
+ * Ensure every live agent carries this mount's restriction (no-op when the
189
+ * `agents` service is absent). Nested calls (the restriction's own
190
+ * `tools/change`) are absorbed into at most ONE coalesced catch-up round, so
191
+ * the dispatch count stays bounded regardless of how many agents are live.
192
+ * @param {boolean} [catchUp] - whether this round is the coalesced catch-up (never re-triggers one).
193
+ */
194
+ const reconcile = (catchUp = false) => {
195
+ if (reconciling) {
196
+ reconcilePending = true
197
+ return
198
+ }
199
+ reconciling = true
200
+ try {
201
+ for (const agent of ctx.get('agents')?.list() ?? []) {
202
+ liveAgents.add(agent)
203
+ install(agent)
204
+ }
205
+ } finally {
206
+ reconciling = false
207
+ }
208
+ if (reconcilePending && !catchUp) {
209
+ reconcilePending = false
210
+ reconcile(true)
211
+ } else {
212
+ reconcilePending = false
213
+ }
214
+ }
215
+
216
+ // `agent/created` fires synchronously at the agent publication boundary
217
+ // (before the agent's first turn); a synchronous listener error would
218
+ // propagate into the publish path, but `install` contains its own failures.
219
+ ctx.on('agent/created', ({ agent }) => {
220
+ if (disposed) return
221
+ liveAgents.add(agent)
222
+ install(agent)
223
+ })
224
+ ctx.on('agent/disposed', ({ agent }) => {
225
+ if (disposed) return
226
+ removed.add(agent)
227
+ liveAgents.delete(agent)
228
+ lift(agent, 'lift the restriction on the')
229
+ })
230
+ ctx.on('tools/change', () => {
231
+ if (disposed) return
232
+ reconcile()
233
+ })
234
+ // Plugin-owned teardown (unload/disable/HMR): registered LAST so cordis
235
+ // disposes it FIRST (effects run in reverse registration order) — while this
236
+ // loop lifts THIS mount's restrictions, `disposed` above makes its listeners
237
+ // no-ops, so lifting cannot re-install.
238
+ ctx.effect(() => () => {
239
+ disposed = true
240
+ for (const agent of [...liveAgents]) lift(agent, 'lift the restriction on the')
241
+ liveAgents.clear()
242
+ }, 'fish terminal-tools policy teardown')
243
+ reconcile()
244
+ }
245
+
246
+ /**
247
+ * Cordis plugin entry. With `terminalTools: 'deny'` the six `terminal_*` tools
248
+ * are taken away from every live agent — at the `agent/created` publication
249
+ * edge, on every `tools/change`, and for agents that already exist (plugin
250
+ * hot-reload) — and every restriction this mount installed is lifted on its
251
+ * teardown. With the default `allow` nothing is registered and the function
252
+ * returns.
253
+ * @param {import('@deepseek-ai/cordis').Context} ctx - the harness context.
254
+ * @param {{ terminalTools?: 'allow' | 'deny' }} [config] - validated plugin config.
255
+ */
256
+ export function apply(ctx, config) {
257
+ if (config?.terminalTools !== 'deny') return
258
+ mountDenyPolicy(ctx)
259
+ }