weir-harness 2.1.1 → 2.1.3
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-manager.js +467 -2
- package/lib/client.capability-model.js +77 -0
- package/lib/client.js +25 -4
- package/package.json +1 -1
- package/src/capabilities/apply-engine.js +20 -4
- package/src/capabilities/capability-remote.js +97 -10
- package/src/capabilities/lifecycle-feed.js +47 -0
- package/src/capabilities/mcp-drain.js +6 -0
- package/src/capabilities/mcp-facade-plugin.js +14 -4
- package/src/capabilities/mcp-facade.js +1 -1
- package/src/capabilities/mcp-inventory.js +152 -0
- package/src/capabilities/mcp-manager.js +220 -171
- package/src/capabilities/mcp-mount-ownership.js +114 -0
- package/src/capabilities/mcp-mount.js +334 -82
- package/src/capabilities/mcp-operation-coordinator.js +76 -0
- package/src/capabilities/mcp-runtime.js +3 -29
- package/src/capabilities/skill-inventory.js +18 -2
- package/src/capabilities/skill-scan-path-registry.js +238 -0
- package/src/capabilities/skill-selection-plugin.js +14 -0
- package/src/capabilities/store/paths.js +3 -0
- package/src/file-lease/client.js +61 -3
- package/src/file-lease/negotiation.js +89 -0
- package/src/file-lease/plugin.js +25 -23
- package/src/file-lease/tools.js +71 -121
- package/src/file-lease/watch.js +225 -36
|
@@ -72,32 +72,6 @@ export function createMcpRuntime() {
|
|
|
72
72
|
}
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
* (design D6), so a Restart can never begin while an edit-mode Test holds the
|
|
79
|
-
* fence and leave the server mounted but refusing calls. A task's failure never
|
|
80
|
-
* poisons the queue; different identities never wait on each other. Not
|
|
81
|
-
* reentrant: callers already inside serialize() pass their token instead.
|
|
82
|
-
*/
|
|
83
|
-
export function createMcpIdentityLock() {
|
|
84
|
-
const tails = new Map()
|
|
85
|
-
return function serialize(identity, task) {
|
|
86
|
-
const previous = tails.get(identity) ?? Promise.resolve()
|
|
87
|
-
const run = previous.then(() => task())
|
|
88
|
-
const tail = run.then(() => {}, () => {})
|
|
89
|
-
tails.set(identity, tail)
|
|
90
|
-
void tail.then(() => { if (tails.get(identity) === tail) tails.delete(identity) })
|
|
91
|
-
return run
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
const fallbackLocks = new WeakMap()
|
|
96
|
-
/** The manager's own lock, or one stable lock per manager object for fakes/older faces. */
|
|
97
|
-
export function mcpIdentityLockFor(manager) {
|
|
98
|
-
if (typeof manager?.serialize === 'function') return (identity, task) => manager.serialize(identity, task)
|
|
99
|
-
if (!manager || (typeof manager !== 'object' && typeof manager !== 'function')) return createMcpIdentityLock()
|
|
100
|
-
let lock = fallbackLocks.get(manager)
|
|
101
|
-
if (!lock) { lock = createMcpIdentityLock(); fallbackLocks.set(manager, lock) }
|
|
102
|
-
return lock
|
|
103
|
-
}
|
|
75
|
+
// Compatibility exports: the implementations live with the manager-owned
|
|
76
|
+
// coordinator so queue/fence/drain rules have one semantic implementation.
|
|
77
|
+
export { createMcpIdentityLock, mcpIdentityLockFor } from './mcp-operation-coordinator.js'
|
|
@@ -3,6 +3,8 @@ import { dirname, join, resolve } from 'node:path'
|
|
|
3
3
|
import { createHash } from 'node:crypto'
|
|
4
4
|
import { createSkillIdentity } from './skill-identity.js'
|
|
5
5
|
import { parseSkillText } from './frontmatter.js'
|
|
6
|
+
import { canonicalPathKey } from './skill-applicability.js'
|
|
7
|
+
import { requireSkillScanPaths } from './skill-scan-path-registry.js'
|
|
6
8
|
|
|
7
9
|
export const SKILL_ROOT_RANKS = Object.freeze({ 'project-dsh': 100, 'project-agents': 200, custom: 300, 'user-dsh': 400, 'user-agents': 500, bundled: 600 })
|
|
8
10
|
|
|
@@ -19,7 +21,7 @@ export async function findProjectRoot(cwd, fs = nodeFs) {
|
|
|
19
21
|
}
|
|
20
22
|
|
|
21
23
|
/** Homes are supplied by the host adapter, not inferred from the process profile. */
|
|
22
|
-
export async function resolveSkillRoots({ cwd, dshHome, agentsHome, customSkillDirs = [], bundledSkillDir, weirBuiltinDir, includeDefaultRoots = true }, fs = nodeFs) {
|
|
24
|
+
export async function resolveSkillRoots({ cwd, dshHome, agentsHome, customSkillDirs = [], bundledSkillDir, weirBuiltinDir, includeDefaultRoots = true, skillScanPathRegistry }, fs = nodeFs) {
|
|
23
25
|
const roots = []
|
|
24
26
|
const add = (path, source, scope, extra = {}) => roots.push({ path: resolve(path), source, scope, rank: SKILL_ROOT_RANKS[source], ...extra })
|
|
25
27
|
if (includeDefaultRoots && cwd !== undefined) {
|
|
@@ -37,7 +39,21 @@ export async function resolveSkillRoots({ cwd, dshHome, agentsHome, customSkillD
|
|
|
37
39
|
}
|
|
38
40
|
// Host bundles are not automatically Weir-owned or selected.
|
|
39
41
|
if (bundledSkillDir !== undefined) add(bundledSkillDir, 'bundled', 'custom', { trustedHost: true })
|
|
40
|
-
|
|
42
|
+
// Existing composition/default roots keep their source, scope and rank. User
|
|
43
|
+
// registry roots append in registry order and never replace Creative's roots.
|
|
44
|
+
if (skillScanPathRegistry) {
|
|
45
|
+
for (const path of await requireSkillScanPaths(skillScanPathRegistry)) add(path, 'custom', 'custom')
|
|
46
|
+
}
|
|
47
|
+
// Canonical identity, not lexical spelling, controls enumeration. A root whose
|
|
48
|
+
// canonical key cannot be proven stays independent so discovery's established
|
|
49
|
+
// partial-failure path reports its real unreadable/missing outcome.
|
|
50
|
+
const seen = new Set()
|
|
51
|
+
return roots.filter((root, index) => {
|
|
52
|
+
const key = canonicalPathKey(root.path) ?? `unresolved:${index}`
|
|
53
|
+
if (seen.has(key)) return false
|
|
54
|
+
seen.add(key)
|
|
55
|
+
return true
|
|
56
|
+
})
|
|
41
57
|
}
|
|
42
58
|
|
|
43
59
|
const rootKey = root => JSON.stringify([resolve(root.path), root.source, root.scope, root.rank])
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
// Profile-scoped, user-managed Skill discovery roots. The capability store owns
|
|
2
|
+
// locking, revisions and crash-safe writes; this module owns strict payload
|
|
3
|
+
// validation and canonical directory semantics. No mutation executes a command,
|
|
4
|
+
// appends to a session, or treats a failed write as a discovery change.
|
|
5
|
+
import { constants } from 'node:fs'
|
|
6
|
+
import { access, realpath, stat } from 'node:fs/promises'
|
|
7
|
+
import { homedir } from 'node:os'
|
|
8
|
+
import * as nodePath from 'node:path'
|
|
9
|
+
import { canonicalPathKey } from './skill-applicability.js'
|
|
10
|
+
|
|
11
|
+
export const SKILL_SCAN_PATH_UNIT = Object.freeze({ kind: 'skill-scan-paths' })
|
|
12
|
+
|
|
13
|
+
const message = error => error instanceof Error ? error.message : String(error)
|
|
14
|
+
const codeOf = error => error?.code
|
|
15
|
+
const missing = error => ['ENOENT', 'ENOTDIR'].includes(codeOf(error))
|
|
16
|
+
const denied = error => ['EACCES', 'EPERM'].includes(codeOf(error))
|
|
17
|
+
const clone = paths => [...paths]
|
|
18
|
+
|
|
19
|
+
class SkillPathFailure extends Error {
|
|
20
|
+
constructor(code, text, result = undefined) {
|
|
21
|
+
super(text)
|
|
22
|
+
this.name = 'SkillPathFailure'
|
|
23
|
+
this.code = code
|
|
24
|
+
this.result = result
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const validation = (code, text) => ({
|
|
29
|
+
status: 'validation-failed', saved: false, revision: null, paths: null,
|
|
30
|
+
error: { code, message: text },
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
const unavailable = (code, text, revision = null) => ({
|
|
34
|
+
status: 'unavailable', saved: false, revision, paths: null,
|
|
35
|
+
error: { code, message: text },
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Comparison of paths already canonicalized by realpath. Registry payloads never
|
|
40
|
+
* store this folded key: the canonical spelling remains the display/read value.
|
|
41
|
+
*/
|
|
42
|
+
function storedPathKey(path, pathApi, platform) {
|
|
43
|
+
let key = pathApi.normalize(path)
|
|
44
|
+
if (platform === 'win32') key = key.replaceAll('\\', '/').toLowerCase()
|
|
45
|
+
return key.replace(/\/+$/, '')
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Strict payload shape: unknown or malformed records are never repaired. */
|
|
49
|
+
function payloadPaths(payload, pathApi, platform) {
|
|
50
|
+
if (payload === undefined) return []
|
|
51
|
+
if (payload === null || typeof payload !== 'object' || Array.isArray(payload)
|
|
52
|
+
|| Object.keys(payload).length !== 1 || !Array.isArray(payload.paths)
|
|
53
|
+
|| !payload.paths.every(path => typeof path === 'string' && path.length > 0 && pathApi.isAbsolute(path))) {
|
|
54
|
+
throw new SkillPathFailure('invalid-payload', 'Skill scan path registry payload is invalid')
|
|
55
|
+
}
|
|
56
|
+
const seen = new Set()
|
|
57
|
+
for (const path of payload.paths) {
|
|
58
|
+
const key = storedPathKey(path, pathApi, platform)
|
|
59
|
+
if (seen.has(key)) throw new SkillPathFailure('invalid-payload', 'Skill scan path registry contains duplicate paths')
|
|
60
|
+
seen.add(key)
|
|
61
|
+
}
|
|
62
|
+
return clone(payload.paths)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* @param {{
|
|
67
|
+
* store: ReturnType<typeof import('./store/store.js').openCapabilityStore>,
|
|
68
|
+
* fs?: { realpath(path: string): Promise<string>, stat(path: string): Promise<{ isDirectory(): boolean }>, access(path: string, mode?: number): Promise<void> },
|
|
69
|
+
* home?: string,
|
|
70
|
+
* platform?: string,
|
|
71
|
+
* pathApi?: typeof nodePath,
|
|
72
|
+
* }} options
|
|
73
|
+
*/
|
|
74
|
+
export function createSkillScanPathRegistry(options) {
|
|
75
|
+
const store = options.store
|
|
76
|
+
const fs = options.fs ?? { realpath, stat, access }
|
|
77
|
+
const platform = options.platform ?? process.platform
|
|
78
|
+
const pathApi = options.pathApi ?? (platform === 'win32' ? nodePath.win32 : nodePath)
|
|
79
|
+
const home = options.home ?? homedir()
|
|
80
|
+
const unit = SKILL_SCAN_PATH_UNIT
|
|
81
|
+
|
|
82
|
+
const pathKey = path => storedPathKey(path, pathApi, platform)
|
|
83
|
+
|
|
84
|
+
function expanded(input) {
|
|
85
|
+
if (typeof input !== 'string') throw new SkillPathFailure('path-invalid', 'Skill scan path must be a string')
|
|
86
|
+
const trimmed = input.trim()
|
|
87
|
+
if (!trimmed) throw new SkillPathFailure('path-empty', 'Skill scan path cannot be empty')
|
|
88
|
+
let value = trimmed
|
|
89
|
+
if (trimmed === '~') value = home
|
|
90
|
+
else if (trimmed.startsWith('~/') || (platform === 'win32' && trimmed.startsWith('~\\'))) {
|
|
91
|
+
if (typeof home !== 'string' || !pathApi.isAbsolute(home)) throw new SkillPathFailure('home-unavailable', 'Home directory is unavailable')
|
|
92
|
+
value = pathApi.resolve(home, trimmed.slice(2))
|
|
93
|
+
}
|
|
94
|
+
if (!pathApi.isAbsolute(value)) throw new SkillPathFailure('path-not-absolute', 'Skill scan path must be absolute or start with ~')
|
|
95
|
+
return value
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function canonicalForAdd(input) {
|
|
99
|
+
const value = expanded(input)
|
|
100
|
+
let canonical
|
|
101
|
+
try { canonical = await fs.realpath(value) }
|
|
102
|
+
catch (error) {
|
|
103
|
+
if (missing(error)) throw new SkillPathFailure('path-missing', `Skill scan path does not exist: ${value}`)
|
|
104
|
+
if (denied(error)) throw new SkillPathFailure('path-unreadable', `Skill scan path is unreadable: ${value}`)
|
|
105
|
+
throw new SkillPathFailure('path-canonicalization-failed', `Cannot canonicalize Skill scan path: ${message(error)}`)
|
|
106
|
+
}
|
|
107
|
+
let info
|
|
108
|
+
try { info = await fs.stat(canonical) }
|
|
109
|
+
catch (error) {
|
|
110
|
+
throw new SkillPathFailure(denied(error) ? 'path-unreadable' : 'path-missing', `Cannot inspect Skill scan path: ${message(error)}`)
|
|
111
|
+
}
|
|
112
|
+
if (!info.isDirectory()) throw new SkillPathFailure('path-not-directory', `Skill scan path is not a directory: ${canonical}`)
|
|
113
|
+
try { await fs.access(canonical, constants.R_OK) }
|
|
114
|
+
catch (error) { throw new SkillPathFailure('path-unreadable', `Skill scan path is not readable: ${message(error)}`) }
|
|
115
|
+
return { path: canonical, key: pathKey(canonical) }
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function keyForRemove(input) {
|
|
119
|
+
const value = expanded(input)
|
|
120
|
+
try { return pathKey(await fs.realpath(value)) }
|
|
121
|
+
catch (error) {
|
|
122
|
+
if (!missing(error)) {
|
|
123
|
+
throw new SkillPathFailure(denied(error) ? 'path-unreadable' : 'path-canonicalization-failed', `Cannot canonicalize Skill scan path: ${message(error)}`)
|
|
124
|
+
}
|
|
125
|
+
// Removal must remain possible after a directory disappears. The shared
|
|
126
|
+
// platform key canonicalizes the deepest surviving ancestor without
|
|
127
|
+
// requiring the removed directory itself to exist.
|
|
128
|
+
const key = canonicalPathKey(value, platform)
|
|
129
|
+
if (key === null) throw new SkillPathFailure('path-canonicalization-failed', `Cannot canonicalize Skill scan path: ${value}`)
|
|
130
|
+
return key
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function read() {
|
|
135
|
+
let record
|
|
136
|
+
try { record = await store.read(unit) }
|
|
137
|
+
catch (error) { return unavailable('read-failed', `Skill scan path registry is unreadable: ${message(error)}`) }
|
|
138
|
+
if (record.kind === 'absent') return { status: 'ok', revision: 0, paths: [] }
|
|
139
|
+
if (record.kind !== 'ok') return unavailable(record.kind, `Skill scan path registry is ${record.kind}`)
|
|
140
|
+
try {
|
|
141
|
+
return { status: 'ok', revision: record.revision, paths: payloadPaths(record.payload, pathApi, platform) }
|
|
142
|
+
} catch (error) {
|
|
143
|
+
return unavailable(error?.code ?? 'invalid-payload', message(error), record.revision)
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function currentAfterConflict(result) {
|
|
148
|
+
const current = await read()
|
|
149
|
+
if (current.status === 'ok') return { revision: current.revision, paths: current.paths, error: null }
|
|
150
|
+
return {
|
|
151
|
+
revision: result.revision ?? current.revision ?? null,
|
|
152
|
+
paths: null,
|
|
153
|
+
error: { code: 'current-state-unavailable', message: `Revision conflict; current registry state is unavailable: ${current.error?.message ?? current.status}` },
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async function mutate(kind, input, expectedRevision, options = {}) {
|
|
158
|
+
if (!Number.isSafeInteger(expectedRevision) || expectedRevision < 0) {
|
|
159
|
+
return validation('revision-invalid', 'expectedRevision must be a non-negative safe integer')
|
|
160
|
+
}
|
|
161
|
+
let candidate
|
|
162
|
+
try { candidate = kind === 'add' ? await canonicalForAdd(input) : { key: await keyForRemove(input) } }
|
|
163
|
+
catch (error) {
|
|
164
|
+
if (error instanceof SkillPathFailure) return validation(error.code, error.message)
|
|
165
|
+
return validation('path-validation-failed', message(error))
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
let result
|
|
169
|
+
try {
|
|
170
|
+
result = await store.commit(unit, expectedRevision, async payload => {
|
|
171
|
+
let paths
|
|
172
|
+
try { paths = payloadPaths(payload, pathApi, platform) }
|
|
173
|
+
catch (error) {
|
|
174
|
+
throw new SkillPathFailure(error?.code ?? 'invalid-payload', message(error), unavailable(error?.code ?? 'invalid-payload', message(error)))
|
|
175
|
+
}
|
|
176
|
+
const index = paths.findIndex(path => pathKey(path) === candidate.key)
|
|
177
|
+
if (kind === 'add' && index >= 0) {
|
|
178
|
+
throw new SkillPathFailure('duplicate', `Skill scan path is already registered: ${paths[index]}`, {
|
|
179
|
+
status: 'duplicate', saved: false, revision: expectedRevision, paths: clone(paths),
|
|
180
|
+
error: { code: 'duplicate', message: `Skill scan path is already registered: ${paths[index]}` },
|
|
181
|
+
})
|
|
182
|
+
}
|
|
183
|
+
if (kind === 'remove' && index < 0) {
|
|
184
|
+
throw new SkillPathFailure('not-found', `Skill scan path is not registered: ${input}`, {
|
|
185
|
+
status: 'not-found', saved: false, revision: expectedRevision, paths: clone(paths),
|
|
186
|
+
error: { code: 'not-found', message: `Skill scan path is not registered: ${input}` },
|
|
187
|
+
})
|
|
188
|
+
}
|
|
189
|
+
// Ownership is rechecked under the unit lock, immediately before the
|
|
190
|
+
// payload is handed to the store's atomic write phase.
|
|
191
|
+
await options.assertOwner?.()
|
|
192
|
+
return { paths: kind === 'add' ? [...paths, candidate.path] : paths.filter((_, i) => i !== index) }
|
|
193
|
+
})
|
|
194
|
+
} catch (error) {
|
|
195
|
+
if (error?.code === 'owner-changed') throw error
|
|
196
|
+
if (error instanceof SkillPathFailure && error.result) return error.result
|
|
197
|
+
return {
|
|
198
|
+
status: 'storage-failed', saved: false, revision: null, paths: null,
|
|
199
|
+
error: { code: 'storage-failed', message: message(error) },
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (result.status === 'committed') {
|
|
204
|
+
return {
|
|
205
|
+
status: kind === 'add' ? 'saved' : 'removed', saved: true,
|
|
206
|
+
revision: result.revision, paths: clone(result.payload.paths), error: null,
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
if (result.status === 'revision-conflict') {
|
|
210
|
+
const current = await currentAfterConflict(result)
|
|
211
|
+
return {
|
|
212
|
+
status: 'revision-conflict', saved: false, revision: current.revision, paths: current.paths,
|
|
213
|
+
error: current.error ?? { code: 'revision-conflict', message: `Skill scan path registry revision ${expectedRevision} is stale` },
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
return {
|
|
217
|
+
status: result.status === 'write-failed' ? 'indeterminate' : result.status,
|
|
218
|
+
saved: false, revision: result.revision ?? null, paths: null,
|
|
219
|
+
error: { code: result.kind ?? result.status, message: result.reason ?? message(result.error ?? `Skill scan path registry write failed: ${result.status}`) },
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return {
|
|
224
|
+
unit,
|
|
225
|
+
read,
|
|
226
|
+
add: (path, expectedRevision, options) => mutate('add', path, expectedRevision, options),
|
|
227
|
+
remove: (path, expectedRevision, options) => mutate('remove', path, expectedRevision, options),
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Registry read failures must become visible inventory/roots failures. */
|
|
232
|
+
export async function requireSkillScanPaths(registry) {
|
|
233
|
+
const result = await registry.read()
|
|
234
|
+
if (result.status !== 'ok') {
|
|
235
|
+
throw new Error(`Skill scan path registry unavailable: ${result.error?.message ?? result.status}`)
|
|
236
|
+
}
|
|
237
|
+
return result.paths
|
|
238
|
+
}
|
|
@@ -7,12 +7,14 @@ import { fileURLToPath } from 'node:url'
|
|
|
7
7
|
import { createSkillIdentity, skillIdentityKey } from './skill-identity.js'
|
|
8
8
|
import { openCapabilityStore } from './store/store.js'
|
|
9
9
|
import { discoverSkillInventory, resolveSkillRoots } from './skill-inventory.js'
|
|
10
|
+
import { createSkillScanPathRegistry } from './skill-scan-path-registry.js'
|
|
10
11
|
import { createSkillSelectionProvider } from './skill-selection-provider.js'
|
|
11
12
|
import { assertBuiltinSkillMigration } from './skill-builtin-migration.js'
|
|
12
13
|
import { createOfficeAdapter, officeDenials } from './skill-office-adapter.js'
|
|
13
14
|
import { createPresetInvalidation, emitPresetSelected } from './preset-invalidation.js'
|
|
14
15
|
import { createLifecycleSnapshots } from './lifecycle-snapshot.js'
|
|
15
16
|
import { preloadLifecycleSnapshots } from './lifecycle-preload.js'
|
|
17
|
+
import { lifecycleFeedRegistry } from './lifecycle-feed.js'
|
|
16
18
|
import { classifySelectionFailure } from './selection-status.js'
|
|
17
19
|
import { resolveInitialSelection, baselineSkillIdentities, bindDefaultSkillNames } from './initial-selection.js'
|
|
18
20
|
import { partitionUnresolvedSkills } from './skill-applicability.js'
|
|
@@ -66,6 +68,9 @@ export function createSkillSelectionPlugin(dependencies = {}) {
|
|
|
66
68
|
/** Task 6.5 initialization reports per session (manager surface). */
|
|
67
69
|
let initialReports = new Map()
|
|
68
70
|
let lifecycle = null
|
|
71
|
+
const scanPathRegistry = dependencies.skillScanPathRegistry ?? createSkillScanPathRegistry({
|
|
72
|
+
store: openCapabilityStore({ profileContext: ctx.get?.('profileContext') }),
|
|
73
|
+
})
|
|
69
74
|
try {
|
|
70
75
|
ctx.skills.registerProvider(control => {
|
|
71
76
|
const readSelection = dependencies.readSelection ?? (async options => {
|
|
@@ -133,6 +138,7 @@ export function createSkillSelectionPlugin(dependencies = {}) {
|
|
|
133
138
|
customSkillDirs: config.customSkillDirs ?? [],
|
|
134
139
|
bundledSkillDir: config.bundledSkillDir ?? process.env.DSH_BUNDLED_SKILL_DIR,
|
|
135
140
|
weirBuiltinDir: config.weirBuiltinDir ?? fileURLToPath(new URL('../../skills/', import.meta.url)),
|
|
141
|
+
skillScanPathRegistry: scanPathRegistry,
|
|
136
142
|
})
|
|
137
143
|
const snapshot = await discoverSkillInventory({ roots, machineId, previous })
|
|
138
144
|
// First-run migration check (D-H 6): a non-equivalent builtin
|
|
@@ -322,6 +328,7 @@ export function createSkillSelectionPlugin(dependencies = {}) {
|
|
|
322
328
|
customSkillDirs: config.customSkillDirs ?? [],
|
|
323
329
|
bundledSkillDir: config.bundledSkillDir ?? process.env.DSH_BUNDLED_SKILL_DIR,
|
|
324
330
|
weirBuiltinDir: config.weirBuiltinDir ?? fileURLToPath(new URL('../../skills/', import.meta.url)),
|
|
331
|
+
skillScanPathRegistry: scanPathRegistry,
|
|
325
332
|
})
|
|
326
333
|
}
|
|
327
334
|
// The shared cold-safe audit channel (cordis emit + .weir/audit.jsonl
|
|
@@ -646,6 +653,13 @@ export function createSkillSelectionPlugin(dependencies = {}) {
|
|
|
646
653
|
}
|
|
647
654
|
mounted.set(ctx, face)
|
|
648
655
|
processFace = face
|
|
656
|
+
// Admission ownership is per mounted revision, not the process-local face.
|
|
657
|
+
// Resolve the manager lazily so either row may mount first; the effect owns
|
|
658
|
+
// exactly this token and its idempotent release cannot remove a replacement.
|
|
659
|
+
ctx.effect?.(() => lifecycleFeedRegistry.register({
|
|
660
|
+
lifecycle,
|
|
661
|
+
manager: () => ctx.get?.('weirMcpManager'),
|
|
662
|
+
}), 'weir-capability-lifecycle-feed')
|
|
649
663
|
// The Host remote can see only this explicitly labeled preset's faces.
|
|
650
664
|
// Its sessionQuery observation, not any creation-time cwd cache, owns routing.
|
|
651
665
|
try {
|
|
@@ -38,6 +38,7 @@ export function resolveStoreRoot(profileContext) {
|
|
|
38
38
|
* | { kind: 'preset', scope: 'global', presetId: string }
|
|
39
39
|
* | { kind: 'preset', scope: 'workspace', workspaceKey: string, presetId: string }
|
|
40
40
|
* | { kind: 'mcp-registry' }
|
|
41
|
+
* | { kind: 'skill-scan-paths' }
|
|
41
42
|
* | { kind: 'distribution', scope: string }} Unit
|
|
42
43
|
*/
|
|
43
44
|
|
|
@@ -77,6 +78,8 @@ export function unitLayout(unit) {
|
|
|
77
78
|
break
|
|
78
79
|
case 'mcp-registry':
|
|
79
80
|
return layout(['mcp'], 'registry')
|
|
81
|
+
case 'skill-scan-paths':
|
|
82
|
+
return layout(['skills'], 'scan-paths')
|
|
80
83
|
case 'distribution':
|
|
81
84
|
need(u.scope)
|
|
82
85
|
return layout(['distribution', /** @type {string} */ (u.scope)], 'active')
|
package/src/file-lease/client.js
CHANGED
|
@@ -1063,6 +1063,29 @@ export function createLeaseClient({
|
|
|
1063
1063
|
// Release and replay
|
|
1064
1064
|
// ---------------------------------------------------------------------
|
|
1065
1065
|
|
|
1066
|
+
/**
|
|
1067
|
+
* Release an explicit batch for `owner` (the `edit_lock_release` form).
|
|
1068
|
+
*
|
|
1069
|
+
* The ledger entry goes BEFORE the frame, and it goes even when the frame
|
|
1070
|
+
* never reaches the arbiter: an explicit release is the caller's intent, and
|
|
1071
|
+
* an unconfirmed transport must not resurrect a path the caller already let
|
|
1072
|
+
* go of — a replay on the next connection would re-take it. Only entries this
|
|
1073
|
+
* owner's lineage holds are forgotten; which of the rest the broker actually
|
|
1074
|
+
* released is still its verdict (`released` / `notOwned`).
|
|
1075
|
+
* @param {any} owner
|
|
1076
|
+
* @param {string[]} paths
|
|
1077
|
+
* @returns {Promise<any>}
|
|
1078
|
+
*/
|
|
1079
|
+
async function release(owner, paths) {
|
|
1080
|
+
const holder = normalizeOwner(owner)
|
|
1081
|
+
const list = normalizePaths(paths)
|
|
1082
|
+
for (const path of list) {
|
|
1083
|
+
const record = ledger.get(path)
|
|
1084
|
+
if (record !== undefined && record.lineage === holder.id) ledger.delete(path)
|
|
1085
|
+
}
|
|
1086
|
+
return await request('release', { owner: holder, paths: list })
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1066
1089
|
/**
|
|
1067
1090
|
* Release everything this session holds EXCEPT retentions (design D10): the
|
|
1068
1091
|
* `turn/end` rule. A failure is swallowed — turn-boundary release is an
|
|
@@ -1142,6 +1165,24 @@ export function createLeaseClient({
|
|
|
1142
1165
|
return attempt
|
|
1143
1166
|
}
|
|
1144
1167
|
|
|
1168
|
+
/**
|
|
1169
|
+
* Reconcile a grant whose replay record was superseded while on the wire.
|
|
1170
|
+
* A newer ledger record is newer intent and must not be released by this stale
|
|
1171
|
+
* operation. No record means the path was released; a grant that nevertheless
|
|
1172
|
+
* arrived late needs its own release because the explicit one may have won the
|
|
1173
|
+
* broker race before the grant existed.
|
|
1174
|
+
* @param {any} reply
|
|
1175
|
+
* @param {'granted' | 'held'} field
|
|
1176
|
+
* @param {{ id: string, session: string, label: string }} owner
|
|
1177
|
+
* @param {string} path
|
|
1178
|
+
*/
|
|
1179
|
+
async function releaseStaleReplayGrant(reply, field, owner, path) {
|
|
1180
|
+
if (ledger.get(path) !== undefined) return
|
|
1181
|
+
const grants = Array.isArray(reply?.[field]) ? reply[field] : []
|
|
1182
|
+
if (!grants.some((entry) => entry?.path === path)) return
|
|
1183
|
+
await request('release', { owner, paths: [path] })
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1145
1186
|
/**
|
|
1146
1187
|
* @returns {Promise<{ replayed: number, dropped: number }>}
|
|
1147
1188
|
*/
|
|
@@ -1153,11 +1194,25 @@ export function createLeaseClient({
|
|
|
1153
1194
|
if (record.holdUntil === null || record.holdUntil <= at) continue
|
|
1154
1195
|
const owner = ownerOf(record.lineage, record.session)
|
|
1155
1196
|
const scope = lastScope
|
|
1197
|
+
let current = record
|
|
1156
1198
|
try {
|
|
1157
1199
|
// Re-acquire first: after a daemon restart the table is empty, so a bare
|
|
1158
|
-
// `hold` would be `not-owned`. The pair is idempotent by protocol.
|
|
1159
|
-
|
|
1200
|
+
// `hold` would be `not-owned`. The pair is idempotent by protocol. Unlike
|
|
1201
|
+
// an ordinary acquire, replay must prove its snapshot is still current
|
|
1202
|
+
// before either response writes the path back into the ledger.
|
|
1203
|
+
const leaseScope = requireScope(scope)
|
|
1204
|
+
const acquired = await request('acquire', { owner, scope: leaseScope, paths: [path] })
|
|
1205
|
+
if (ledger.get(path) !== record) {
|
|
1206
|
+
await releaseStaleReplayGrant(acquired, 'granted', owner, path)
|
|
1207
|
+
continue
|
|
1208
|
+
}
|
|
1209
|
+
if (Array.isArray(acquired.granted)) remember(acquired.granted, owner, leaseScope)
|
|
1210
|
+
current = ledger.get(path) ?? record
|
|
1160
1211
|
const response = await request('hold', { owner, paths: [path], minutes: holdMinutes(record.holdUntil - at) })
|
|
1212
|
+
if (ledger.get(path) !== current) {
|
|
1213
|
+
await releaseStaleReplayGrant(response, 'held', owner, path)
|
|
1214
|
+
continue
|
|
1215
|
+
}
|
|
1161
1216
|
applyHold(response, owner)
|
|
1162
1217
|
replayed += 1
|
|
1163
1218
|
} catch (error) {
|
|
@@ -1166,7 +1221,9 @@ export function createLeaseClient({
|
|
|
1166
1221
|
// rather than forgetting a retention nobody has seen fail.
|
|
1167
1222
|
break
|
|
1168
1223
|
}
|
|
1169
|
-
|
|
1224
|
+
// A late failure is stale for the same reason as a late grant: neither
|
|
1225
|
+
// may delete a newer record installed after this snapshot was taken.
|
|
1226
|
+
if (ledger.get(path) === current) ledger.delete(path)
|
|
1170
1227
|
dropped += 1
|
|
1171
1228
|
}
|
|
1172
1229
|
}
|
|
@@ -1290,6 +1347,7 @@ export function createLeaseClient({
|
|
|
1290
1347
|
reply,
|
|
1291
1348
|
releaseSession,
|
|
1292
1349
|
releaseAll,
|
|
1350
|
+
release,
|
|
1293
1351
|
replayHolds,
|
|
1294
1352
|
onEvent,
|
|
1295
1353
|
onReset,
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// Transfer Negotiation owns the local reply window, not the broker request.
|
|
2
|
+
//
|
|
3
|
+
// One instance belongs to one plugin mount, matching the mount's conditional
|
|
4
|
+
// tool registrations. Process-wide request dedupe remains in the plugin: a
|
|
5
|
+
// sibling mount that did not deliver a request has no local window to close.
|
|
6
|
+
// The broker remains the only ownership and request-settlement authority.
|
|
7
|
+
|
|
8
|
+
/** @param {unknown} value @returns {string | undefined} */
|
|
9
|
+
function text(value) {
|
|
10
|
+
return typeof value === 'string' && value.length > 0 ? value : undefined
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Whether one structured tool outcome closes its local request.
|
|
15
|
+
*
|
|
16
|
+
* `transferred` and `declined` are broker settlements; `expired` and `stale`
|
|
17
|
+
* say the broker request is already over; `not-holder` closes only this
|
|
18
|
+
* unauthorized agent's local window. A transport result contains no broker
|
|
19
|
+
* verdict, so a valid pending request survives for an idempotent retry.
|
|
20
|
+
* Unknown results and failures retain the old finally-block behaviour and
|
|
21
|
+
* close the local request rather than inventing a new retry contract.
|
|
22
|
+
*
|
|
23
|
+
* @param {any} outcome a reply result or thrown error carrying state/code
|
|
24
|
+
* @returns {boolean}
|
|
25
|
+
*/
|
|
26
|
+
function closesLocalRequest(outcome) {
|
|
27
|
+
const code = text(outcome?.code)
|
|
28
|
+
const state = text(outcome?.state)
|
|
29
|
+
if (code === 'transport') return false
|
|
30
|
+
if (state === 'transferred' || state === 'declined') return true
|
|
31
|
+
if (code === 'expired' || code === 'stale' || code === 'not-holder') return true
|
|
32
|
+
return true
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Create the pending-request and reply-window state for one plugin mount.
|
|
37
|
+
*
|
|
38
|
+
* This module never calls the client. In particular, local `not-holder`
|
|
39
|
+
* cleanup cannot consume the broker request, which remains answerable by the
|
|
40
|
+
* real holder.
|
|
41
|
+
*/
|
|
42
|
+
export function createTransferNegotiation() {
|
|
43
|
+
/** @type {WeakMap<object, Map<string, { path: string, from: any }>>} */
|
|
44
|
+
const pendingByAgent = new WeakMap()
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
/**
|
|
48
|
+
* Add or refresh one request in an agent's local reply window.
|
|
49
|
+
* @param {object} agent
|
|
50
|
+
* @param {string} requestId
|
|
51
|
+
* @param {{ path: string, from: any }} request
|
|
52
|
+
*/
|
|
53
|
+
openRequest(agent, requestId, request) {
|
|
54
|
+
let pending = pendingByAgent.get(agent)
|
|
55
|
+
if (pending === undefined) {
|
|
56
|
+
pending = new Map()
|
|
57
|
+
pendingByAgent.set(agent, pending)
|
|
58
|
+
}
|
|
59
|
+
pending.set(requestId, request)
|
|
60
|
+
},
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Apply one reply attempt's structured outcome to its own request id.
|
|
64
|
+
*
|
|
65
|
+
* @param {object} agent
|
|
66
|
+
* @param {unknown} requestId
|
|
67
|
+
* @param {any} outcome reply result or thrown error
|
|
68
|
+
* @returns {number | undefined} requests still pending, or undefined when
|
|
69
|
+
* this agent has no local window (the adapter then has nothing to retire)
|
|
70
|
+
*/
|
|
71
|
+
finishReply(agent, requestId, outcome) {
|
|
72
|
+
const pending = pendingByAgent.get(agent)
|
|
73
|
+
if (pending === undefined) return undefined
|
|
74
|
+
const id = text(requestId)
|
|
75
|
+
if (closesLocalRequest(outcome) && id !== undefined) pending.delete(id)
|
|
76
|
+
if (pending.size === 0) pendingByAgent.delete(agent)
|
|
77
|
+
return pending.size
|
|
78
|
+
},
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Close every local request for an agent. Turn end, disposal and unmount
|
|
82
|
+
* call this independently of any broker outcome.
|
|
83
|
+
* @param {object} agent
|
|
84
|
+
*/
|
|
85
|
+
closeAgent(agent) {
|
|
86
|
+
pendingByAgent.delete(agent)
|
|
87
|
+
},
|
|
88
|
+
}
|
|
89
|
+
}
|