weir-harness 2.0.0 → 2.1.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/lib/client.capability-badge.js +2 -0
- package/lib/client.capability-manager.js +46 -179
- package/lib/client.capability-model.js +428 -3
- package/lib/client.file-lease-tool-model.js +189 -0
- package/lib/client.file-lease-tool-view.js +434 -0
- package/lib/client.js +105 -7
- package/lib/client.mcp-manager.js +563 -0
- package/package.json +1 -1
- package/skills/mcp-server-setup/SKILL.md +124 -0
- package/src/capabilities/apply-engine.js +18 -4
- package/src/capabilities/capability-remote.js +296 -45
- package/src/capabilities/initial-selection.js +3 -2
- package/src/capabilities/lifecycle-preload.js +69 -11
- package/src/capabilities/lifecycle-snapshot.js +127 -39
- package/src/capabilities/mcp-admission.js +2 -2
- package/src/capabilities/mcp-config.js +344 -0
- package/src/capabilities/mcp-credentials.js +180 -0
- package/src/capabilities/mcp-diagnostics.js +169 -0
- package/src/capabilities/mcp-drain.js +18 -4
- package/src/capabilities/mcp-facade.js +3 -3
- package/src/capabilities/mcp-gate.js +51 -52
- package/src/capabilities/mcp-install-tool.js +276 -0
- package/src/capabilities/mcp-manager.js +368 -142
- package/src/capabilities/mcp-mount.js +219 -79
- package/src/capabilities/mcp-operations.js +359 -0
- package/src/capabilities/mcp-process.js +43 -0
- package/src/capabilities/mcp-registry.js +74 -60
- package/src/capabilities/mcp-runtime.js +103 -0
- package/src/capabilities/read-payloads.js +8 -3
- package/src/capabilities/skill-applicability.js +185 -0
- package/src/capabilities/skill-builtin-migration.js +2 -1
- package/src/capabilities/skill-selection-plugin.js +161 -56
- package/src/file-lease/client.js +268 -35
- package/src/file-lease/index.js +4 -0
- package/src/file-lease/plugin.js +239 -15
- package/src/file-lease/tools.js +426 -38
- package/src/file-lease/watch.js +473 -0
- package/src/intent-gate/index.js +29 -12
- package/src/intent-gate/matcher.js +36 -2
- package/src/lease-broker/protocol.js +3 -2
- package/src/lease-broker/server.js +6 -3
- package/src/lease-broker/state.js +68 -11
- package/src/process/index.js +4 -2
- package/src/shared/audit.js +3 -0
- package/src/shared/child-scope.js +2 -0
- package/src/worktree/rules.js +1 -1
|
@@ -0,0 +1,473 @@
|
|
|
1
|
+
// The in-memory release-subscription coordinator behind `edit_lock_watch`.
|
|
2
|
+
//
|
|
3
|
+
// There is ONE coordinator per lease client (design D2/D4), not one per preset
|
|
4
|
+
// mount. Every mount of this bundle shares one client, so it must also share one
|
|
5
|
+
// view of "which session is waiting for which path": a per-mount registry would
|
|
6
|
+
// let two mounts subscribe the same `(session, path)` twice — two entries, two
|
|
7
|
+
// notices for one release — and would let one mount's teardown cancel a wait
|
|
8
|
+
// another mount still owns.
|
|
9
|
+
//
|
|
10
|
+
// The coordinator holds three collections and nothing else:
|
|
11
|
+
//
|
|
12
|
+
// active one entry per `(session, normalized path)`, in `watching`
|
|
13
|
+
// pendingDelivery consumed entries (`hit` / `reset`) whose notice has not
|
|
14
|
+
// been delivered yet, so disposal can still cancel them
|
|
15
|
+
// mounts the mount references that may deliver a notice
|
|
16
|
+
//
|
|
17
|
+
// The five states are a closed set:
|
|
18
|
+
//
|
|
19
|
+
// watching — registered, waiting for a release fact
|
|
20
|
+
// hit — a release fact consumed this subscription; it is over
|
|
21
|
+
// reset — the connection that could have delivered the fact is gone; it
|
|
22
|
+
// is over, and NOTHING is claimed about ownership
|
|
23
|
+
// self — this session already owns the path (an authoritative read said
|
|
24
|
+
// so, or a transfer settled); it is over, and there is nothing
|
|
25
|
+
// left to wait for
|
|
26
|
+
// cancelled — the subscriber went away (agent disposal, unmount, or a read
|
|
27
|
+
// that found the path free); it is over
|
|
28
|
+
//
|
|
29
|
+
// Consumption is one-way and happens in the event callback, BEFORE any notice is
|
|
30
|
+
// scheduled, so a duplicate or repeated release observation can never deliver a
|
|
31
|
+
// subscription twice. The notice itself is deferred by one turn of the event
|
|
32
|
+
// loop, because its caller is a socket callback and a message must never be
|
|
33
|
+
// injected from inside the transport's publish stack.
|
|
34
|
+
//
|
|
35
|
+
// Nothing here talks to a socket or a cordis context. A mount handle supplies
|
|
36
|
+
// the two things only the plugin knows: how to resolve the live agent for a
|
|
37
|
+
// session, and how to deliver a notice to it. Everything else — entries, the
|
|
38
|
+
// one shared status flight per entry, the pending-delivery window, the deferred
|
|
39
|
+
// timer — is decided here.
|
|
40
|
+
|
|
41
|
+
/** The closed subscription-state vocabulary. */
|
|
42
|
+
export const WATCH_STATES = Object.freeze(['watching', 'hit', 'reset', 'self', 'cancelled'])
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* @typedef {'watching' | 'hit' | 'reset' | 'self' | 'cancelled'} WatchState
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* @typedef {object} WatchEntry
|
|
50
|
+
* @property {string} sessionId the session that subscribed
|
|
51
|
+
* @property {string} path normalized absolute path
|
|
52
|
+
* @property {WatchState} state
|
|
53
|
+
* @property {boolean} settledByTool true once a running tool already reported
|
|
54
|
+
* this episode to the model, so a deferred notice would only repeat it
|
|
55
|
+
* @property {any} agent the agent that subscribed: delivery re-resolves the
|
|
56
|
+
* session and refuses to land on a replacement agent
|
|
57
|
+
* @property {Promise<any> | null} statusFlight the ONE authoritative read this
|
|
58
|
+
* entry is currently sharing, so overlapping calls cannot disagree
|
|
59
|
+
* @property {Set<WatchMount>} mounts the mount references that still want it
|
|
60
|
+
* @property {ReturnType<typeof setTimeout> | null} timer the deferred notice
|
|
61
|
+
*/
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* @typedef {object} WatchMount
|
|
65
|
+
* @property {boolean} disposed set by the coordinator when the mount leaves
|
|
66
|
+
* @property {(sessionId: string) => any} agentFor the live agent for a session
|
|
67
|
+
* @property {(entry: WatchEntry, agent: any) => void} notify deliver one notice
|
|
68
|
+
*/
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* @typedef {object} WatchProjection
|
|
72
|
+
* @property {string} sessionId
|
|
73
|
+
* @property {string} path
|
|
74
|
+
* @property {WatchState} state
|
|
75
|
+
* @property {boolean} settledByTool
|
|
76
|
+
*/
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Do not keep a finished process alive for a notice (design D4).
|
|
80
|
+
* @param {ReturnType<typeof setTimeout>} timer
|
|
81
|
+
*/
|
|
82
|
+
function unrefTimer(timer) {
|
|
83
|
+
const handle = /** @type {{ unref?: () => void }} */ (/** @type {unknown} */ (timer))
|
|
84
|
+
if (typeof handle.unref === 'function') handle.unref()
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The coordinator surface: one factory is the single definition of what a
|
|
89
|
+
* consumer holds, so the tool surface and the plugin row never re-describe it.
|
|
90
|
+
* @typedef {ReturnType<typeof createWatchCoordinator>} WatchCoordinator
|
|
91
|
+
*/
|
|
92
|
+
export function createWatchCoordinator() {
|
|
93
|
+
/** @type {Map<string, WatchEntry>} */
|
|
94
|
+
const active = new Map()
|
|
95
|
+
/** @type {Set<WatchEntry>} */
|
|
96
|
+
const pendingDelivery = new Set()
|
|
97
|
+
/** @type {Set<WatchMount>} */
|
|
98
|
+
const mounts = new Set()
|
|
99
|
+
|
|
100
|
+
/** @param {string} sessionId @param {string} path @returns {string} */
|
|
101
|
+
function keyOf(sessionId, path) {
|
|
102
|
+
return `${sessionId}\u0000${path}`
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* The public shape of an entry. Internals (mounts, flight, timer, agent
|
|
107
|
+
* identity) stay inside the coordinator.
|
|
108
|
+
* @param {WatchEntry} entry
|
|
109
|
+
* @returns {WatchProjection}
|
|
110
|
+
*/
|
|
111
|
+
function project(entry) {
|
|
112
|
+
return { sessionId: entry.sessionId, path: entry.path, state: entry.state, settledByTool: entry.settledByTool }
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* The mount that may deliver one notice: the entry's own references first, so
|
|
117
|
+
* the mount that registered the wait speaks for it, then any live mount of the
|
|
118
|
+
* same client — a notice survives the unmount that created it (design D4).
|
|
119
|
+
* @param {WatchEntry} entry
|
|
120
|
+
* @returns {WatchMount | undefined}
|
|
121
|
+
*/
|
|
122
|
+
function liveMount(entry) {
|
|
123
|
+
for (const mount of entry.mounts) if (!mount.disposed) return mount
|
|
124
|
+
for (const mount of mounts) if (!mount.disposed) return mount
|
|
125
|
+
return undefined
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** @param {WatchEntry} entry */
|
|
129
|
+
function cancelTimer(entry) {
|
|
130
|
+
if (entry.timer === null) return
|
|
131
|
+
clearTimeout(entry.timer)
|
|
132
|
+
entry.timer = null
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Consume one entry and schedule its notice: it joins `pendingDelivery` first,
|
|
137
|
+
* so a disposal between now and the timer still cancels it.
|
|
138
|
+
*
|
|
139
|
+
* A notice consumed while an authoritative read is still outstanding waits for
|
|
140
|
+
* that read to settle. The read's awaiting callers resume as microtasks and can
|
|
141
|
+
* then report the same episode as a tool result (`released` / `reset`), while
|
|
142
|
+
* this notice needs a macrotask to be delivered; arming after the flight is
|
|
143
|
+
* therefore enough for the tool's own answer to win, and a zero-delay timer
|
|
144
|
+
* can never duplicate it.
|
|
145
|
+
* @param {WatchEntry} entry
|
|
146
|
+
*/
|
|
147
|
+
function scheduleNotice(entry) {
|
|
148
|
+
pendingDelivery.add(entry)
|
|
149
|
+
const flight = entry.statusFlight
|
|
150
|
+
if (flight === null) {
|
|
151
|
+
armNotice(entry)
|
|
152
|
+
return
|
|
153
|
+
}
|
|
154
|
+
// Both outcomes arm: a rejected read is caught by the tool as well, and its
|
|
155
|
+
// terminal rules run before this reaction's timer can fire.
|
|
156
|
+
void flight.then(
|
|
157
|
+
() => armNotice(entry),
|
|
158
|
+
() => armNotice(entry),
|
|
159
|
+
)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Start the one deferred timer for a consumed entry. The pending set is the
|
|
164
|
+
* source of truth: a disposal, unmount or transfer may have ended the entry
|
|
165
|
+
* while the read was still outstanding, and that must win over the notice.
|
|
166
|
+
*
|
|
167
|
+
* The timer re-checks everything that could have ended the subscription in the
|
|
168
|
+
* meantime — the terminal state, the mount references, and the live agent
|
|
169
|
+
* identity — so a late notice can never revive one or land on a replacement
|
|
170
|
+
* agent (design D4/D5).
|
|
171
|
+
* @param {WatchEntry} entry
|
|
172
|
+
*/
|
|
173
|
+
function armNotice(entry) {
|
|
174
|
+
if (!pendingDelivery.has(entry)) return
|
|
175
|
+
if (entry.state !== 'hit' && entry.state !== 'reset') {
|
|
176
|
+
pendingDelivery.delete(entry)
|
|
177
|
+
return
|
|
178
|
+
}
|
|
179
|
+
if (entry.timer !== null) return
|
|
180
|
+
const timer = setTimeout(() => {
|
|
181
|
+
entry.timer = null
|
|
182
|
+
pendingDelivery.delete(entry)
|
|
183
|
+
if (entry.state !== 'hit' && entry.state !== 'reset') return
|
|
184
|
+
if (entry.settledByTool === true) return
|
|
185
|
+
const mount = liveMount(entry)
|
|
186
|
+
if (mount === undefined) return
|
|
187
|
+
const agent = mount.agentFor(entry.sessionId)
|
|
188
|
+
if (agent === undefined || agent === null) return
|
|
189
|
+
if (entry.agent !== undefined && agent !== entry.agent) return
|
|
190
|
+
try {
|
|
191
|
+
mount.notify(entry, agent)
|
|
192
|
+
} catch {
|
|
193
|
+
// A notice is best effort: delivery must never break the transport.
|
|
194
|
+
}
|
|
195
|
+
}, 0)
|
|
196
|
+
entry.timer = timer
|
|
197
|
+
unrefTimer(timer)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* End a PENDING entry: its notice is over and its timer must not fire.
|
|
202
|
+
* @param {WatchEntry} entry
|
|
203
|
+
* @param {WatchState} state
|
|
204
|
+
*/
|
|
205
|
+
function endPending(entry, state) {
|
|
206
|
+
if (pendingDelivery.delete(entry)) cancelTimer(entry)
|
|
207
|
+
entry.state = state
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Attach one mount reference. The first reference starts the subscription
|
|
212
|
+
* lifetime of this coordinator; a later mount of the same client reuses the
|
|
213
|
+
* same entries, flights and notices.
|
|
214
|
+
* @param {WatchMount} mount
|
|
215
|
+
*/
|
|
216
|
+
function join(mount) {
|
|
217
|
+
mounts.add(mount)
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Drop one mount reference (unmount). A single mount leaving removes only its
|
|
222
|
+
* own references: entries another mount still wants stay active, and a pending
|
|
223
|
+
* notice transfers to a surviving reference. When the LAST mount leaves, the
|
|
224
|
+
* whole coordinator — entries, pending notices, timers — is cleared so a later
|
|
225
|
+
* mount starts with no inherited wait.
|
|
226
|
+
* @param {WatchMount} mount
|
|
227
|
+
*/
|
|
228
|
+
function leave(mount) {
|
|
229
|
+
if (!mounts.delete(mount)) return
|
|
230
|
+
mount.disposed = true
|
|
231
|
+
for (const [key, entry] of [...active]) {
|
|
232
|
+
if (!entry.mounts.delete(mount)) continue
|
|
233
|
+
if (entry.mounts.size > 0) continue
|
|
234
|
+
active.delete(key)
|
|
235
|
+
entry.state = 'cancelled'
|
|
236
|
+
}
|
|
237
|
+
for (const entry of [...pendingDelivery]) entry.mounts.delete(mount)
|
|
238
|
+
if (mounts.size === 0) clear()
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Find or create the single entry for one session and path. Repeating a
|
|
243
|
+
* subscription is idempotent: the same entry comes back, so one release can
|
|
244
|
+
* produce at most one notice however many times or from however many mounts
|
|
245
|
+
* the subscription was requested.
|
|
246
|
+
* @param {string} sessionId
|
|
247
|
+
* @param {string} path
|
|
248
|
+
* @param {WatchMount} [mount]
|
|
249
|
+
* @param {any} [agent]
|
|
250
|
+
* @returns {WatchEntry}
|
|
251
|
+
*/
|
|
252
|
+
function register(sessionId, path, mount, agent) {
|
|
253
|
+
const key = keyOf(sessionId, path)
|
|
254
|
+
const known = active.get(key)
|
|
255
|
+
if (known !== undefined) {
|
|
256
|
+
if (mount !== undefined) known.mounts.add(mount)
|
|
257
|
+
if (agent !== undefined && agent !== null) known.agent = agent
|
|
258
|
+
return known
|
|
259
|
+
}
|
|
260
|
+
/** @type {WatchEntry} */
|
|
261
|
+
const entry = {
|
|
262
|
+
sessionId,
|
|
263
|
+
path,
|
|
264
|
+
state: 'watching',
|
|
265
|
+
settledByTool: false,
|
|
266
|
+
agent,
|
|
267
|
+
statusFlight: null,
|
|
268
|
+
mounts: new Set(),
|
|
269
|
+
timer: null,
|
|
270
|
+
}
|
|
271
|
+
if (mount !== undefined) entry.mounts.add(mount)
|
|
272
|
+
active.set(key, entry)
|
|
273
|
+
return entry
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* @param {string} sessionId
|
|
278
|
+
* @param {string} path
|
|
279
|
+
* @returns {WatchEntry | undefined}
|
|
280
|
+
*/
|
|
281
|
+
function peek(sessionId, path) {
|
|
282
|
+
return active.get(keyOf(sessionId, path))
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* One authoritative read per entry, shared by every overlapping caller
|
|
287
|
+
* (design D2). Two calls for the same `(session, path)` must not run two
|
|
288
|
+
* reads and then disagree about whether the entry is still active: they await
|
|
289
|
+
* the same answer and apply the same terminal rules to the same object.
|
|
290
|
+
* @param {WatchEntry} entry
|
|
291
|
+
* @param {() => any} run the arbitration read
|
|
292
|
+
* @returns {Promise<any>}
|
|
293
|
+
*/
|
|
294
|
+
function shareStatus(entry, run) {
|
|
295
|
+
if (entry.statusFlight !== null) return entry.statusFlight
|
|
296
|
+
/** @type {Promise<any>} */
|
|
297
|
+
const flight = Promise.resolve()
|
|
298
|
+
.then(run)
|
|
299
|
+
.finally(() => {
|
|
300
|
+
if (entry.statusFlight === flight) entry.statusFlight = null
|
|
301
|
+
})
|
|
302
|
+
entry.statusFlight = flight
|
|
303
|
+
return flight
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* End one subscription because the caller no longer needs it. `expected`
|
|
308
|
+
* makes the removal conditional on the SAME entry object still being active:
|
|
309
|
+
* a late caller can then never cancel a newer episode, and can never report
|
|
310
|
+
* `watching` for an entry it no longer owns.
|
|
311
|
+
* @param {string} sessionId
|
|
312
|
+
* @param {string} path
|
|
313
|
+
* @param {WatchEntry} [expected]
|
|
314
|
+
* @param {WatchState} [state]
|
|
315
|
+
* @returns {WatchEntry | undefined}
|
|
316
|
+
*/
|
|
317
|
+
function cancel(sessionId, path, expected, state = 'cancelled') {
|
|
318
|
+
const key = keyOf(sessionId, path)
|
|
319
|
+
const entry = active.get(key)
|
|
320
|
+
if (entry === undefined) return undefined
|
|
321
|
+
if (expected !== undefined && entry !== expected) return undefined
|
|
322
|
+
active.delete(key)
|
|
323
|
+
if (pendingDelivery.delete(entry)) cancelTimer(entry)
|
|
324
|
+
entry.state = state
|
|
325
|
+
return entry
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* This session acquired the path by another route (a transfer settled as
|
|
330
|
+
* `transferred`). The wait is over because its goal was reached, and any
|
|
331
|
+
* notice already consumed for that path is moot: waking the session would
|
|
332
|
+
* only make it compete with itself (design D4).
|
|
333
|
+
* @param {string} sessionId
|
|
334
|
+
* @param {string} path
|
|
335
|
+
* @returns {WatchEntry | undefined}
|
|
336
|
+
*/
|
|
337
|
+
function settleSelf(sessionId, path) {
|
|
338
|
+
/** @type {WatchEntry | undefined} */
|
|
339
|
+
let ended
|
|
340
|
+
for (const entry of [...pendingDelivery]) {
|
|
341
|
+
if (entry.sessionId !== sessionId || entry.path !== path) continue
|
|
342
|
+
endPending(entry, 'self')
|
|
343
|
+
ended = ended ?? entry
|
|
344
|
+
}
|
|
345
|
+
const key = keyOf(sessionId, path)
|
|
346
|
+
const activeEntry = active.get(key)
|
|
347
|
+
if (activeEntry !== undefined) {
|
|
348
|
+
active.delete(key)
|
|
349
|
+
activeEntry.state = 'self'
|
|
350
|
+
ended = ended ?? activeEntry
|
|
351
|
+
}
|
|
352
|
+
return ended
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Consume every subscription for one path (the release fact carries no
|
|
357
|
+
* session, and one path may be watched by several sessions of this client).
|
|
358
|
+
* Each returned entry is already in `hit` and out of the active map, so a
|
|
359
|
+
* second observation of the same release finds nothing.
|
|
360
|
+
* @param {string} path
|
|
361
|
+
* @returns {WatchEntry[]}
|
|
362
|
+
*/
|
|
363
|
+
function consumePath(path) {
|
|
364
|
+
/** @type {WatchEntry[]} */
|
|
365
|
+
const consumed = []
|
|
366
|
+
for (const [key, entry] of [...active]) {
|
|
367
|
+
if (entry.path !== path) continue
|
|
368
|
+
active.delete(key)
|
|
369
|
+
entry.state = 'hit'
|
|
370
|
+
scheduleNotice(entry)
|
|
371
|
+
consumed.push(entry)
|
|
372
|
+
}
|
|
373
|
+
return consumed
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* The connection that could have delivered a release is gone. Every active
|
|
378
|
+
* subscription becomes `reset` — a terminal state that says nothing about
|
|
379
|
+
* ownership — and is scheduled for the re-check notice.
|
|
380
|
+
* @returns {WatchEntry[]}
|
|
381
|
+
*/
|
|
382
|
+
function resetAll() {
|
|
383
|
+
const entries = [...active.values()]
|
|
384
|
+
active.clear()
|
|
385
|
+
for (const entry of entries) {
|
|
386
|
+
entry.state = 'reset'
|
|
387
|
+
scheduleNotice(entry)
|
|
388
|
+
}
|
|
389
|
+
return entries
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* End every subscription of one session — active waits AND consumed entries
|
|
394
|
+
* whose notice has not been delivered yet (agent disposal, design D4).
|
|
395
|
+
* @param {string} sessionId
|
|
396
|
+
* @returns {WatchEntry[]}
|
|
397
|
+
*/
|
|
398
|
+
function cancelSession(sessionId) {
|
|
399
|
+
/** @type {WatchEntry[]} */
|
|
400
|
+
const cancelled = []
|
|
401
|
+
for (const [key, entry] of [...active]) {
|
|
402
|
+
if (entry.sessionId !== sessionId) continue
|
|
403
|
+
active.delete(key)
|
|
404
|
+
entry.state = 'cancelled'
|
|
405
|
+
cancelled.push(entry)
|
|
406
|
+
}
|
|
407
|
+
for (const entry of [...pendingDelivery]) {
|
|
408
|
+
if (entry.sessionId !== sessionId) continue
|
|
409
|
+
endPending(entry, 'cancelled')
|
|
410
|
+
cancelled.push(entry)
|
|
411
|
+
}
|
|
412
|
+
return cancelled
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* End everything: no entry can be revived afterwards, and no scheduled notice
|
|
417
|
+
* survives. Called when the last mount of the client leaves.
|
|
418
|
+
* @returns {WatchEntry[]}
|
|
419
|
+
*/
|
|
420
|
+
function clear() {
|
|
421
|
+
/** @type {WatchEntry[]} */
|
|
422
|
+
const entries = [...active.values(), ...pendingDelivery]
|
|
423
|
+
active.clear()
|
|
424
|
+
for (const entry of [...pendingDelivery]) cancelTimer(entry)
|
|
425
|
+
pendingDelivery.clear()
|
|
426
|
+
for (const entry of entries) entry.state = 'cancelled'
|
|
427
|
+
return entries
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/** @returns {number} how many subscriptions are still active */
|
|
431
|
+
function size() {
|
|
432
|
+
return active.size
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/** @returns {number} how many consumed notices are still undelivered */
|
|
436
|
+
function pendingSize() {
|
|
437
|
+
return pendingDelivery.size
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/** @returns {number} how many mount references are attached */
|
|
441
|
+
function mountCount() {
|
|
442
|
+
return mounts.size
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/** @returns {WatchProjection[]} the active entries, as copies */
|
|
446
|
+
function entries() {
|
|
447
|
+
return [...active.values()].map(project)
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/** @returns {WatchProjection[]} the pending notices, as copies */
|
|
451
|
+
function pendingEntries() {
|
|
452
|
+
return [...pendingDelivery].map(project)
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
return Object.freeze({
|
|
456
|
+
join,
|
|
457
|
+
leave,
|
|
458
|
+
register,
|
|
459
|
+
peek,
|
|
460
|
+
shareStatus,
|
|
461
|
+
cancel,
|
|
462
|
+
settleSelf,
|
|
463
|
+
consumePath,
|
|
464
|
+
resetAll,
|
|
465
|
+
cancelSession,
|
|
466
|
+
clear,
|
|
467
|
+
size,
|
|
468
|
+
pendingSize,
|
|
469
|
+
mountCount,
|
|
470
|
+
entries,
|
|
471
|
+
pendingEntries,
|
|
472
|
+
})
|
|
473
|
+
}
|
package/src/intent-gate/index.js
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
compileIntentTable,
|
|
12
12
|
DEFAULT_INTENTS,
|
|
13
13
|
matchIntents,
|
|
14
|
+
intentSuppressed,
|
|
14
15
|
renderReminder,
|
|
15
16
|
renderSkillPointer,
|
|
16
17
|
stripQuotedRegions,
|
|
@@ -36,7 +37,7 @@ function latestUserText(messages) {
|
|
|
36
37
|
return undefined
|
|
37
38
|
}
|
|
38
39
|
|
|
39
|
-
function apply(ctx, config = {}) {
|
|
40
|
+
function apply(ctx, config = {}, dependencies = {}) {
|
|
40
41
|
const audit = createAudit(ctx)
|
|
41
42
|
const rowConfig = config
|
|
42
43
|
const nestKeys = { jev: { endpoint: 'jevEndpoint', model: 'jevModel', apiKeyEnv: 'jevApiKeyEnv' } }
|
|
@@ -45,6 +46,7 @@ function apply(ctx, config = {}) {
|
|
|
45
46
|
const table = compileIntentTable(config.intents ?? DEFAULT_INTENTS)
|
|
46
47
|
const disabled = new Set(Array.isArray(config.disabled) ? config.disabled : [])
|
|
47
48
|
const intents = table.filter((intent) => !disabled.has(intent.id))
|
|
49
|
+
const selectionFor = dependencies.selectionFor ?? skillSelectionFor
|
|
48
50
|
|
|
49
51
|
// Semantic classifier: null in regex mode (regex-only behavior, zero cost).
|
|
50
52
|
const classifier = createClassifier({
|
|
@@ -78,15 +80,18 @@ function apply(ctx, config = {}) {
|
|
|
78
80
|
let hits = matchIntents(stripped, intents)
|
|
79
81
|
|
|
80
82
|
// Semantic front-end: only when regex missed entirely (short-circuit).
|
|
81
|
-
|
|
82
|
-
|
|
83
|
+
// Negated/discussion-only setup wording is removed from its catalog too,
|
|
84
|
+
// so a semantic hit cannot undo an explicit suppression.
|
|
85
|
+
const semanticIntents = intents.filter(intent => !intentSuppressed(stripped, intent))
|
|
86
|
+
if (hits.length === 0 && classifier && semanticIntents.length > 0) {
|
|
87
|
+
const semanticHit = await classifier(stripped, semanticIntents, {
|
|
83
88
|
agent,
|
|
84
89
|
onAudit: (event) => {
|
|
85
90
|
audit(agent.session, AUDIT_TYPES.intentClassify, event)
|
|
86
91
|
},
|
|
87
92
|
})
|
|
88
93
|
if (semanticHit) {
|
|
89
|
-
const intent =
|
|
94
|
+
const intent = semanticIntents.find((entry) => entry.id === semanticHit)
|
|
90
95
|
if (intent) hits = [intent]
|
|
91
96
|
}
|
|
92
97
|
}
|
|
@@ -105,11 +110,14 @@ function apply(ctx, config = {}) {
|
|
|
105
110
|
// stays unarmed (the ledger below never sees it), so a later hit once
|
|
106
111
|
// the skill is available still injects the full initial pointer; nothing
|
|
107
112
|
// is injected in its place and nothing already injected is withdrawn.
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
+
let eligibility = null
|
|
114
|
+
try {
|
|
115
|
+
const selection = selectionFor(ctx)
|
|
116
|
+
const skillView = selection?.provider ? createSkillConsumerView({ provider: selection.provider }) : null
|
|
117
|
+
eligibility = skillView
|
|
118
|
+
? skill => skillView.conclusion({ cwd: agent.session?.header?.cwd, scope: { session: { id: sessionId } } }, skill, 'model')
|
|
119
|
+
: null
|
|
120
|
+
} catch { eligibility = null }
|
|
113
121
|
|
|
114
122
|
const injections = []
|
|
115
123
|
for (const intent of hits) {
|
|
@@ -123,9 +131,18 @@ function apply(ctx, config = {}) {
|
|
|
123
131
|
record(ctx, agent, intent, true)
|
|
124
132
|
continue
|
|
125
133
|
}
|
|
126
|
-
if (intent.injection.kind === 'skill-pointer'
|
|
127
|
-
|
|
128
|
-
|
|
134
|
+
if (intent.injection.kind === 'skill-pointer') {
|
|
135
|
+
if (!eligibility) {
|
|
136
|
+
record(ctx, agent, intent, false, { suppressed: 'provider-unavailable' })
|
|
137
|
+
continue
|
|
138
|
+
}
|
|
139
|
+
let verdict
|
|
140
|
+
try { verdict = await eligibility(intent.injection.skill) }
|
|
141
|
+
catch {
|
|
142
|
+
record(ctx, agent, intent, false, { suppressed: 'view-unavailable' })
|
|
143
|
+
continue
|
|
144
|
+
}
|
|
145
|
+
if (verdict?.invocable !== true) {
|
|
129
146
|
record(ctx, agent, intent, false, { suppressed: verdict?.reason ?? 'not-selected' })
|
|
130
147
|
continue
|
|
131
148
|
}
|
|
@@ -8,8 +8,11 @@ export function stripQuotedRegions(text) {
|
|
|
8
8
|
text
|
|
9
9
|
// fenced code blocks (``` or ~~~, optional info string)
|
|
10
10
|
.replace(/(^|\n)[ \t]*(```|~~~)[\s\S]*?\2[ \t]*(?=\n|$)/g, '$1')
|
|
11
|
-
//
|
|
11
|
+
// Any remaining opener starts an unclosed example: blank through EOF.
|
|
12
|
+
.replace(/(^|\n)[ \t]*(```|~~~)[\s\S]*$/g, '$1')
|
|
13
|
+
// inline code spans, including an unclosed trailing span
|
|
12
14
|
.replace(/`[^`\n]*`/g, ' ')
|
|
15
|
+
.replace(/`[^`\n]*$/gm, ' ')
|
|
13
16
|
// blockquote lines
|
|
14
17
|
.replace(/^[ \t]*>.*$/gm, '')
|
|
15
18
|
)
|
|
@@ -21,6 +24,7 @@ export function stripQuotedRegions(text) {
|
|
|
21
24
|
* @property {RegExp[]} patterns - compiled matchers
|
|
22
25
|
* @property {boolean} oncePerSession
|
|
23
26
|
* @property {string} [summary] - one-line catalog description for the semantic classifier
|
|
27
|
+
* @property {RegExp[]} [suppressed] - negation/discussion matchers that also suppress semantic hits
|
|
24
28
|
* @property {{ kind: 'skill-pointer', skill: string }
|
|
25
29
|
* | { kind: 'message', text: string }
|
|
26
30
|
* | { kind: 'effort', reasoningEffort: string }} injection
|
|
@@ -54,6 +58,12 @@ export function compileIntentTable(raw) {
|
|
|
54
58
|
throw new Error(`intent-gate: intent "${entry.id}" has an invalid matcher ${JSON.stringify(matcher)}: ${String(error)}`)
|
|
55
59
|
}
|
|
56
60
|
})
|
|
61
|
+
if (entry.suppressedMatchers !== undefined && !Array.isArray(entry.suppressedMatchers)) throw new Error(`intent-gate: intent "${entry.id}" suppressedMatchers must be an array`)
|
|
62
|
+
const suppressed = (entry.suppressedMatchers ?? []).map((matcher) => {
|
|
63
|
+
if (typeof matcher !== 'string' || matcher.length === 0) throw new Error(`intent-gate: intent "${entry.id}" has a non-string suppressed matcher`)
|
|
64
|
+
try { return new RegExp(matcher, 'i') }
|
|
65
|
+
catch (error) { throw new Error(`intent-gate: intent "${entry.id}" has an invalid suppressed matcher ${JSON.stringify(matcher)}: ${String(error)}`) }
|
|
66
|
+
})
|
|
57
67
|
const injection = entry.injection
|
|
58
68
|
if (!injection || typeof injection !== 'object') {
|
|
59
69
|
throw new Error(`intent-gate: intent "${entry.id}" needs an injection object`)
|
|
@@ -78,6 +88,7 @@ export function compileIntentTable(raw) {
|
|
|
78
88
|
patterns,
|
|
79
89
|
oncePerSession: entry.oncePerSession !== false,
|
|
80
90
|
...(typeof entry.summary === 'string' && entry.summary.length > 0 ? { summary: entry.summary } : {}),
|
|
91
|
+
...(suppressed.length > 0 ? { suppressed } : {}),
|
|
81
92
|
injection,
|
|
82
93
|
}
|
|
83
94
|
})
|
|
@@ -90,7 +101,12 @@ export function compileIntentTable(raw) {
|
|
|
90
101
|
* @returns {IntentEntry[]} matched intents in table order
|
|
91
102
|
*/
|
|
92
103
|
export function matchIntents(stripped, intents) {
|
|
93
|
-
return intents.filter((intent) => intent.patterns.some((pattern) => pattern.test(stripped)))
|
|
104
|
+
return intents.filter((intent) => !intentSuppressed(stripped, intent) && intent.patterns.some((pattern) => pattern.test(stripped)))
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Explicit negation/discussion suppression, also applied before semantic classification. */
|
|
108
|
+
export function intentSuppressed(stripped, intent) {
|
|
109
|
+
return Boolean(intent.suppressed?.some(pattern => pattern.test(stripped)))
|
|
94
110
|
}
|
|
95
111
|
|
|
96
112
|
/** Verbatim directive injected on every writing intent hit. */
|
|
@@ -105,6 +121,24 @@ Write the deliverable as the finished product, not as a record of producing it.
|
|
|
105
121
|
|
|
106
122
|
/** The default intent table (English templates; runtime content follows the session language). */
|
|
107
123
|
export const DEFAULT_INTENTS = [
|
|
124
|
+
{
|
|
125
|
+
id: 'mcp-server-setup',
|
|
126
|
+
matchers: [
|
|
127
|
+
'\\b(?:install|add|configure|set[ \\t]+up)\\b[^\\n.!?]{0,80}\\bmcp(?:[ \\t]+server)?\\b',
|
|
128
|
+
'\\bmcp(?:[ \\t]+server)?\\b[^\\n.!?]{0,60}\\b(?:install|add|configure|set[ \\t]+up)\\b',
|
|
129
|
+
'(?:安装|添加|配置)[^\\n。!?]{0,40}mcp(?:服务器)?',
|
|
130
|
+
'mcp(?:服务器)?[^\\n。!?]{0,40}(?:安装|添加|配置)',
|
|
131
|
+
],
|
|
132
|
+
suppressedMatchers: [
|
|
133
|
+
'\\b(?:do[ \\t]+not|don[\'’]t|never|should[ \\t]+not|shouldn[\'’]t)\\b[^\\n.!?]{0,40}\\b(?:install|add|configure|set[ \\t]+up)\\b[^\\n.!?]{0,80}\\bmcp\\b',
|
|
134
|
+
'(?:不要|不用|别|请勿|禁止)[^\\n。!?]{0,30}(?:安装|添加|配置)[^\\n。!?]{0,40}mcp',
|
|
135
|
+
'(?:^|\\n)[ \\t]*(?:what|why|explain|discuss|describe|tell[ \\t]+me[ \\t]+about)\\b[^\\n]{0,100}\\bmcp\\b',
|
|
136
|
+
'\\b(?:explain|discuss|describe)\\b[^\\n.!?]{0,80}\\b(?:install|add|configure|set[ \\t]+up)\\b[^\\n.!?]{0,80}\\bmcp\\b',
|
|
137
|
+
'(?:^|\\n)[ \\t]*(?:什么是|解释|讨论|介绍|说明)[^\\n。!?]{0,80}mcp',
|
|
138
|
+
],
|
|
139
|
+
summary: 'install, add, or configure a new MCP server; not for calling or troubleshooting an existing MCP tool or general MCP discussion',
|
|
140
|
+
injection: { kind: 'skill-pointer', skill: 'mcp-server-setup' },
|
|
141
|
+
},
|
|
108
142
|
{
|
|
109
143
|
id: 'deep-work',
|
|
110
144
|
matchers: ['\\bdeep[- ]?work\\b', '深度工作', '深度作业'],
|
|
@@ -18,8 +18,9 @@ export const PROTOCOL = 1
|
|
|
18
18
|
/** Request operations accepted after the handshake (`hello` is the handshake). */
|
|
19
19
|
export const OPS = Object.freeze(['hello', 'acquire', 'release', 'hold', 'status', 'request', 'reply'])
|
|
20
20
|
|
|
21
|
-
/** Unsolicited events pushed by the broker to one
|
|
22
|
-
|
|
21
|
+
/** Unsolicited events pushed by the broker. `request` and `settled` go to one
|
|
22
|
+
* connection; `released` is a fact offered to every connected client. */
|
|
23
|
+
export const EVENTS = Object.freeze(['request', 'settled', 'released'])
|
|
23
24
|
|
|
24
25
|
/** Closed error vocabulary. No code may describe an operator action: every
|
|
25
26
|
* refusal either resolves by negotiation or by validity elapsing. */
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// Wire shape: newline-delimited JSON, one frame per line, in both directions.
|
|
7
7
|
// Requests carry `op`; replies carry `ok` plus either the result fields or
|
|
8
8
|
// `error: {code, ...details}` and always echo the client's `reqId` when it sent
|
|
9
|
-
// one. Unsolicited events carry `event` ('request' | 'settled').
|
|
9
|
+
// one. Unsolicited events carry `event` ('request' | 'settled' | 'released').
|
|
10
10
|
//
|
|
11
11
|
// Process lifetime (design D1/D8): the daemon is a cache, so it is allowed to
|
|
12
12
|
// die. It exits after `idleMs` with no connected client, and stays alive as
|
|
@@ -18,7 +18,7 @@ import { unlinkSync } from 'node:fs'
|
|
|
18
18
|
import { homedir as osHomedir, tmpdir as osTmpdir } from 'node:os'
|
|
19
19
|
import { join } from 'node:path'
|
|
20
20
|
import process from 'node:process'
|
|
21
|
-
import { PROTOCOL, validateFrame } from './protocol.js'
|
|
21
|
+
import { EVENTS, PROTOCOL, validateFrame } from './protocol.js'
|
|
22
22
|
|
|
23
23
|
/** Idle window before a client-less daemon exits. */
|
|
24
24
|
export const DEFAULT_IDLE_MS = 60_000
|
|
@@ -237,7 +237,10 @@ export async function startServer({
|
|
|
237
237
|
return
|
|
238
238
|
}
|
|
239
239
|
state.greeted = true
|
|
240
|
-
|
|
240
|
+
// The handshake advertises the push events this incarnation supports,
|
|
241
|
+
// inside major 1: a client that needs one (a release subscription) can
|
|
242
|
+
// fail explicitly instead of waiting for an event this broker never sends.
|
|
243
|
+
writeFrame(socket, toWire({ ok: true, protocol, daemonVersion, startedAt, events: [...EVENTS] }, hello.reqId))
|
|
241
244
|
return
|
|
242
245
|
}
|
|
243
246
|
if (!parsed.ok) {
|