weir-harness 1.2.3 → 2.0.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/cordis.patch.yml +7 -18
- package/lib/client.file-lease-badge.js +307 -0
- package/lib/client.js +20 -30
- package/lib/client.settings-page.js +10 -34
- package/package.json +2 -2
- package/src/capabilities/store/checked-contract.d.ts +2 -2
- package/src/capabilities/store/fs-adapter.js +2 -3
- package/src/context-guard/index.js +2 -2
- package/src/delegate/supervision-mount.js +5 -5
- package/src/delegate/tool.js +2 -10
- package/src/file-lease/client.js +1066 -0
- package/src/file-lease/index.js +30 -0
- package/src/file-lease/lineage.js +207 -0
- package/src/file-lease/node-contracts.d.ts +42 -0
- package/src/file-lease/plugin.js +789 -0
- package/src/file-lease/singleton.js +155 -0
- package/src/file-lease/spawn.js +62 -0
- package/src/file-lease/tools.js +459 -0
- package/src/file-lease/view.js +144 -0
- package/src/hashline-edit/index.js +76 -42
- package/src/hashline-edit/write-tool.js +90 -0
- package/src/lease-broker/entry.js +98 -0
- package/src/lease-broker/node-contracts.d.ts +63 -0
- package/src/lease-broker/protocol.js +236 -0
- package/src/lease-broker/server.js +399 -0
- package/src/lease-broker/state.js +425 -0
- package/src/lsp/tools.js +137 -27
- package/src/settings/index.js +2 -44
- package/src/settings/sections.js +2 -24
- package/src/shared/audit.js +6 -1
- package/src/todo-driver/index.js +1 -6
- package/src/worktree/cards.js +6 -21
- package/src/worktree/lane-core.js +1 -19
- package/src/worktree/lane-workflows.js +11 -32
- package/lib/client.edit-lock-maintenance.js +0 -592
- package/lib/client.edit-lock-panel.js +0 -405
- package/src/edit-lock/adapter.js +0 -57
- package/src/edit-lock/admin-ledger.js +0 -149
- package/src/edit-lock/admin-recovery.js +0 -151
- package/src/edit-lock/admission.js +0 -82
- package/src/edit-lock/binding-attestation.js +0 -29
- package/src/edit-lock/binding-guidance.js +0 -72
- package/src/edit-lock/binding-machine.js +0 -312
- package/src/edit-lock/call-context.js +0 -65
- package/src/edit-lock/derived-facts.js +0 -67
- package/src/edit-lock/domain.js +0 -165
- package/src/edit-lock/domains.js +0 -170
- package/src/edit-lock/host.js +0 -149
- package/src/edit-lock/incarnations.js +0 -150
- package/src/edit-lock/index.js +0 -1303
- package/src/edit-lock/inspect.js +0 -219
- package/src/edit-lock/lifecycle.js +0 -579
- package/src/edit-lock/limits.js +0 -61
- package/src/edit-lock/maintenance.js +0 -310
- package/src/edit-lock/manager.js +0 -875
- package/src/edit-lock/negotiation.js +0 -132
- package/src/edit-lock/operation-history.js +0 -214
- package/src/edit-lock/peer-client.js +0 -86
- package/src/edit-lock/peer-transport.js +0 -76
- package/src/edit-lock/peer.js +0 -87
- package/src/edit-lock/publisher.js +0 -191
- package/src/edit-lock/read-authority.js +0 -101
- package/src/edit-lock/recovery.js +0 -139
- package/src/edit-lock/remote.js +0 -362
- package/src/edit-lock/request-binding.js +0 -42
- package/src/edit-lock/request-data.js +0 -40
- package/src/edit-lock/reservation.js +0 -301
- package/src/edit-lock/reserved-runtime.js +0 -45
- package/src/edit-lock/resource-identity.js +0 -130
- package/src/edit-lock/runtime.js +0 -145
- package/src/edit-lock/settle.js +0 -144
- package/src/edit-lock/snapshot.js +0 -175
- package/src/edit-lock/stale-sweep.js +0 -120
- package/src/edit-lock/state.js +0 -462
- package/src/edit-lock/store.js +0 -327
- package/src/edit-lock/tool-scope.js +0 -29
- package/src/edit-lock/view.js +0 -170
- package/src/edit-lock/write-scopes.js +0 -48
- package/src/edit-lock/write-tool.js +0 -45
- package/src/shared/delegate-edit-lifecycle.js +0 -36
- package/src/worktree/authority.js +0 -56
|
@@ -0,0 +1,789 @@
|
|
|
1
|
+
// The preset entry point of the file-lease feature (design D4, D5, D10, D11).
|
|
2
|
+
//
|
|
3
|
+
// This module owns exactly three things the arbiter cannot know:
|
|
4
|
+
//
|
|
5
|
+
// 1. The tool surface: five ownership tools that always exist, plus the
|
|
6
|
+
// negotiation reply tool that exists only while a request is pending. A
|
|
7
|
+
// tool nobody can answer is noise; a tool that vanishes mid-turn makes a
|
|
8
|
+
// late answer report "unknown tool", so the reply tool is retired when its
|
|
9
|
+
// pending set empties and no later than the turn boundary.
|
|
10
|
+
// 2. The turn lifecycle (D10): `turn/end` releases what the turn acquired
|
|
11
|
+
// without releasing retention, `agent/disposed` releases everything,
|
|
12
|
+
// retention included. Both are best-effort — a missed release is covered by
|
|
13
|
+
// the lease validity, which is why neither path may ever throw.
|
|
14
|
+
// 3. Wake delivery (D4): a transfer request addressed to a session that is
|
|
15
|
+
// idle in this process opens ONE turn so it can answer; a request addressed
|
|
16
|
+
// to a session that is mid-turn simply lands inside that turn and costs no
|
|
17
|
+
// extra wake. A request for a session that is not here (another process, a
|
|
18
|
+
// disposed agent) is silently dropped: the arbiter will expire it.
|
|
19
|
+
//
|
|
20
|
+
// The transport is process-wide (D8/D11). Two preset mounts of this bundle
|
|
21
|
+
// share ONE client per socket path, and unmounting must never close it — that
|
|
22
|
+
// would rip the connection out from under the sibling mount. The default
|
|
23
|
+
// client is therefore a module-level lazy singleton, and the injected `client`
|
|
24
|
+
// option is the seam tests (and any future embedder) use instead.
|
|
25
|
+
//
|
|
26
|
+
// Nothing here imports `@deepseek-ai/*`: services arrive through `ctx`.
|
|
27
|
+
|
|
28
|
+
import { createLineageResolver } from './lineage.js'
|
|
29
|
+
import { userTextMessage } from '../shared/user-message.js'
|
|
30
|
+
import { createLeaseTools, REPLY_TOOL, shortName } from './tools.js'
|
|
31
|
+
import { AUDIT_TYPES, createAudit } from '../shared/audit.js'
|
|
32
|
+
import { getSharedLeaseClient } from './singleton.js'
|
|
33
|
+
import { buildLeaseView } from './view.js'
|
|
34
|
+
|
|
35
|
+
/** Plugin name (composition identity only; this row publishes no service). */
|
|
36
|
+
const PLUGIN_NAME = 'weir-file-lease'
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The one read-only HTTP route of the feature (design D13). The path keeps the
|
|
40
|
+
* pre-rebuild name on purpose: the client chunk that reads it, the feature doc
|
|
41
|
+
* and the spec all name `/api/weir-edit-lock/view`, and a rename would buy
|
|
42
|
+
* nothing a reader could see.
|
|
43
|
+
*/
|
|
44
|
+
const VIEW_PATH = '/api/weir-edit-lock/view'
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The host's exact-duplicate-route refusal, and nothing else.
|
|
48
|
+
*
|
|
49
|
+
* Several preset mounts of this bundle live in ONE process (D11), while `fetch`
|
|
50
|
+
* routes are process-wide: the first mount owns the route and the host refuses
|
|
51
|
+
* a later mount's identical registration. Only that exact message is tolerated;
|
|
52
|
+
* every other registration error is a real fault and must surface.
|
|
53
|
+
* @param {unknown} error
|
|
54
|
+
* @returns {boolean}
|
|
55
|
+
*/
|
|
56
|
+
function duplicateRouteRegistration(error) {
|
|
57
|
+
return messageOf(error) === `connection: exact Fetch route ${JSON.stringify(VIEW_PATH)} is already registered`
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Producer tag every injected message carries (AGENTS §3.4). */
|
|
61
|
+
const SOURCE_KIND = 'weir-file-lease'
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Request ids this process has already handed to a holder agent.
|
|
65
|
+
*
|
|
66
|
+
* Two preset mounts share ONE client (D11), so both hear the same `request`
|
|
67
|
+
* event; without this a holder would be woken twice for one request, which
|
|
68
|
+
* D4/spec explicitly forbid ("no second wake is spent for the same request").
|
|
69
|
+
* The arbiter allocates a monotonic request id per request and never reuses
|
|
70
|
+
* one, so a value in this set means exactly "this request was delivered".
|
|
71
|
+
* Cleared when the reply window closes (answer, turn end, disposal).
|
|
72
|
+
* @type {WeakMap<object, Set<string>>}
|
|
73
|
+
*/
|
|
74
|
+
const noticedByAgent = new WeakMap()
|
|
75
|
+
|
|
76
|
+
/** @param {unknown} value @returns {string | undefined} */
|
|
77
|
+
function text(value) {
|
|
78
|
+
return typeof value === 'string' && value.length > 0 ? value : undefined
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The delivery memo for one holder agent.
|
|
83
|
+
* @param {object} agent
|
|
84
|
+
* @returns {Set<string>}
|
|
85
|
+
*/
|
|
86
|
+
function noticedFor(agent) {
|
|
87
|
+
let noticed = noticedByAgent.get(agent)
|
|
88
|
+
if (noticed === undefined) {
|
|
89
|
+
noticed = new Set()
|
|
90
|
+
noticedByAgent.set(agent, noticed)
|
|
91
|
+
}
|
|
92
|
+
return noticed
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** @param {unknown} error @returns {string} */
|
|
96
|
+
function messageOf(error) {
|
|
97
|
+
return String(/** @type {any} */ (error)?.message ?? error)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The session this agent acts for: the durable header id when there is one,
|
|
102
|
+
* else the live session id, else the agent id. Non-empty by construction, and
|
|
103
|
+
* `undefined` only for an agent object with no identity at all — a caller that
|
|
104
|
+
* cannot name a session cannot release one either.
|
|
105
|
+
* @param {any} agent
|
|
106
|
+
* @returns {string | undefined}
|
|
107
|
+
*/
|
|
108
|
+
function sessionIdOf(agent) {
|
|
109
|
+
return text(agent?.session?.header?.id) ?? text(agent?.session?.id) ?? text(agent?.id)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** @param {any} agent @returns {string | undefined} */
|
|
113
|
+
function cwdOf(agent) {
|
|
114
|
+
return text(agent?.session?.header?.cwd)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Resolve `ownerOf(agent)` from the live lineage rule (D9): the lineage id plus
|
|
119
|
+
* the session and label the arbiter only ever formats.
|
|
120
|
+
* @param {() => any} agents lazy accessor for the `agents` service
|
|
121
|
+
* @returns {(agent: any) => { id: string, session: string, label: string }}
|
|
122
|
+
*/
|
|
123
|
+
function createOwnerResolver(agents) {
|
|
124
|
+
const resolver = createLineageResolver({
|
|
125
|
+
// The ancestor walk reads one header per hop; a missing registry (a cold or
|
|
126
|
+
// partially mounted host) degrades to "this session is its own lineage".
|
|
127
|
+
sessions: {
|
|
128
|
+
get: (/** @type {string} */ id) => {
|
|
129
|
+
try {
|
|
130
|
+
return agents()?.get?.(id)
|
|
131
|
+
} catch {
|
|
132
|
+
return undefined
|
|
133
|
+
}
|
|
134
|
+
},
|
|
135
|
+
},
|
|
136
|
+
})
|
|
137
|
+
return (agent) => {
|
|
138
|
+
const lineage = resolver.lineageOf(agent)
|
|
139
|
+
return {
|
|
140
|
+
id: lineage,
|
|
141
|
+
session: sessionIdOf(agent) ?? lineage,
|
|
142
|
+
label: resolver.labelOf(agent),
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Deliver one model-facing notice (AGENTS §3.4): `followup` when the wake flag
|
|
149
|
+
* is set and the agent is idle — that single call is what opens the turn the
|
|
150
|
+
* holder needs in order to answer — and `steer` otherwise, which lands inside a
|
|
151
|
+
* running turn instead of spending an extra one. Both channels failing is
|
|
152
|
+
* silence: the negotiation simply expires and audit belongs to the client (D7).
|
|
153
|
+
*
|
|
154
|
+
* @param {any} agent
|
|
155
|
+
* @param {string} text
|
|
156
|
+
* @param {boolean} [wake]
|
|
157
|
+
* @returns {Promise<boolean>} whether a channel accepted the message
|
|
158
|
+
*/
|
|
159
|
+
async function defaultDeliver(agent, text, wake = false) {
|
|
160
|
+
const message = userTextMessage(text, SOURCE_KIND)
|
|
161
|
+
// `followup` only when asked to wake an idle agent: a followup to a running
|
|
162
|
+
// agent would queue a second turn, which is exactly the cost D4 forbids.
|
|
163
|
+
const channels = wake && agent?.status === 'idle' ? ['followup', 'steer'] : ['steer', 'followup']
|
|
164
|
+
for (const channel of channels) {
|
|
165
|
+
try {
|
|
166
|
+
const send = agent?.[channel]
|
|
167
|
+
if (typeof send !== 'function') continue
|
|
168
|
+
await send.call(agent, message)
|
|
169
|
+
return true
|
|
170
|
+
} catch {
|
|
171
|
+
// Fall through to the other channel; a total failure stays silent.
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return false
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* The request notice addressed to the holder. Model-facing only: it names the
|
|
179
|
+
* requester, names the tool and the request id, and states that inaction keeps
|
|
180
|
+
* ownership. It must never read like something for a human to act on.
|
|
181
|
+
* @param {{ requestId: string, short: string, label: string, fromSession: string, seconds: number }} detail
|
|
182
|
+
* @returns {string}
|
|
183
|
+
*/
|
|
184
|
+
function requestText({ requestId, short, label, fromSession, seconds }) {
|
|
185
|
+
return [
|
|
186
|
+
`Session "${label}" (session ${fromSession}) is asking to take over ${short}.`,
|
|
187
|
+
`Answer with edit_lock_reply: request_id "${requestId}", decision "release" to hand it over, or "keep" to hold on to it.`,
|
|
188
|
+
`Nothing moves until you answer — if you do not answer, ownership stays yours and the request expires by itself (about ${seconds}s left).`,
|
|
189
|
+
].join(' ')
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* The result notice addressed to the requester once its request settled.
|
|
194
|
+
* @param {string} state `transferred` | `declined` | `expired`
|
|
195
|
+
* @param {string} requestId
|
|
196
|
+
* @param {string} short
|
|
197
|
+
* @returns {string}
|
|
198
|
+
*/
|
|
199
|
+
function settledText(state, requestId, short) {
|
|
200
|
+
if (state === 'transferred') {
|
|
201
|
+
return `Request ${requestId}: ownership of ${short} was transferred to this session. It is yours now — edit it, and claim it again only if it lapses.`
|
|
202
|
+
}
|
|
203
|
+
if (state === 'declined') {
|
|
204
|
+
return `Request ${requestId}: the holder kept ${short}. Ownership is unchanged, and it becomes available when its validity ends — do not retry the same edit blindly.`
|
|
205
|
+
}
|
|
206
|
+
return `Request ${requestId}: no answer arrived for ${short}, so the request expired and ownership is unchanged. It becomes available when its validity ends; ask again later if you still need it.`
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Adapt one degradation record from the lease client to the shared audit
|
|
211
|
+
* channel (design D7, `AUDIT_TYPES.fileLease`).
|
|
212
|
+
*
|
|
213
|
+
* The client calls its sink synchronously at the moment a managed write
|
|
214
|
+
* degrades, and rate limits it itself (one record per process window), so this
|
|
215
|
+
* adapter must never throttle again. `detail.session` is the owning session id;
|
|
216
|
+
* the shared channel takes the LIVE session object so the JSONL mirror lands in
|
|
217
|
+
* that session's workspace. A session this process can no longer resolve still
|
|
218
|
+
* emits the in-process event with `session: null` — audit is log-only and the
|
|
219
|
+
* degraded write proceeds either way.
|
|
220
|
+
*
|
|
221
|
+
* The client's detail is flat (`{code, session, lineage, scope, paths, at}`);
|
|
222
|
+
* the record names the owner under `owner` so a reader never depends on the
|
|
223
|
+
* client's internal field names. `paths` is a COUNT — no path content.
|
|
224
|
+
*
|
|
225
|
+
* @param {(session: any, type: string, data?: unknown) => void} audit the shared audit emitter
|
|
226
|
+
* @param {() => any} agents lazy `agents` service accessor
|
|
227
|
+
* @param {string} kind the degradation kind (`'degraded'`)
|
|
228
|
+
* @param {Record<string, any>} [detail] the client's degradation detail
|
|
229
|
+
*/
|
|
230
|
+
function auditLeaseDegradation(audit, agents, kind, detail) {
|
|
231
|
+
try {
|
|
232
|
+
const sessionId = text(detail?.session)
|
|
233
|
+
/** @type {any} */
|
|
234
|
+
let session = null
|
|
235
|
+
if (sessionId !== undefined) {
|
|
236
|
+
try {
|
|
237
|
+
session = agents()?.get?.(sessionId)?.session ?? null
|
|
238
|
+
} catch {
|
|
239
|
+
session = null
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
audit(session, AUDIT_TYPES.fileLease, {
|
|
243
|
+
kind,
|
|
244
|
+
...detail,
|
|
245
|
+
owner: { session: sessionId ?? null, lineage: text(detail?.lineage) ?? null },
|
|
246
|
+
})
|
|
247
|
+
} catch {
|
|
248
|
+
// Audit is log-only: a broken sink must never break a managed write.
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Create one plugin instance. Every collaborator is injectable so a unit test
|
|
254
|
+
* can drive the whole surface with a fake client and a fake ctx: the default
|
|
255
|
+
* client is the process-wide singleton and the default cortex is a live cordis
|
|
256
|
+
* context, neither of which a unit test should touch.
|
|
257
|
+
*
|
|
258
|
+
* @param {object} [options]
|
|
259
|
+
* @param {any} [options.client] lease client (default: the process-wide singleton)
|
|
260
|
+
* @param {(overrides?: any) => any} [options.sharedClient] shared-client factory
|
|
261
|
+
* (default: the process-wide singleton; injectable so a unit test can observe
|
|
262
|
+
* the degradation-audit wiring without a socket)
|
|
263
|
+
* @param {(agent: any) => { id: string, session: string, label: string }} [options.ownerOf] owner identity (default: the lineage rule)
|
|
264
|
+
* @param {(agent: any, text: string, wake?: boolean) => any} [options.deliver] model-facing message channel
|
|
265
|
+
* @param {() => number} [options.now] injected clock (epoch ms)
|
|
266
|
+
* @returns {{ name: string, inject: string[], apply: (ctx: any, config?: { enabled?: boolean }) => (() => void) | undefined }}
|
|
267
|
+
*/
|
|
268
|
+
export function createFileLeasePlugin(options = {}) {
|
|
269
|
+
const injectedClient = options?.client ?? null
|
|
270
|
+
const sharedClient = typeof options?.sharedClient === 'function' ? options.sharedClient : getSharedLeaseClient
|
|
271
|
+
const injectedOwnerOf = options?.ownerOf ?? null
|
|
272
|
+
const injectedDeliver = options?.deliver ?? null
|
|
273
|
+
const now = typeof options?.now === 'function' ? options.now : Date.now
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Mount this row.
|
|
277
|
+
*
|
|
278
|
+
* `config.enabled === false` is a COMPOSITION-LAYER SEAM, not a user setting
|
|
279
|
+
* and not a feature flag: it exists so a test composition (or an embedder) can
|
|
280
|
+
* mount this row switched off for one boot without deleting it from the patch.
|
|
281
|
+
* The production presets never write that key — the feature is always on there
|
|
282
|
+
* — so no settings schema, no command and no documentation promises it. With
|
|
283
|
+
* the seam off the mount does nothing at all: no tool is registered and no
|
|
284
|
+
* event is subscribed, which is exactly what "this row is not here" means.
|
|
285
|
+
* Every other value (including omitted) is enabled.
|
|
286
|
+
*
|
|
287
|
+
* @param {any} ctx the row's cordis context
|
|
288
|
+
* @param {{ enabled?: boolean }} [config] composition config (`enabled` seam only)
|
|
289
|
+
* @returns {(() => void) | undefined} the unmount disposer
|
|
290
|
+
*/
|
|
291
|
+
function apply(ctx, config) {
|
|
292
|
+
// The seam is checked before anything touches the ctx: a disabled mount must
|
|
293
|
+
// leave no tool, no listener and no subscription behind (design D11 mount
|
|
294
|
+
// lifetime), so there is nothing to return a disposer for.
|
|
295
|
+
if (config?.enabled === false) return undefined
|
|
296
|
+
/** @type {(() => void)[]} */
|
|
297
|
+
const disposers = []
|
|
298
|
+
/** @type {WeakMap<object, Map<string, { path: string, from: any }>>} */
|
|
299
|
+
const pendingByAgent = new WeakMap()
|
|
300
|
+
/** @type {WeakMap<object, { dispose: () => void }>} */
|
|
301
|
+
const replyTools = new WeakMap()
|
|
302
|
+
/** @type {Set<any>} agents whose reply tool this mount registered */
|
|
303
|
+
const replyAgents = new Set()
|
|
304
|
+
/**
|
|
305
|
+
* `requestId -> the session that asked for it`. A `settled` event carries no
|
|
306
|
+
* session (it is pushed to the requesting connection, which every session of
|
|
307
|
+
* this process shares), so this map is the only way to address the outcome.
|
|
308
|
+
* It is routing state, never authority: the arbiter decides (D6).
|
|
309
|
+
* @type {Map<string, { session: string, path: string }>}
|
|
310
|
+
*/
|
|
311
|
+
const issued = new Map()
|
|
312
|
+
let disposed = false
|
|
313
|
+
/** @type {() => void} this mount's read-only view route disposer (D13) */
|
|
314
|
+
let disposeView = () => {}
|
|
315
|
+
|
|
316
|
+
/** @returns {any} the `agents` service, or undefined when it is not mounted */
|
|
317
|
+
function agents() {
|
|
318
|
+
try {
|
|
319
|
+
return ctx.get?.('agents')
|
|
320
|
+
} catch {
|
|
321
|
+
return undefined
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// The process-wide client (D11): shared with every other mount of this row
|
|
326
|
+
// and never closed by an unmount. An injected client wins and creates nothing.
|
|
327
|
+
//
|
|
328
|
+
// D7 wiring: the client's own degradation sink is already rate limited per
|
|
329
|
+
// process, so the adapter below only maps it onto the shared audit channel
|
|
330
|
+
// (never a second throttle). Only the SHARED client gets it — an injected
|
|
331
|
+
// client belongs to its embedder and keeps whatever sink it was built with.
|
|
332
|
+
const weirAudit = createAudit(ctx)
|
|
333
|
+
const client = injectedClient ?? sharedClient({
|
|
334
|
+
audit: (/** @type {string} */ kind, /** @type {Record<string, any>} */ detail) =>
|
|
335
|
+
auditLeaseDegradation(weirAudit, agents, kind, detail),
|
|
336
|
+
})
|
|
337
|
+
const ownerOf = injectedOwnerOf ?? createOwnerResolver(agents)
|
|
338
|
+
const deliver = injectedDeliver ?? defaultDeliver
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Deliver a notice without letting delivery break the caller: both channels
|
|
342
|
+
* failing is silence, by contract.
|
|
343
|
+
* @param {any} agent @param {string} text @param {boolean} [wake]
|
|
344
|
+
*/
|
|
345
|
+
function notify(agent, text, wake = false) {
|
|
346
|
+
if (disposed) return
|
|
347
|
+
try {
|
|
348
|
+
const outcome = deliver(agent, text, wake)
|
|
349
|
+
const promise = /** @type {any} */ (outcome)
|
|
350
|
+
if (promise !== null && typeof promise === 'object' && typeof promise.then === 'function') {
|
|
351
|
+
promise.then(undefined, () => {})
|
|
352
|
+
}
|
|
353
|
+
} catch {
|
|
354
|
+
// Silent: the negotiation expires on its own.
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Run a best-effort release. Never throws and never keeps state to retry:
|
|
360
|
+
* the lease validity is the guarantee, this is the optimisation (D10).
|
|
361
|
+
* @param {() => any} run
|
|
362
|
+
*/
|
|
363
|
+
function quiet(run) {
|
|
364
|
+
try {
|
|
365
|
+
const outcome = run()
|
|
366
|
+
const promise = /** @type {any} */ (outcome)
|
|
367
|
+
if (promise !== null && typeof promise === 'object' && typeof promise.then === 'function') {
|
|
368
|
+
promise.then(undefined, () => {})
|
|
369
|
+
}
|
|
370
|
+
} catch {
|
|
371
|
+
// Same rule as notify().
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/** @param {string | undefined} session @returns {any} */
|
|
376
|
+
function agentFor(session) {
|
|
377
|
+
if (session === undefined) return undefined
|
|
378
|
+
try {
|
|
379
|
+
return agents()?.get?.(session)
|
|
380
|
+
} catch {
|
|
381
|
+
return undefined
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/** @param {any} agent @returns {Map<string, { path: string, from: any }>} */
|
|
386
|
+
function pendingOf(agent) {
|
|
387
|
+
let pending = pendingByAgent.get(agent)
|
|
388
|
+
if (pending === undefined) {
|
|
389
|
+
pending = new Map()
|
|
390
|
+
pendingByAgent.set(agent, pending)
|
|
391
|
+
}
|
|
392
|
+
return pending
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Register the reply tool on THIS AGENT's scope (not the mount's): the tool
|
|
397
|
+
* answers a request addressed to one holder, and a tool visible to every
|
|
398
|
+
* agent in the preset would offer an answer nobody can give.
|
|
399
|
+
*
|
|
400
|
+
* Idempotent by construction — a second mount on the same agent scope must
|
|
401
|
+
* not throw, and a registration of the same name that this mount does not
|
|
402
|
+
* own is left alone.
|
|
403
|
+
* @param {any} agent
|
|
404
|
+
*/
|
|
405
|
+
function registerReplyTool(agent) {
|
|
406
|
+
if (disposed || replyTools.has(agent)) return
|
|
407
|
+
const scope = agent?.ctx
|
|
408
|
+
if (!scope?.tools?.register) return
|
|
409
|
+
try {
|
|
410
|
+
if (typeof scope.tools.get === 'function' && scope.tools.get(REPLY_TOOL) !== undefined) return
|
|
411
|
+
} catch {
|
|
412
|
+
// A registry probe must never break delivery.
|
|
413
|
+
}
|
|
414
|
+
try {
|
|
415
|
+
const dispose = scope.tools.register(replyToolFor(agent))
|
|
416
|
+
replyTools.set(agent, { dispose: typeof dispose === 'function' ? dispose : () => {} })
|
|
417
|
+
replyAgents.add(agent)
|
|
418
|
+
} catch (error) {
|
|
419
|
+
ctx.logger?.warn?.(`file-lease: the reply tool could not be registered: ${messageOf(error)}`)
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* The reply definition, wrapped so this mount learns that a pending request
|
|
425
|
+
* was answered (the broker reports it to the requester, not to the holder).
|
|
426
|
+
* @param {any} agent
|
|
427
|
+
* @returns {any}
|
|
428
|
+
*/
|
|
429
|
+
function replyToolFor(agent) {
|
|
430
|
+
const definition = /** @type {any} */ (replyDefinition)
|
|
431
|
+
return {
|
|
432
|
+
...definition,
|
|
433
|
+
async execute(/** @type {any} */ args, /** @type {any} */ exec) {
|
|
434
|
+
try {
|
|
435
|
+
return await definition.execute(args, exec)
|
|
436
|
+
} finally {
|
|
437
|
+
answered(agent, args?.request_id)
|
|
438
|
+
}
|
|
439
|
+
},
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* One pending request is gone; the reply tool follows when none is left.
|
|
445
|
+
* @param {any} agent @param {unknown} requestId
|
|
446
|
+
*/
|
|
447
|
+
function answered(agent, requestId) {
|
|
448
|
+
const pending = pendingByAgent.get(agent)
|
|
449
|
+
if (pending === undefined) return
|
|
450
|
+
const id = text(requestId)
|
|
451
|
+
if (id !== undefined) pending.delete(id)
|
|
452
|
+
if (pending.size === 0) retireReplyTool(agent)
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* The reply window for this agent is over: the tool goes, and the delivery
|
|
457
|
+
* memo goes with it so a later request is never mistaken for a repeat.
|
|
458
|
+
* @param {any} agent
|
|
459
|
+
*/
|
|
460
|
+
function retireReplyTool(agent) {
|
|
461
|
+
if (agent === null || typeof agent !== 'object') return
|
|
462
|
+
const registered = replyTools.get(agent)
|
|
463
|
+
replyTools.delete(agent)
|
|
464
|
+
replyAgents.delete(agent)
|
|
465
|
+
pendingByAgent.delete(agent)
|
|
466
|
+
noticedByAgent.delete(agent)
|
|
467
|
+
if (registered === undefined) return
|
|
468
|
+
try {
|
|
469
|
+
registered.dispose()
|
|
470
|
+
} catch {
|
|
471
|
+
// Already unregistered by the scope's own teardown.
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/** @param {string | undefined} session */
|
|
476
|
+
function forgetIssued(session) {
|
|
477
|
+
if (session === undefined) return
|
|
478
|
+
for (const [requestId, entry] of [...issued]) if (entry.session === session) issued.delete(requestId)
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* The turn boundary (D10). ANY reason releases — completed, aborted by the
|
|
483
|
+
* user, or an error: the turn is over, so the session is no longer editing,
|
|
484
|
+
* and the next message must be able to edit immediately. Retention is what
|
|
485
|
+
* survives a boundary, which `releaseSession` already encodes.
|
|
486
|
+
* @param {any} agent
|
|
487
|
+
*/
|
|
488
|
+
function releaseOnTurnEnd(agent) {
|
|
489
|
+
const session = sessionIdOf(agent)
|
|
490
|
+
// The reply tool exists for one turn's pending answers; the boundary ends
|
|
491
|
+
// that window whether or not the holder replied.
|
|
492
|
+
retireReplyTool(agent)
|
|
493
|
+
forgetIssued(session)
|
|
494
|
+
if (session === undefined) return
|
|
495
|
+
quiet(() => client.releaseSession(session))
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* A disposed agent never comes back, so its retention ends with it (D10).
|
|
500
|
+
* @param {any} agent
|
|
501
|
+
*/
|
|
502
|
+
function releaseOnDisposed(agent) {
|
|
503
|
+
const session = sessionIdOf(agent)
|
|
504
|
+
retireReplyTool(agent)
|
|
505
|
+
forgetIssued(session)
|
|
506
|
+
if (session === undefined) return
|
|
507
|
+
quiet(() => client.releaseAll(session))
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* The holder a `request` event is addressed to. The wire event names the
|
|
512
|
+
* requester (`from`) but not the holder — it is pushed to the holder's
|
|
513
|
+
* connection, which this process shares across every session — so the
|
|
514
|
+
* holder is whoever the arbiter reports holding that path (D8). A future
|
|
515
|
+
* transport that names the holder is honoured first.
|
|
516
|
+
* @param {any} event @param {string} path
|
|
517
|
+
* @returns {Promise<string | undefined>}
|
|
518
|
+
*/
|
|
519
|
+
async function holderSession(event, path) {
|
|
520
|
+
const named = text(event?.session) ?? text(event?.holder?.session) ?? text(event?.to?.session)
|
|
521
|
+
if (named !== undefined) return named
|
|
522
|
+
try {
|
|
523
|
+
const reply = await client.status({ path })
|
|
524
|
+
const leases = Array.isArray(reply?.leases) ? reply.leases : []
|
|
525
|
+
return text(leases[0]?.owner?.session)
|
|
526
|
+
} catch {
|
|
527
|
+
return undefined
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/** @param {any} event */
|
|
532
|
+
async function onRequest(event) {
|
|
533
|
+
const requestId = text(event?.requestId)
|
|
534
|
+
const path = text(event?.path)
|
|
535
|
+
if (requestId === undefined || path === undefined) return
|
|
536
|
+
const session = await holderSession(event, path)
|
|
537
|
+
if (session === undefined) return
|
|
538
|
+
const agent = agentFor(session)
|
|
539
|
+
// Cross-process or already gone: drop silently. The arbiter expires the
|
|
540
|
+
// request and the requester is told; nothing here is asked to fix it.
|
|
541
|
+
if (!agent) return
|
|
542
|
+
// One delivery per request per process, however many mounts are alive.
|
|
543
|
+
const noticed = noticedFor(agent)
|
|
544
|
+
if (noticed.has(requestId)) return
|
|
545
|
+
noticed.add(requestId)
|
|
546
|
+
pendingOf(agent).set(requestId, { path, from: event?.from })
|
|
547
|
+
registerReplyTool(agent)
|
|
548
|
+
const from = event?.from ?? {}
|
|
549
|
+
const label = text(from.label) ?? 'another session'
|
|
550
|
+
const fromSession = text(from.session) ?? 'unknown'
|
|
551
|
+
const expiresAt = typeof event?.expiresAt === 'number' ? event.expiresAt : now()
|
|
552
|
+
const seconds = Math.max(0, Math.ceil((expiresAt - now()) / 1000))
|
|
553
|
+
notify(agent, requestText({ requestId, short: shortName(path, cwdOf(agent)), label, fromSession, seconds }), agent?.status === 'idle')
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/** @param {any} event */
|
|
557
|
+
function onSettled(event) {
|
|
558
|
+
const requestId = text(event?.requestId)
|
|
559
|
+
if (requestId === undefined) return
|
|
560
|
+
const entry = issued.get(requestId)
|
|
561
|
+
issued.delete(requestId)
|
|
562
|
+
// A request issued by another process settles in that process.
|
|
563
|
+
if (entry === undefined) return
|
|
564
|
+
const agent = agentFor(entry.session)
|
|
565
|
+
if (!agent) return
|
|
566
|
+
const state = text(event?.state) ?? 'expired'
|
|
567
|
+
notify(agent, settledText(state, requestId, shortName(entry.path, cwdOf(agent))), agent?.status === 'idle')
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/** @param {any} event */
|
|
571
|
+
async function onClientEvent(event) {
|
|
572
|
+
if (disposed || event === null || typeof event !== 'object') return
|
|
573
|
+
try {
|
|
574
|
+
if (event.event === 'request') await onRequest(event)
|
|
575
|
+
else if (event.event === 'settled') onSettled(event)
|
|
576
|
+
} catch {
|
|
577
|
+
// A listener is a consumer; it must never break the transport.
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
/**
|
|
582
|
+
* The tools see the client through this narrow facade for one reason: a
|
|
583
|
+
* `settled` event carries no session, so the plugin has to remember which
|
|
584
|
+
* session issued which requestId at the moment the request is made. The
|
|
585
|
+
* facade adds that bookkeeping and nothing else — every call still ends in
|
|
586
|
+
* the arbiter.
|
|
587
|
+
*/
|
|
588
|
+
const routedClient = Object.freeze({
|
|
589
|
+
acquire: (/** @type {any} */ owner, /** @type {any} */ paths, /** @type {any} */ scope) => client.acquire(owner, paths, scope),
|
|
590
|
+
hold: (/** @type {any} */ paths, /** @type {any} */ minutes, /** @type {any} */ owner) => client.hold(paths, minutes, owner),
|
|
591
|
+
status: (/** @type {any} */ filter) => client.status(filter),
|
|
592
|
+
request: (/** @type {any} */ op, /** @type {any} */ payload) => client.request(op, payload),
|
|
593
|
+
reply: (/** @type {any} */ input) => client.reply(input),
|
|
594
|
+
requestTransfer: async (/** @type {any} */ path, /** @type {any} */ from) => {
|
|
595
|
+
const reply = await client.requestTransfer(path, from)
|
|
596
|
+
const requestId = text(reply?.requestId)
|
|
597
|
+
if (requestId !== undefined) issued.set(requestId, { session: text(from?.session) ?? '', path })
|
|
598
|
+
return reply
|
|
599
|
+
},
|
|
600
|
+
})
|
|
601
|
+
|
|
602
|
+
const definitions = createLeaseTools({
|
|
603
|
+
client: routedClient,
|
|
604
|
+
ownerOf,
|
|
605
|
+
deliverNotice: (agent, text) => notify(agent, text, false),
|
|
606
|
+
})
|
|
607
|
+
const replyDefinition = definitions.find((definition) => definition?.name === REPLY_TOOL)
|
|
608
|
+
const staticDefinitions = definitions.filter((definition) => definition?.name !== REPLY_TOOL)
|
|
609
|
+
|
|
610
|
+
for (const definition of staticDefinitions) {
|
|
611
|
+
// A second mount on the same scope must not throw: the tool is already
|
|
612
|
+
// there, provided by the sibling mount (D11 allows exactly that shape).
|
|
613
|
+
try {
|
|
614
|
+
if (typeof ctx.tools?.get === 'function' && ctx.tools.get(definition.name) !== undefined) continue
|
|
615
|
+
} catch {
|
|
616
|
+
// A registry probe must never abort the mount.
|
|
617
|
+
}
|
|
618
|
+
try {
|
|
619
|
+
const dispose = ctx.tools?.register?.(definition)
|
|
620
|
+
if (typeof dispose === 'function') disposers.push(dispose)
|
|
621
|
+
} catch (error) {
|
|
622
|
+
ctx.logger?.warn?.(`file-lease: tool ${String(definition?.name)} was not registered: ${messageOf(error)}`)
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
// The turn boundary. `turn/end` is the host-level event this surface is
|
|
627
|
+
// defined against; this runtime publishes the same boundary as the durable
|
|
628
|
+
// `turn/end` session event, so both feed the ONE handler (release is
|
|
629
|
+
// idempotent per protocol, so a host that emits both is harmless).
|
|
630
|
+
const offTurnEnd = ctx.on?.('turn/end', (/** @type {any} */ payload) => {
|
|
631
|
+
releaseOnTurnEnd(payload?.agent)
|
|
632
|
+
})
|
|
633
|
+
if (typeof offTurnEnd === 'function') disposers.push(offTurnEnd)
|
|
634
|
+
|
|
635
|
+
const offSession = ctx.on?.('session/event', (/** @type {any} */ session, /** @type {any} */ event) => {
|
|
636
|
+
if (event?.type !== 'turn/end') return
|
|
637
|
+
const agent = agentFor(text(session?.id))
|
|
638
|
+
if (agent === undefined) return
|
|
639
|
+
releaseOnTurnEnd(agent)
|
|
640
|
+
})
|
|
641
|
+
if (typeof offSession === 'function') disposers.push(offSession)
|
|
642
|
+
|
|
643
|
+
const offDisposed = ctx.on?.('agent/disposed', (/** @type {any} */ payload) => {
|
|
644
|
+
releaseOnDisposed(payload?.agent)
|
|
645
|
+
})
|
|
646
|
+
if (typeof offDisposed === 'function') disposers.push(offDisposed)
|
|
647
|
+
|
|
648
|
+
/** @type {() => void} */
|
|
649
|
+
let offEvents = () => {}
|
|
650
|
+
try {
|
|
651
|
+
const off = client.onEvent((/** @type {any} */ event) => {
|
|
652
|
+
void onClientEvent(event)
|
|
653
|
+
})
|
|
654
|
+
if (typeof off === 'function') offEvents = off
|
|
655
|
+
} catch (error) {
|
|
656
|
+
ctx.logger?.warn?.(`file-lease: negotiation events are unavailable: ${messageOf(error)}`)
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
/**
|
|
660
|
+
* The arbiter's health as the read-only surface reports it (D13): the client's
|
|
661
|
+
* own connection state, never a fresh probe, so looking cannot change a
|
|
662
|
+
* verdict. A client that cannot answer is `unavailable` — an unknown
|
|
663
|
+
* connection must never be presented as healthy.
|
|
664
|
+
* @returns {'ok' | 'degraded' | 'unavailable'}
|
|
665
|
+
*/
|
|
666
|
+
function arbiterState() {
|
|
667
|
+
try {
|
|
668
|
+
const state = typeof client.connectionState === 'function' ? client.connectionState() : 'unavailable'
|
|
669
|
+
return state === 'ok' || state === 'degraded' || state === 'unavailable' ? state : 'unavailable'
|
|
670
|
+
} catch {
|
|
671
|
+
return 'unavailable'
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
// ---- The read-only ownership view (design D13) --------------------------
|
|
676
|
+
//
|
|
677
|
+
// The ONLY user-facing ownership read in the rebuild, and it reads. Nothing
|
|
678
|
+
// in this handler acquires, releases, retains, negotiates or records
|
|
679
|
+
// anything: no lease changes, no conversation entry, no audit row. Every
|
|
680
|
+
// ownership state resolves by itself (turn end, expiry, negotiation; D10),
|
|
681
|
+
// so a surface that could act would reintroduce exactly the "some state
|
|
682
|
+
// needs a human" the redesign deletes.
|
|
683
|
+
//
|
|
684
|
+
// `connection` is a host-plane service, so the preset realm cannot hide it.
|
|
685
|
+
// Its fetch routes are process-wide (D11), which is why a second mount's
|
|
686
|
+
// identical registration is tolerated below and nothing else is.
|
|
687
|
+
ctx.inject?.(['connection'], (/** @type {any} */ scope) => {
|
|
688
|
+
const fetchRegistry = scope?.connection?.fetch
|
|
689
|
+
if (!fetchRegistry?.register) return
|
|
690
|
+
const reply = (/** @type {any} */ payload, /** @type {number} */ status = 200) =>
|
|
691
|
+
new Response(JSON.stringify(payload), { status, headers: { 'content-type': 'application/json' } })
|
|
692
|
+
/** @type {(() => void) | undefined} */
|
|
693
|
+
let dispose
|
|
694
|
+
try {
|
|
695
|
+
dispose = fetchRegistry.register({
|
|
696
|
+
path: VIEW_PATH,
|
|
697
|
+
methods: ['POST'],
|
|
698
|
+
requestBody: 'buffered',
|
|
699
|
+
fetch: async (/** @type {any} */ request) => {
|
|
700
|
+
let body
|
|
701
|
+
try {
|
|
702
|
+
body = await request.json()
|
|
703
|
+
} catch {
|
|
704
|
+
body = null
|
|
705
|
+
}
|
|
706
|
+
const sessionId = typeof body?.sessionId === 'string' ? body.sessionId : ''
|
|
707
|
+
if (sessionId === '') {
|
|
708
|
+
return reply({ ok: false, error: { code: 'weir-edit-lock/invalid', message: 'body needs { sessionId }' } }, 400)
|
|
709
|
+
}
|
|
710
|
+
const agent = agentFor(sessionId)
|
|
711
|
+
const cwd = cwdOf(agent)
|
|
712
|
+
// A session with no live agent is its own lineage (D9), and its
|
|
713
|
+
// leases died with the process that held them (D10): the honest
|
|
714
|
+
// answer is "nothing held", never an error to act on.
|
|
715
|
+
const mineLineage = agent === undefined ? sessionId : ownerOf(agent).id
|
|
716
|
+
/** @type {any[]} */
|
|
717
|
+
let leases = []
|
|
718
|
+
try {
|
|
719
|
+
const snapshot = await client.status(cwd === undefined ? {} : { scope: cwd })
|
|
720
|
+
leases = Array.isArray(snapshot?.leases) ? snapshot.leases : []
|
|
721
|
+
} catch {
|
|
722
|
+
// An unreadable arbiter IS the degraded state (D7): answer with the
|
|
723
|
+
// same envelope so the panel reports it, and never claim "nothing
|
|
724
|
+
// held" on the strength of a failed read.
|
|
725
|
+
leases = []
|
|
726
|
+
}
|
|
727
|
+
return reply({ ok: true, value: buildLeaseView({ leases, mineLineage, cwd, arbiter: arbiterState(), now: now() }) })
|
|
728
|
+
},
|
|
729
|
+
})
|
|
730
|
+
} catch (error) {
|
|
731
|
+
// Duplicate-route tolerance (D11): the first mount in this process owns
|
|
732
|
+
// this process-wide route, and the host refuses a later mount's
|
|
733
|
+
// identical registration. Skipping ONLY that exact refusal keeps this
|
|
734
|
+
// mount's inject callback intact instead of aborting it mid-mount, and
|
|
735
|
+
// correctness never depends on mount order because the client and the
|
|
736
|
+
// owner rule are process-wide too.
|
|
737
|
+
if (!duplicateRouteRegistration(error)) throw error
|
|
738
|
+
return
|
|
739
|
+
}
|
|
740
|
+
disposeView = () => {
|
|
741
|
+
try {
|
|
742
|
+
dispose?.()
|
|
743
|
+
} catch {
|
|
744
|
+
// Already released by the injection scope's own teardown.
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
return disposeView
|
|
748
|
+
})
|
|
749
|
+
|
|
750
|
+
// Unmount: drop this mount's tools, its listeners, its event subscription and
|
|
751
|
+
// its view route. The client and the daemon keep running (D11) — a sibling
|
|
752
|
+
// mount, or the next mount of this row, owns the connection.
|
|
753
|
+
return () => {
|
|
754
|
+
if (disposed) return
|
|
755
|
+
disposed = true
|
|
756
|
+
try {
|
|
757
|
+
offEvents()
|
|
758
|
+
} catch {
|
|
759
|
+
// Already gone.
|
|
760
|
+
}
|
|
761
|
+
try {
|
|
762
|
+
disposeView()
|
|
763
|
+
} catch {
|
|
764
|
+
// Already released by the injection scope's own teardown.
|
|
765
|
+
}
|
|
766
|
+
disposeView = () => {}
|
|
767
|
+
for (const agent of [...replyAgents]) retireReplyTool(agent)
|
|
768
|
+
replyAgents.clear()
|
|
769
|
+
issued.clear()
|
|
770
|
+
for (const dispose of disposers.splice(0)) {
|
|
771
|
+
try {
|
|
772
|
+
dispose()
|
|
773
|
+
} catch {
|
|
774
|
+
// Already disposed by the scope's own teardown.
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
return { name: PLUGIN_NAME, inject: ['tools', 'agents'], apply }
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
/**
|
|
784
|
+
* The default instance: same export shape as every other row in this bundle, so
|
|
785
|
+
* `cordis.patch.yml` can mount the module directly.
|
|
786
|
+
*/
|
|
787
|
+
const { name, inject, apply } = createFileLeasePlugin()
|
|
788
|
+
|
|
789
|
+
export { name, inject, apply }
|