weir-harness 1.2.3 → 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/cordis.patch.yml +7 -18
- 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-badge.js +307 -0
- package/lib/client.file-lease-tool-model.js +189 -0
- package/lib/client.file-lease-tool-view.js +434 -0
- package/lib/client.js +124 -36
- package/lib/client.mcp-manager.js +563 -0
- package/lib/client.settings-page.js +10 -34
- package/package.json +2 -2
- 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/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 +1299 -0
- package/src/file-lease/index.js +34 -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 +1013 -0
- package/src/file-lease/singleton.js +155 -0
- package/src/file-lease/spawn.js +62 -0
- package/src/file-lease/tools.js +847 -0
- package/src/file-lease/view.js +144 -0
- package/src/file-lease/watch.js +473 -0
- package/src/hashline-edit/index.js +76 -42
- package/src/hashline-edit/write-tool.js +90 -0
- package/src/intent-gate/index.js +29 -12
- package/src/intent-gate/matcher.js +36 -2
- package/src/lease-broker/entry.js +98 -0
- package/src/lease-broker/node-contracts.d.ts +63 -0
- package/src/lease-broker/protocol.js +237 -0
- package/src/lease-broker/server.js +402 -0
- package/src/lease-broker/state.js +482 -0
- package/src/lsp/tools.js +137 -27
- package/src/process/index.js +4 -2
- package/src/settings/index.js +2 -44
- package/src/settings/sections.js +2 -24
- package/src/shared/audit.js +9 -1
- package/src/shared/child-scope.js +2 -0
- 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/src/worktree/rules.js +1 -1
- 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,847 @@
|
|
|
1
|
+
// The model-facing tool surface of the file-lease feature (design D4, D5, D13).
|
|
2
|
+
//
|
|
3
|
+
// Seven pure definitions. They own no state: the arbiter is the only authority
|
|
4
|
+
// (D6/D8), so a tool translates a model intent into one protocol call and
|
|
5
|
+
// translates the verdict back into something a model can act on. Nothing here
|
|
6
|
+
// re-derives ownership, remembers it, or waits for it.
|
|
7
|
+
//
|
|
8
|
+
// Text discipline (AGENTS §3.7): every template below is English, and every
|
|
9
|
+
// refusal is addressed to the MODEL — name the holder, say that the claim frees
|
|
10
|
+
// itself, say not to retry the same edit blindly, and offer the negotiation
|
|
11
|
+
// path. No template may name a command, a setting, a panel or any operator
|
|
12
|
+
// action: ownership always resolves by turn end, by validity elapsing, or by
|
|
13
|
+
// model-to-model negotiation (spec: "Refusal is addressed to the model", "No
|
|
14
|
+
// ownership state requires a human").
|
|
15
|
+
//
|
|
16
|
+
// Path discipline: the protocol speaks absolute paths only (a relative path
|
|
17
|
+
// would be `invalid-path` and then degrade), so tools resolve the model's
|
|
18
|
+
// short paths against the session cwd on the way in and render short names
|
|
19
|
+
// relative to that same cwd on the way out. An absolute path never lands in a
|
|
20
|
+
// message body.
|
|
21
|
+
//
|
|
22
|
+
// Normalization is lexical and it happens HERE, for every tool input: `resolve`
|
|
23
|
+
// folds `.`, `..`, repeated and trailing separators and keeps an absolute input
|
|
24
|
+
// absolute, so one file has one spelling for arbitration and for the
|
|
25
|
+
// subscription table. The broker deliberately does not normalize — there string
|
|
26
|
+
// equality IS a path (docs/features/edit-lock-protocol.md) — so a verbatim `..`
|
|
27
|
+
// spelling would be granted a SECOND lease on a file another session holds, and
|
|
28
|
+
// `edit_lock_watch` would miss the held canonical path entirely.
|
|
29
|
+
|
|
30
|
+
import { basename, relative, resolve } from 'node:path'
|
|
31
|
+
import { isLeaseConflict } from './client.js'
|
|
32
|
+
import { createWatchCoordinator } from './watch.js'
|
|
33
|
+
|
|
34
|
+
/** Retention bound in minutes; the protocol clamps here and so does the tool. */
|
|
35
|
+
export const MAX_HOLD_MINUTES = 60
|
|
36
|
+
|
|
37
|
+
/** The negotiation reply tool: registered only while a request is pending. */
|
|
38
|
+
export const REPLY_TOOL = 'edit_lock_reply'
|
|
39
|
+
|
|
40
|
+
/** The release-subscription tool: one path, one in-memory subscription. */
|
|
41
|
+
export const WATCH_TOOL = 'edit_lock_watch'
|
|
42
|
+
|
|
43
|
+
/** Default retention when the model asks for one without a length. */
|
|
44
|
+
export const DEFAULT_HOLD_MINUTES = MAX_HOLD_MINUTES
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* UI-only presentation data handed from `execute` to `presentationMeta`
|
|
48
|
+
* (openspec edit-lock-tool-cards, design D1). The conversation cards need a
|
|
49
|
+
* few facts the model never reads — the try_steal deadline, the per-row
|
|
50
|
+
* validity of a status answer, the paths of a degraded call — and the hard
|
|
51
|
+
* constraint is that the result object stays field-for-field what it was. So
|
|
52
|
+
* those facts never become result fields: `execute` files them here and
|
|
53
|
+
* `presentationMeta` takes them out again.
|
|
54
|
+
*
|
|
55
|
+
* The handoff is keyed by the result's JSON text, not by object identity: the
|
|
56
|
+
* tool registry snapshots the value `execute` returned (a lossless-JSON copy,
|
|
57
|
+
* then deep-frozen) and hands that COPY to `render` and `presentationMeta`, so
|
|
58
|
+
* an identity-keyed WeakMap never hits. A copy serializes to the same text,
|
|
59
|
+
* so the same key finds the entry. Each entry is taken once (a queue per key
|
|
60
|
+
* keeps two identical concurrent answers apart), the table is bounded, and a
|
|
61
|
+
* result with no entry still gets the subset its own fields can carry.
|
|
62
|
+
* @type {Map<string, Record<string, any>[]>}
|
|
63
|
+
*/
|
|
64
|
+
const presented = new Map()
|
|
65
|
+
|
|
66
|
+
/** Upper bound on filed-but-never-read entries (a result the host dropped). */
|
|
67
|
+
const MAX_PRESENTED = 256
|
|
68
|
+
|
|
69
|
+
/** @param {unknown} value @returns {string | undefined} */
|
|
70
|
+
function presentationKey(value) {
|
|
71
|
+
if (value === null || typeof value !== 'object') return undefined
|
|
72
|
+
try {
|
|
73
|
+
return JSON.stringify(value)
|
|
74
|
+
} catch {
|
|
75
|
+
return undefined
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** @param {unknown} value @returns {Record<string, any> | undefined} */
|
|
80
|
+
function takePresented(value) {
|
|
81
|
+
const key = presentationKey(value)
|
|
82
|
+
if (key === undefined) return undefined
|
|
83
|
+
const queue = presented.get(key)
|
|
84
|
+
if (queue === undefined) return undefined
|
|
85
|
+
const fields = queue.shift()
|
|
86
|
+
if (queue.length === 0) presented.delete(key)
|
|
87
|
+
return fields
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Presentation metadata schema version; the client narrows on it. */
|
|
91
|
+
export const LEASE_META_VERSION = 1
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The closed state set per tool (design D1). Anything outside it is never
|
|
95
|
+
* emitted, so the client can treat an unknown state as malformed metadata.
|
|
96
|
+
* @type {Record<string, readonly string[]>}
|
|
97
|
+
*/
|
|
98
|
+
export const LEASE_META_STATES = Object.freeze({
|
|
99
|
+
edit_lock_acquire: Object.freeze(['claimed', 'degraded']),
|
|
100
|
+
edit_lock_release: Object.freeze(['released', 'degraded']),
|
|
101
|
+
edit_lock_hold: Object.freeze(['held', 'degraded']),
|
|
102
|
+
edit_lock_status: Object.freeze(['status', 'degraded']),
|
|
103
|
+
edit_lock_try_steal: Object.freeze(['pending', 'free', 'self', 'degraded']),
|
|
104
|
+
edit_lock_reply: Object.freeze(['transferred', 'declined', 'degraded']),
|
|
105
|
+
edit_lock_watch: Object.freeze(['watching', 'free', 'self', 'released', 'reset', 'unsupported', 'cancelled', 'degraded']),
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* File the presentation data for one result and hand the result back
|
|
110
|
+
* untouched: nothing is added to the object the model reads.
|
|
111
|
+
* @template {object} T
|
|
112
|
+
* @param {T} value
|
|
113
|
+
* @param {Record<string, any> | undefined} fields
|
|
114
|
+
* @returns {T}
|
|
115
|
+
*/
|
|
116
|
+
function present(value, fields) {
|
|
117
|
+
const key = presentationKey(value)
|
|
118
|
+
if (fields === undefined || key === undefined) return value
|
|
119
|
+
const queue = presented.get(key)
|
|
120
|
+
if (queue === undefined) presented.set(key, [fields])
|
|
121
|
+
else queue.push(fields)
|
|
122
|
+
// Insertion order is age order: drop the oldest key once over the bound.
|
|
123
|
+
if (presented.size > MAX_PRESENTED) {
|
|
124
|
+
const oldest = presented.keys().next().value
|
|
125
|
+
if (oldest !== undefined) presented.delete(oldest)
|
|
126
|
+
}
|
|
127
|
+
return value
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* File a degraded (arbiter-unreachable) answer: the card shows "protection
|
|
132
|
+
* temporarily unavailable", never a refusal, plus what the call was about.
|
|
133
|
+
* @template {{ code: string }} T
|
|
134
|
+
* @param {T} value
|
|
135
|
+
* @param {Record<string, any>} about
|
|
136
|
+
* @returns {T}
|
|
137
|
+
*/
|
|
138
|
+
function presentDegraded(value, about) {
|
|
139
|
+
return present(value, { state: 'degraded', code: value.code, ...about })
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** @param {unknown} list @returns {string[]} */
|
|
143
|
+
function strings(list) {
|
|
144
|
+
return Array.isArray(list) ? list.filter((entry) => typeof entry === 'string') : []
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** @param {any} holder */
|
|
148
|
+
function holderOf(holder) {
|
|
149
|
+
return holder && typeof holder === 'object' && typeof holder.label === 'string' && typeof holder.session === 'string'
|
|
150
|
+
? { label: holder.label, session: holder.session }
|
|
151
|
+
: undefined
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* The presentation fields one result's OWN fields can carry. This is both the
|
|
156
|
+
* base every `execute` extends and the whole answer for a result that has no
|
|
157
|
+
* filed entry.
|
|
158
|
+
* @param {string} tool
|
|
159
|
+
* @param {any} value
|
|
160
|
+
* @returns {Record<string, any> | undefined}
|
|
161
|
+
*/
|
|
162
|
+
function derivedFields(tool, value) {
|
|
163
|
+
if (value === null || typeof value !== 'object') return undefined
|
|
164
|
+
if (value.degraded === true) {
|
|
165
|
+
const code = typeof value.code === 'string' ? value.code : 'internal'
|
|
166
|
+
// A watch without release events or one cancelled underneath it is not an
|
|
167
|
+
// unreachable arbiter, and the card must not read it as one.
|
|
168
|
+
if (tool === WATCH_TOOL && code === 'release-events-unsupported') return { state: 'unsupported', code }
|
|
169
|
+
if (tool === WATCH_TOOL && code === 'watch-cancelled') return { state: 'cancelled', code }
|
|
170
|
+
return { state: 'degraded', code }
|
|
171
|
+
}
|
|
172
|
+
switch (tool) {
|
|
173
|
+
case 'edit_lock_acquire':
|
|
174
|
+
return { state: 'claimed', paths: strings(value.paths) }
|
|
175
|
+
case 'edit_lock_release':
|
|
176
|
+
return { state: 'released', released: strings(value.released), notOwned: strings(value.notOwned) }
|
|
177
|
+
case 'edit_lock_hold':
|
|
178
|
+
return {
|
|
179
|
+
state: 'held',
|
|
180
|
+
held: (Array.isArray(value.held) ? value.held : []).map((/** @type {any} */ row) => ({ path: String(row?.path ?? ''), holdUntil: typeof row?.holdUntil === 'number' ? row.holdUntil : null })),
|
|
181
|
+
}
|
|
182
|
+
case 'edit_lock_status':
|
|
183
|
+
return { state: 'status', mine: strings(value.mine).map((path) => ({ path })), others: [] }
|
|
184
|
+
case 'edit_lock_try_steal': {
|
|
185
|
+
if (!['pending', 'free', 'self'].includes(value.state)) return undefined
|
|
186
|
+
const holder = holderOf(value.holder)
|
|
187
|
+
return { state: value.state, ...(typeof value.request_id === 'string' ? { requestId: value.request_id } : {}), ...(holder ? { holder } : {}) }
|
|
188
|
+
}
|
|
189
|
+
case REPLY_TOOL:
|
|
190
|
+
return {
|
|
191
|
+
state: value.state === 'transferred' ? 'transferred' : 'declined',
|
|
192
|
+
...(typeof value.request_id === 'string' ? { requestId: value.request_id } : {}),
|
|
193
|
+
...(typeof value.decision === 'string' ? { decision: value.decision } : {}),
|
|
194
|
+
}
|
|
195
|
+
case WATCH_TOOL: {
|
|
196
|
+
if (!LEASE_META_STATES[WATCH_TOOL].includes(value.state)) return undefined
|
|
197
|
+
const holder = holderOf(value.holder)
|
|
198
|
+
return { state: value.state, ...(typeof value.file_path === 'string' ? { path: value.file_path } : {}), ...(holder ? { holder } : {}) }
|
|
199
|
+
}
|
|
200
|
+
default:
|
|
201
|
+
return undefined
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* The output contract of every edit-lock tool. `render` is byte-identical to
|
|
207
|
+
* the single-field text rendering it replaces (the model reads `message` and
|
|
208
|
+
* nothing else from the text channel); `presentationMeta` is the UI-only
|
|
209
|
+
* channel the conversation cards read from the persisted tool result.
|
|
210
|
+
* Short names only and epoch-ms instants: an absolute path never appears
|
|
211
|
+
* here either.
|
|
212
|
+
* @param {string} tool
|
|
213
|
+
*/
|
|
214
|
+
function leaseOutput(tool) {
|
|
215
|
+
return {
|
|
216
|
+
schema: { type: 'object' },
|
|
217
|
+
render: (/** @type {any} */ _args, /** @type {any} */ result) => [{ type: 'text', text: result.message }],
|
|
218
|
+
// `null`, never `undefined`, for a value it cannot describe: the registry
|
|
219
|
+
// rejects a non-JSON projection, which would turn the call into an error.
|
|
220
|
+
presentationMeta: (/** @type {any} */ _args, /** @type {any} */ value) => {
|
|
221
|
+
const fields = takePresented(value) ?? derivedFields(tool, value)
|
|
222
|
+
if (fields === undefined || !LEASE_META_STATES[tool]?.includes(fields.state)) return null
|
|
223
|
+
return { editLock: { v: LEASE_META_VERSION, tool, ...fields } }
|
|
224
|
+
},
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** @param {string} description */
|
|
229
|
+
function pathList(description) {
|
|
230
|
+
return { type: 'array', items: { type: 'string' }, minItems: 1, description }
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** @param {unknown} value @returns {string | undefined} */
|
|
234
|
+
function text(value) {
|
|
235
|
+
return typeof value === 'string' && value.length > 0 ? value : undefined
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** A broker refusal code, or `internal` when the failure carries none. @param {any} error */
|
|
239
|
+
function codeOf(error) {
|
|
240
|
+
const code = error && typeof error === 'object' ? /** @type {any} */ (error).code : undefined
|
|
241
|
+
return typeof code === 'string' ? code : 'internal'
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** True for the one failure class that is infrastructure, not a verdict (D7). */
|
|
245
|
+
function isTransport(error) {
|
|
246
|
+
return codeOf(error) === 'transport'
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* The session working directory, which is both the resolution base for short
|
|
251
|
+
* paths and the opaque project scope the arbiter only ever uses for `status`.
|
|
252
|
+
* @param {any} exec
|
|
253
|
+
* @returns {string}
|
|
254
|
+
*/
|
|
255
|
+
function requireCwd(exec) {
|
|
256
|
+
const cwd = text(exec?.agent?.session?.header?.cwd)
|
|
257
|
+
if (cwd === undefined) throw new Error('the session working directory is unavailable, so paths cannot be resolved')
|
|
258
|
+
return cwd
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Render a path the way the model thinks about it: relative to the session cwd,
|
|
263
|
+
* or as a bare name when it lives outside. A resolved absolute path is accepted
|
|
264
|
+
* on the way in — that symmetry is what makes the model's own absolute path
|
|
265
|
+
* echo readable instead of pasted verbatim.
|
|
266
|
+
* @param {string} path
|
|
267
|
+
* @param {string | undefined} cwd
|
|
268
|
+
* @returns {string}
|
|
269
|
+
*/
|
|
270
|
+
export function shortName(path, cwd) {
|
|
271
|
+
const name = basename(path)
|
|
272
|
+
if (cwd === undefined) return name
|
|
273
|
+
const rel = relative(cwd, path)
|
|
274
|
+
if (rel === '' || rel === '..' || rel.startsWith('../') || rel.startsWith('..\\')) return name
|
|
275
|
+
return rel
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* One file, one spelling: the lexical normalization every tool input passes
|
|
280
|
+
* through before it can become a claim, a retention, a release, a transfer
|
|
281
|
+
* request or a release subscription.
|
|
282
|
+
*
|
|
283
|
+
* `resolve` is the right instrument and no other is needed: it folds `.`,
|
|
284
|
+
* `..`, repeated and trailing separators, keeps an absolute input absolute
|
|
285
|
+
* (the base is then ignored) and resolves a relative one against the session
|
|
286
|
+
* cwd. It touches no filesystem, so symlinks and case are left alone — the
|
|
287
|
+
* protocol makes that the client's call, and it keys leases by string equality,
|
|
288
|
+
* which is exactly why `/p/src/../src/a.js` and `/p/src/a.js` must never reach
|
|
289
|
+
* it as two paths.
|
|
290
|
+
* @param {string} raw
|
|
291
|
+
* @param {string} cwd
|
|
292
|
+
* @returns {string}
|
|
293
|
+
*/
|
|
294
|
+
function canonicalPath(raw, cwd) {
|
|
295
|
+
return resolve(cwd, raw)
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Resolve the model's path batch to the canonical, de-duplicated list the
|
|
300
|
+
* protocol accepts. Normalization happens before the de-duplication, so two
|
|
301
|
+
* spellings of one file collapse into one entry instead of becoming two claims.
|
|
302
|
+
* @param {unknown} paths
|
|
303
|
+
* @param {string} cwd
|
|
304
|
+
* @returns {string[]}
|
|
305
|
+
*/
|
|
306
|
+
function absoluteBatch(paths, cwd) {
|
|
307
|
+
if (!Array.isArray(paths) || paths.length === 0) throw new Error('paths must list at least one path')
|
|
308
|
+
/** @type {string[]} */
|
|
309
|
+
const out = []
|
|
310
|
+
for (const entry of paths) {
|
|
311
|
+
const raw = text(entry)
|
|
312
|
+
if (raw === undefined) throw new Error('every entry of paths must be a non-empty string')
|
|
313
|
+
const absolute = canonicalPath(raw, cwd)
|
|
314
|
+
if (!out.includes(absolute)) out.push(absolute)
|
|
315
|
+
}
|
|
316
|
+
return out
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* The single-path form of {@link absoluteBatch}: normalize before the path can
|
|
321
|
+
* reach the arbiter or the subscription coordinator.
|
|
322
|
+
* @param {unknown} filePath
|
|
323
|
+
* @param {string} cwd
|
|
324
|
+
* @returns {string}
|
|
325
|
+
*/
|
|
326
|
+
function absoluteOne(filePath, cwd) {
|
|
327
|
+
const raw = text(filePath)
|
|
328
|
+
if (raw === undefined) throw new Error('file_path must be a non-empty string')
|
|
329
|
+
return canonicalPath(raw, cwd)
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** `2026-05-05T12:00:00.000Z` for a validity instant. @param {number} at epoch ms */
|
|
333
|
+
function instant(at) {
|
|
334
|
+
return new Date(at).toISOString()
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Remaining validity in whole seconds, rounded up so a positive window never
|
|
339
|
+
* renders as "0s" and a lapsed one never renders as still valid.
|
|
340
|
+
* @param {number} target epoch ms
|
|
341
|
+
* @param {number} at epoch ms
|
|
342
|
+
* @returns {number}
|
|
343
|
+
*/
|
|
344
|
+
function secondsUntil(target, at) {
|
|
345
|
+
return Math.max(0, Math.ceil((target - at) / 1000))
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/** Whether a row carries a retention deadline beyond its plain validity. */
|
|
349
|
+
function retainedUntil(/** @type {any} */ row) {
|
|
350
|
+
const expiresAt = typeof row?.expiresAt === 'number' ? row.expiresAt : 0
|
|
351
|
+
const holdUntil = typeof row?.holdUntil === 'number' ? row.holdUntil : 0
|
|
352
|
+
return holdUntil > expiresAt ? holdUntil : undefined
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* The refusal text for a `held` verdict. The obligations of the spec's "Refusal
|
|
357
|
+
* names the holder" and "Refusal offers no human step" scenarios are encoded
|
|
358
|
+
* here and nowhere else, so the wording cannot drift between the tools that can
|
|
359
|
+
* hit a conflict — including the managed editors that will acquire leases
|
|
360
|
+
* instead of calling a tool, which is why this is exported.
|
|
361
|
+
* @param {any} error a `held` LeaseFailure carrying `conflicts`
|
|
362
|
+
* @param {string} cwd
|
|
363
|
+
* @param {number} at epoch ms
|
|
364
|
+
* @returns {string}
|
|
365
|
+
*/
|
|
366
|
+
export function describeConflicts(error, cwd, at) {
|
|
367
|
+
const conflicts = Array.isArray(error?.conflicts) ? error.conflicts : []
|
|
368
|
+
const lines = conflicts.map((/** @type {any} */ conflict) => {
|
|
369
|
+
const holder = conflict?.holder ?? {}
|
|
370
|
+
const label = text(holder.label) ?? 'another session'
|
|
371
|
+
const session = text(holder.session) ?? 'unknown'
|
|
372
|
+
const target = Math.max(typeof conflict?.expiresAt === 'number' ? conflict.expiresAt : 0, typeof conflict?.holdUntil === 'number' ? conflict.holdUntil : 0)
|
|
373
|
+
return `- ${shortName(String(conflict?.path ?? ''), cwd)} is held by "${label}" (session ${session}) for about ${secondsUntil(target, at)}s more`
|
|
374
|
+
})
|
|
375
|
+
const headline = conflicts.length > 0
|
|
376
|
+
? `Refused: ${conflicts.length} of the requested path(s) are owned by another session.`
|
|
377
|
+
: 'Refused: another session owns one or more of the requested paths.'
|
|
378
|
+
return [
|
|
379
|
+
headline,
|
|
380
|
+
...lines,
|
|
381
|
+
'Ownership frees itself when its validity ends, so do not retry this edit blindly: the same call keeps being refused while the holder keeps the path.',
|
|
382
|
+
'If you need a path now, ask for it — call edit_lock_try_steal with that path and wait for the holder\'s answer (release transfers it, keep or silence leaves it where it is).',
|
|
383
|
+
].join('\n')
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* The synchronous answer for a subscription that is already over. Every await in
|
|
388
|
+
* the watch tool re-reads this first (design D2): a release, a reset or a
|
|
389
|
+
* transfer that landed while the authoritative read was in flight is the truth,
|
|
390
|
+
* and a late read result or failure may neither overwrite it nor cancel its
|
|
391
|
+
* pending notice.
|
|
392
|
+
* @param {import('./watch.js').WatchEntry} entry
|
|
393
|
+
* @param {string} short
|
|
394
|
+
* @returns {any | undefined}
|
|
395
|
+
*/
|
|
396
|
+
function terminalOutcome(entry, short) {
|
|
397
|
+
if (entry.state === 'hit') {
|
|
398
|
+
entry.settledByTool = true
|
|
399
|
+
return {
|
|
400
|
+
state: 'released',
|
|
401
|
+
file_path: short,
|
|
402
|
+
message: `The release of ${short} happened while this subscription was being established, so nothing is pending. Claim it with edit_lock_acquire if you still need it — a release is an opportunity, not an ownership grant.`,
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
if (entry.state === 'reset') {
|
|
406
|
+
entry.settledByTool = true
|
|
407
|
+
return {
|
|
408
|
+
state: 'reset',
|
|
409
|
+
file_path: short,
|
|
410
|
+
message: `The ownership arbiter connection was reset while this subscription was being established, so nothing is pending and nothing is known about ownership. Re-check with edit_lock_status, and call edit_lock_watch again if ${short} is still held by another session.`,
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
if (entry.state === 'self') {
|
|
414
|
+
entry.settledByTool = true
|
|
415
|
+
return {
|
|
416
|
+
state: 'self',
|
|
417
|
+
file_path: short,
|
|
418
|
+
message: `This session already owns ${short} — ownership arrived while this subscription was being established — so no release subscription was kept and there is nothing to wait for.`,
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
return undefined
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* The degraded answer for a wait that ended without an ownership fact.
|
|
426
|
+
* @param {string} short
|
|
427
|
+
* @returns {any}
|
|
428
|
+
*/
|
|
429
|
+
function cancelledOutcome(short) {
|
|
430
|
+
return {
|
|
431
|
+
degraded: true,
|
|
432
|
+
code: 'watch-cancelled',
|
|
433
|
+
message: `The release subscription for ${short} was cancelled before it could be established (this agent or the feature was shut down), so nothing is pending. Call edit_lock_watch again in a live agent if you still need the path.`,
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* Whether one entry is still the active wait it was. `watching` may only be
|
|
439
|
+
* reported for the SAME object that is currently registered (design D2): an
|
|
440
|
+
* overlapping call, a transfer or a disposal can end the wait while a read is
|
|
441
|
+
* in flight, and a stale entry must never come back as `watching`.
|
|
442
|
+
* @param {import('./watch.js').WatchCoordinator} watches
|
|
443
|
+
* @param {import('./watch.js').WatchEntry} entry
|
|
444
|
+
* @returns {boolean}
|
|
445
|
+
*/
|
|
446
|
+
function stillWatching(watches, entry) {
|
|
447
|
+
return entry.state === 'watching' && watches.peek(entry.sessionId, entry.path) === entry
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* Create the tool definitions.
|
|
452
|
+
*
|
|
453
|
+
* @param {object} options
|
|
454
|
+
* @param {any} options.client the lease client (or a facade with the same surface)
|
|
455
|
+
* @param {(agent: any) => { id: string, session: string, label: string }} options.ownerOf
|
|
456
|
+
* @param {(agent: any, text: string) => any} [options.deliverNotice] model-facing
|
|
457
|
+
* notice channel, used only where a tool result cannot carry the outcome
|
|
458
|
+
* @param {import('./watch.js').WatchCoordinator} [options.watchCoordinator] the
|
|
459
|
+
* subscription coordinator shared with the plugin's event routing
|
|
460
|
+
* @param {import('./watch.js').WatchMount} [options.mount] the mount reference
|
|
461
|
+
* that owns this call's subscription, when a mount is in play
|
|
462
|
+
* @returns {any[]} seven definitions, `REPLY_TOOL` included (the plugin decides
|
|
463
|
+
* when that one becomes visible)
|
|
464
|
+
*/
|
|
465
|
+
export function createLeaseTools({ client, ownerOf, deliverNotice = () => {}, watchCoordinator = createWatchCoordinator(), mount }) {
|
|
466
|
+
const watches = watchCoordinator
|
|
467
|
+
return [
|
|
468
|
+
{
|
|
469
|
+
name: 'edit_lock_acquire',
|
|
470
|
+
description:
|
|
471
|
+
'Claim file paths for this session before touching them, so another session cannot take them while you analyse. Managed edits claim implicitly; call this to hold the paths up front. The whole batch is granted or refused together. A refusal names the current holder and frees itself — do not retry it blindly.',
|
|
472
|
+
parameters: {
|
|
473
|
+
type: 'object',
|
|
474
|
+
properties: {
|
|
475
|
+
paths: pathList('Paths to claim, absolute or relative to the session working directory.'),
|
|
476
|
+
},
|
|
477
|
+
required: ['paths'],
|
|
478
|
+
},
|
|
479
|
+
output: leaseOutput('edit_lock_acquire'),
|
|
480
|
+
async execute(/** @type {any} */ args, /** @type {any} */ exec) {
|
|
481
|
+
const owner = ownerOf(exec?.agent)
|
|
482
|
+
const cwd = requireCwd(exec)
|
|
483
|
+
const paths = absoluteBatch(args?.paths, cwd)
|
|
484
|
+
try {
|
|
485
|
+
const reply = await client.acquire(owner, paths, cwd)
|
|
486
|
+
const granted = Array.isArray(reply?.granted) ? reply.granted : []
|
|
487
|
+
const claimed = granted.map((/** @type {any} */ lease) => shortName(String(lease?.path ?? ''), cwd))
|
|
488
|
+
const result = {
|
|
489
|
+
paths: claimed,
|
|
490
|
+
message: claimed.length > 0
|
|
491
|
+
? `Claimed ${claimed.length} path(s) for this turn: ${claimed.join(', ')}.`
|
|
492
|
+
: 'Nothing was claimed.',
|
|
493
|
+
}
|
|
494
|
+
return present(result, { state: 'claimed', paths: [...claimed] })
|
|
495
|
+
} catch (error) {
|
|
496
|
+
if (isLeaseConflict(error)) throw new Error(describeConflicts(error, cwd, Date.now()))
|
|
497
|
+
if (isTransport(error)) return presentDegraded(unavailable(error, 'nothing was claimed'), { paths: paths.map((path) => shortName(path, cwd)) })
|
|
498
|
+
throw error
|
|
499
|
+
}
|
|
500
|
+
},
|
|
501
|
+
},
|
|
502
|
+
|
|
503
|
+
{
|
|
504
|
+
name: 'edit_lock_release',
|
|
505
|
+
description:
|
|
506
|
+
'Release paths this session owns, retained ones included, so another session can claim them without waiting for any deadline. Releasing a path nobody owns is not an error: the answer simply says it was not held.',
|
|
507
|
+
parameters: {
|
|
508
|
+
type: 'object',
|
|
509
|
+
properties: {
|
|
510
|
+
paths: pathList('Paths to release, absolute or relative to the session working directory.'),
|
|
511
|
+
},
|
|
512
|
+
required: ['paths'],
|
|
513
|
+
},
|
|
514
|
+
output: leaseOutput('edit_lock_release'),
|
|
515
|
+
async execute(/** @type {any} */ args, /** @type {any} */ exec) {
|
|
516
|
+
const owner = ownerOf(exec?.agent)
|
|
517
|
+
const cwd = requireCwd(exec)
|
|
518
|
+
const paths = absoluteBatch(args?.paths, cwd)
|
|
519
|
+
let reply
|
|
520
|
+
try {
|
|
521
|
+
reply = await client.request('release', { owner, paths })
|
|
522
|
+
} catch (error) {
|
|
523
|
+
if (isTransport(error)) return presentDegraded(unavailable(error, 'the release was not confirmed'), { paths: paths.map((path) => shortName(path, cwd)) })
|
|
524
|
+
throw error
|
|
525
|
+
}
|
|
526
|
+
const released = (Array.isArray(reply?.released) ? reply.released : []).map((/** @type {any} */ path) => shortName(String(path), cwd))
|
|
527
|
+
const notOwned = (Array.isArray(reply?.notOwned) ? reply.notOwned : []).map((/** @type {any} */ path) => shortName(String(path), cwd))
|
|
528
|
+
const lines = []
|
|
529
|
+
lines.push(released.length > 0 ? `Released ${released.length} path(s): ${released.join(', ')}.` : 'Released nothing.')
|
|
530
|
+
if (notOwned.length > 0) lines.push(`Not held by this session, so there was nothing to release: ${notOwned.join(', ')}.`)
|
|
531
|
+
return present({ released, notOwned, message: lines.join('\n') }, { state: 'released', released: [...released], notOwned: [...notOwned] })
|
|
532
|
+
},
|
|
533
|
+
},
|
|
534
|
+
|
|
535
|
+
{
|
|
536
|
+
name: 'edit_lock_hold',
|
|
537
|
+
description: `Keep paths this session already owns past the end of this turn, for up to ${MAX_HOLD_MINUTES} minutes from now. Use it when the work continues after this turn; the paths stay yours and other sessions are refused until the deadline. A longer request is clamped, not ignored. When the deadline passes the paths become available again, and the expiry is announced only to a session that explicitly subscribed to that path's release with edit_lock_watch: a matching subscriber gets the usual notice that it may claim the path again, while with no subscriber nothing is announced.`,
|
|
538
|
+
parameters: {
|
|
539
|
+
type: 'object',
|
|
540
|
+
properties: {
|
|
541
|
+
paths: pathList('Paths to keep, absolute or relative to the session working directory. They must already be owned.'),
|
|
542
|
+
minutes: {
|
|
543
|
+
type: 'number',
|
|
544
|
+
description: `How long to keep them, counted from now; clamped to ${MAX_HOLD_MINUTES}. Omit for the full ${DEFAULT_HOLD_MINUTES} minutes.`,
|
|
545
|
+
},
|
|
546
|
+
},
|
|
547
|
+
required: ['paths'],
|
|
548
|
+
},
|
|
549
|
+
output: leaseOutput('edit_lock_hold'),
|
|
550
|
+
async execute(/** @type {any} */ args, /** @type {any} */ exec) {
|
|
551
|
+
const owner = ownerOf(exec?.agent)
|
|
552
|
+
const cwd = requireCwd(exec)
|
|
553
|
+
const paths = absoluteBatch(args?.paths, cwd)
|
|
554
|
+
const requested = args?.minutes === undefined || args?.minutes === null ? DEFAULT_HOLD_MINUTES : Number(args.minutes)
|
|
555
|
+
if (!Number.isFinite(requested) || requested <= 0) throw new Error('minutes must be a positive number of minutes')
|
|
556
|
+
const minutes = Math.min(MAX_HOLD_MINUTES, requested)
|
|
557
|
+
let reply
|
|
558
|
+
try {
|
|
559
|
+
reply = await client.hold(paths, minutes, owner)
|
|
560
|
+
} catch (error) {
|
|
561
|
+
const code = codeOf(error)
|
|
562
|
+
if (code === 'not-owned') {
|
|
563
|
+
throw new Error(`This session does not own ${paths.map((path) => shortName(path, cwd)).join(', ')}. Retention only extends ownership that already exists — claim the paths with edit_lock_acquire first.`)
|
|
564
|
+
}
|
|
565
|
+
if (isTransport(error)) return presentDegraded(unavailable(error, 'no retention was recorded'), { paths: paths.map((path) => shortName(path, cwd)) })
|
|
566
|
+
throw error
|
|
567
|
+
}
|
|
568
|
+
const held = Array.isArray(reply?.held) ? reply.held : []
|
|
569
|
+
const at = Date.now()
|
|
570
|
+
/** @type {{ path: string, holdUntil: number }[]} */
|
|
571
|
+
const shown = []
|
|
572
|
+
const entries = held.map((/** @type {any} */ row) => {
|
|
573
|
+
const until = typeof row?.holdUntil === 'number' ? row.holdUntil : typeof row?.expiresAt === 'number' ? row.expiresAt : at
|
|
574
|
+
shown.push({ path: shortName(String(row?.path ?? ''), cwd), holdUntil: until })
|
|
575
|
+
return `${shortName(String(row?.path ?? ''), cwd)} until ${instant(until)} (about ${Math.ceil(secondsUntil(until, at) / 60)}m from now)`
|
|
576
|
+
})
|
|
577
|
+
return present({
|
|
578
|
+
// Short names only: an absolute path never appears in a tool result,
|
|
579
|
+
// not even in a structured field the model also reads.
|
|
580
|
+
held: held.map((/** @type {any} */ row) => ({ path: shortName(String(row?.path ?? ''), cwd), holdUntil: row?.holdUntil ?? null })),
|
|
581
|
+
message: entries.length > 0
|
|
582
|
+
? `Kept ${entries.length} path(s) past this turn: ${entries.join(', ')}. At most ${MAX_HOLD_MINUTES} minutes per request; a deadline that passes simply makes the paths available, and the expiry is announced only to a session that explicitly subscribed to that path with edit_lock_watch — a matching subscriber gets the usual chance to claim it again, while with no subscriber nothing is announced.`
|
|
583
|
+
: 'Nothing was retained.',
|
|
584
|
+
}, { state: 'held', held: shown })
|
|
585
|
+
},
|
|
586
|
+
},
|
|
587
|
+
|
|
588
|
+
{
|
|
589
|
+
name: 'edit_lock_try_steal',
|
|
590
|
+
description:
|
|
591
|
+
'Ask the session that owns a file to hand it over. Returns immediately with a pending request: it never waits for the holder. The holder is told about it and answers with edit_lock_reply; release transfers ownership, keep (or silence) leaves it where it is. Do not edit the file before the transfer is granted.',
|
|
592
|
+
parameters: {
|
|
593
|
+
type: 'object',
|
|
594
|
+
properties: {
|
|
595
|
+
file_path: { type: 'string', description: 'The owned file you need, absolute or relative to the session working directory.' },
|
|
596
|
+
},
|
|
597
|
+
required: ['file_path'],
|
|
598
|
+
},
|
|
599
|
+
output: leaseOutput('edit_lock_try_steal'),
|
|
600
|
+
async execute(/** @type {any} */ args, /** @type {any} */ exec) {
|
|
601
|
+
const owner = ownerOf(exec?.agent)
|
|
602
|
+
const cwd = requireCwd(exec)
|
|
603
|
+
const path = absoluteOne(args?.file_path, cwd)
|
|
604
|
+
const short = shortName(path, cwd)
|
|
605
|
+
let reply
|
|
606
|
+
try {
|
|
607
|
+
reply = await client.requestTransfer(path, owner)
|
|
608
|
+
} catch (error) {
|
|
609
|
+
const code = codeOf(error)
|
|
610
|
+
if (code === 'free') return present({ state: 'free', message: `Nobody holds ${short} right now: edit it directly, or claim it with edit_lock_acquire so another session cannot take it mid-work.` }, { state: 'free', path: short })
|
|
611
|
+
if (code === 'self') return present({ state: 'self', message: `This session already owns ${short}; no transfer is needed.` }, { state: 'self', path: short })
|
|
612
|
+
if (isTransport(error)) return presentDegraded(unavailable(error, 'no transfer request was created'), { path: short })
|
|
613
|
+
throw error
|
|
614
|
+
}
|
|
615
|
+
const requestId = text(reply?.requestId) ?? ''
|
|
616
|
+
const holder = reply?.holder ?? {}
|
|
617
|
+
const label = text(holder.label) ?? 'the holder'
|
|
618
|
+
const session = text(holder.session) ?? 'unknown'
|
|
619
|
+
const expiresAt = typeof reply?.expiresAt === 'number' ? reply.expiresAt : Date.now()
|
|
620
|
+
return present({
|
|
621
|
+
state: 'pending',
|
|
622
|
+
request_id: requestId,
|
|
623
|
+
holder: { label, session },
|
|
624
|
+
message: `Request ${requestId} for ${short} is pending with "${label}" (session ${session}). Do not modify ${short} until ownership is granted — a pending request is not an approval, and edits keep being refused meanwhile. The holder answers with edit_lock_reply (release hands it over, keep keeps it); an unanswered request expires in about ${secondsUntil(expiresAt, Date.now())}s and ownership stays where it is. A message will tell you how it settled.`,
|
|
625
|
+
}, { state: 'pending', path: short, requestId, holder: { label, session }, expiresAt })
|
|
626
|
+
},
|
|
627
|
+
},
|
|
628
|
+
|
|
629
|
+
{
|
|
630
|
+
name: REPLY_TOOL,
|
|
631
|
+
description:
|
|
632
|
+
'Answer a pending ownership request another session addressed to this session. Only the current holder can answer, this tool exists only while a request is pending, and stale or already-settled requests are refused. "release" hands the path over (this session stops owning it); "keep" leaves ownership unchanged. Not answering also leaves ownership unchanged.',
|
|
633
|
+
parameters: {
|
|
634
|
+
type: 'object',
|
|
635
|
+
properties: {
|
|
636
|
+
request_id: { type: 'string', description: 'The request_id from the ownership request message.' },
|
|
637
|
+
decision: { type: 'string', enum: ['release', 'keep'], description: 'release to hand the path over, keep to hold on to it.' },
|
|
638
|
+
},
|
|
639
|
+
required: ['request_id', 'decision'],
|
|
640
|
+
},
|
|
641
|
+
output: leaseOutput(REPLY_TOOL),
|
|
642
|
+
async execute(/** @type {any} */ args, /** @type {any} */ exec) {
|
|
643
|
+
const owner = ownerOf(exec?.agent)
|
|
644
|
+
const requestId = text(args?.request_id)
|
|
645
|
+
if (requestId === undefined) throw new Error('request_id must be a non-empty string')
|
|
646
|
+
const decision = args?.decision === 'release' ? 'release' : args?.decision === 'keep' ? 'keep' : undefined
|
|
647
|
+
if (decision === undefined) throw new Error('decision must be "release" or "keep"')
|
|
648
|
+
let reply
|
|
649
|
+
try {
|
|
650
|
+
reply = await client.reply({ requestId, owner, decision })
|
|
651
|
+
} catch (error) {
|
|
652
|
+
const code = codeOf(error)
|
|
653
|
+
if (code === 'expired') throw new Error(`Request ${requestId} has expired and ownership is unchanged. The requester can ask again later if it still needs the path.`)
|
|
654
|
+
if (code === 'stale') throw new Error(`Request ${requestId} is stale: ownership changed before the answer arrived, so nothing was transferred.`)
|
|
655
|
+
if (code === 'not-holder') throw new Error(`Request ${requestId} is not addressed to this session; only the current holder can answer it.`)
|
|
656
|
+
if (isTransport(error)) return presentDegraded(unavailable(error, 'the answer was not delivered'), { requestId, decision })
|
|
657
|
+
throw error
|
|
658
|
+
}
|
|
659
|
+
const state = text(reply?.state) ?? 'declined'
|
|
660
|
+
if (state === 'transferred') {
|
|
661
|
+
// Ownership moved: the caller may have more tool calls queued for that
|
|
662
|
+
// path, so this reaches the model as a message rather than only as a
|
|
663
|
+
// result it may have already reasoned past.
|
|
664
|
+
deliverNotice(exec?.agent, `Ownership handed over: request ${requestId} is settled and this session no longer holds that path. Claim it again with edit_lock_acquire if you still need it.`)
|
|
665
|
+
}
|
|
666
|
+
return present({
|
|
667
|
+
request_id: requestId,
|
|
668
|
+
decision,
|
|
669
|
+
state,
|
|
670
|
+
message: state === 'transferred'
|
|
671
|
+
? `Request ${requestId}: transferred. The requester owns the path now; this session no longer holds it.`
|
|
672
|
+
: `Request ${requestId}: declined. Ownership is unchanged and the path is still this session's.`,
|
|
673
|
+
}, { state: state === 'transferred' ? 'transferred' : 'declined', requestId, decision })
|
|
674
|
+
},
|
|
675
|
+
},
|
|
676
|
+
|
|
677
|
+
{
|
|
678
|
+
name: 'edit_lock_status',
|
|
679
|
+
description:
|
|
680
|
+
'Read-only ownership report: the paths this session holds, and the sessions that currently hold paths in this working directory. Grants nothing and changes nothing. Ownership is arbitrated centrally, so the answer covers other processes too.',
|
|
681
|
+
parameters: { type: 'object', properties: {}, required: [] },
|
|
682
|
+
output: leaseOutput('edit_lock_status'),
|
|
683
|
+
async execute(/** @type {any} */ _args, /** @type {any} */ exec) {
|
|
684
|
+
const owner = ownerOf(exec?.agent)
|
|
685
|
+
const cwd = requireCwd(exec)
|
|
686
|
+
let reply
|
|
687
|
+
try {
|
|
688
|
+
reply = await client.status({ scope: cwd })
|
|
689
|
+
} catch (error) {
|
|
690
|
+
if (isTransport(error)) return presentDegraded(unavailable(error, 'no ownership could be read'), {})
|
|
691
|
+
throw error
|
|
692
|
+
}
|
|
693
|
+
const leases = Array.isArray(reply?.leases) ? reply.leases : []
|
|
694
|
+
const at = Date.now()
|
|
695
|
+
const mine = leases.filter((/** @type {any} */ lease) => text(lease?.owner?.id) === owner.id || text(lease?.owner?.session) === owner.session)
|
|
696
|
+
const others = leases.filter((/** @type {any} */ lease) => !mine.includes(lease))
|
|
697
|
+
const lines = []
|
|
698
|
+
/** @type {{ path: string, until: number, retained: boolean }[]} */
|
|
699
|
+
const shownMine = []
|
|
700
|
+
/** @type {{ path: string, label: string, session: string, until: number, lapsed: boolean }[]} */
|
|
701
|
+
const shownOthers = []
|
|
702
|
+
lines.push(mine.length > 0 ? `Held by this session (${mine.length}):` : 'This session holds nothing.')
|
|
703
|
+
for (const lease of mine) {
|
|
704
|
+
const until = retainedUntil(lease)
|
|
705
|
+
const expiresAt = typeof lease?.expiresAt === 'number' ? lease.expiresAt : at
|
|
706
|
+
const validity = until === undefined
|
|
707
|
+
? `valid for about ${secondsUntil(expiresAt, at)}s`
|
|
708
|
+
: `retained until ${instant(until)}`
|
|
709
|
+
shownMine.push({ path: shortName(String(lease?.path ?? ''), cwd), until: until ?? expiresAt, retained: until !== undefined })
|
|
710
|
+
lines.push(`- ${shortName(String(lease?.path ?? ''), cwd)} — ${validity}`)
|
|
711
|
+
}
|
|
712
|
+
if (others.length > 0) {
|
|
713
|
+
lines.push(`Held by other sessions here (${others.length}):`)
|
|
714
|
+
for (const lease of others) {
|
|
715
|
+
const holder = lease?.owner ?? {}
|
|
716
|
+
const label = text(holder.label) ?? 'another session'
|
|
717
|
+
const session = text(holder.session) ?? 'unknown'
|
|
718
|
+
const target = Math.max(typeof lease?.expiresAt === 'number' ? lease.expiresAt : 0, typeof lease?.holdUntil === 'number' ? lease.holdUntil : 0)
|
|
719
|
+
shownOthers.push({ path: shortName(String(lease?.path ?? ''), cwd), label, session, until: target, lapsed: !(target > at) })
|
|
720
|
+
lines.push(`- ${shortName(String(lease?.path ?? ''), cwd)} — "${label}" (session ${session}), ${target > at ? `about ${secondsUntil(target, at)}s left` : 'lapsed, claimable'}`)
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
return present(
|
|
724
|
+
{ mine: mine.map((/** @type {any} */ lease) => shortName(String(lease?.path ?? ''), cwd)), message: lines.join('\n') },
|
|
725
|
+
{ state: 'status', at, mine: shownMine, others: shownOthers },
|
|
726
|
+
)
|
|
727
|
+
},
|
|
728
|
+
},
|
|
729
|
+
|
|
730
|
+
{
|
|
731
|
+
name: WATCH_TOOL,
|
|
732
|
+
description:
|
|
733
|
+
'Wait for the next release of one contested path without polling it. Use it after an acquisition conflict or a declined/expired transfer request. The answer says whether anything is actually pending: a path that is already free or already yours returns that immediately and keeps no subscription. While it is held by another session, this subscribes once for this session and the next release arrives as a message; a release is only an opportunity to call edit_lock_acquire, never a grant of ownership, and another waiting session may claim it first. Repeating the call is idempotent.',
|
|
734
|
+
parameters: {
|
|
735
|
+
type: 'object',
|
|
736
|
+
properties: {
|
|
737
|
+
file_path: { type: 'string', description: 'The contested file to watch for a release, absolute or relative to the session working directory.' },
|
|
738
|
+
},
|
|
739
|
+
required: ['file_path'],
|
|
740
|
+
},
|
|
741
|
+
output: leaseOutput(WATCH_TOOL),
|
|
742
|
+
async execute(/** @type {any} */ args, /** @type {any} */ exec) {
|
|
743
|
+
const owner = ownerOf(exec?.agent)
|
|
744
|
+
const cwd = requireCwd(exec)
|
|
745
|
+
const path = absoluteOne(args?.file_path, cwd)
|
|
746
|
+
const short = shortName(path, cwd)
|
|
747
|
+
// Every answer below carries the watched short path for the card, the
|
|
748
|
+
// degraded ones included (their result object has no path field).
|
|
749
|
+
const settle = (/** @type {any} */ value) => present(value, { ...derivedFields(WATCH_TOOL, value), path: short })
|
|
750
|
+
// Registration comes FIRST (D2): a release that lands between the
|
|
751
|
+
// registration and the authoritative read is caught by the event
|
|
752
|
+
// callback, and one that landed before it is caught by the read below.
|
|
753
|
+
// The opposite order would leave a permanent hole for that release.
|
|
754
|
+
const entry = watches.register(owner.session, path, mount, exec?.agent)
|
|
755
|
+
|
|
756
|
+
/** @type {any} */
|
|
757
|
+
let reply
|
|
758
|
+
try {
|
|
759
|
+
// ONE read per entry: an overlapping call for the same session and
|
|
760
|
+
// path joins this flight instead of running its own, so both calls
|
|
761
|
+
// judge the same answer and the same entry object.
|
|
762
|
+
reply = await watches.shareStatus(entry, () => client.status({ path }))
|
|
763
|
+
} catch (error) {
|
|
764
|
+
// A terminal outcome wins over any failure (D2): a release, a reset or
|
|
765
|
+
// a transfer that landed while the read was in flight IS the answer,
|
|
766
|
+
// and its pending notice must survive. A late rejection may only
|
|
767
|
+
// cancel a plain wait.
|
|
768
|
+
const terminal = terminalOutcome(entry, short)
|
|
769
|
+
if (terminal !== undefined) return settle(terminal)
|
|
770
|
+
// No authoritative answer means no subscription: never leave a
|
|
771
|
+
// record that could wait forever for a release nobody described.
|
|
772
|
+
// The removal is conditional on THIS entry still being active, so a
|
|
773
|
+
// late caller can never cancel a newer episode.
|
|
774
|
+
watches.cancel(owner.session, path, entry)
|
|
775
|
+
if (isTransport(error)) return settle(unavailable(error, 'no release subscription was created'))
|
|
776
|
+
throw error
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
// Same precedence on the success path: the entry may have been consumed
|
|
780
|
+
// or ended while the read was in flight, whatever the read now says.
|
|
781
|
+
const terminal = terminalOutcome(entry, short)
|
|
782
|
+
if (terminal !== undefined) return settle(terminal)
|
|
783
|
+
|
|
784
|
+
const leases = Array.isArray(reply?.leases) ? reply.leases : []
|
|
785
|
+
if (leases.length === 0) {
|
|
786
|
+
watches.cancel(owner.session, path, entry)
|
|
787
|
+
return settle({
|
|
788
|
+
state: 'free',
|
|
789
|
+
file_path: short,
|
|
790
|
+
message: `Nobody holds ${short} right now, so no release subscription was kept. Claim it with edit_lock_acquire (or just edit it) before working on it.`,
|
|
791
|
+
})
|
|
792
|
+
}
|
|
793
|
+
const holder = leases[0]?.owner ?? {}
|
|
794
|
+
const mine = text(holder.id) === owner.id || text(holder.session) === owner.session
|
|
795
|
+
if (mine) {
|
|
796
|
+
// `self` is terminal, not merely cancelled: an overlapping call that
|
|
797
|
+
// shares this entry must read the same verdict instead of reporting a
|
|
798
|
+
// wait that no longer exists.
|
|
799
|
+
watches.cancel(owner.session, path, entry, 'self')
|
|
800
|
+
return settle({
|
|
801
|
+
state: 'self',
|
|
802
|
+
file_path: short,
|
|
803
|
+
message: `This session already owns ${short}, so no release subscription was kept and there is nothing to wait for.`,
|
|
804
|
+
})
|
|
805
|
+
}
|
|
806
|
+
if (typeof client.supportsEvent !== 'function' || client.supportsEvent('released') !== true) {
|
|
807
|
+
watches.cancel(owner.session, path, entry)
|
|
808
|
+
return settle({
|
|
809
|
+
degraded: true,
|
|
810
|
+
code: 'release-events-unsupported',
|
|
811
|
+
message: `The connected arbiter does not announce release events (release-events-unsupported), so no release subscription was created for ${short}. Only this subscription is unavailable: conflicts, managed edits and negotiation behave exactly as before, and this tool can be used again once the arbiter restarts with the capability.`,
|
|
812
|
+
})
|
|
813
|
+
}
|
|
814
|
+
// `watching` is only claimed while THIS entry is still the active one:
|
|
815
|
+
// an overlapping call or a disposal may have ended the wait while the
|
|
816
|
+
// read was in flight, and a stale entry must never read as a live wait.
|
|
817
|
+
if (!stillWatching(watches, entry)) return settle(terminalOutcome(entry, short) ?? cancelledOutcome(short))
|
|
818
|
+
const label = text(holder.label) ?? 'another session'
|
|
819
|
+
const session = text(holder.session) ?? 'unknown'
|
|
820
|
+
return settle({
|
|
821
|
+
state: 'watching',
|
|
822
|
+
file_path: short,
|
|
823
|
+
holder: { label, session },
|
|
824
|
+
message: `Watching ${short} for its next release; "${label}" (session ${session}) holds it now. A release notice will arrive as a message: it means only that the path is free to compete for, so call edit_lock_acquire then. It is not an ownership grant and it reserves nothing — another waiting session may claim the path first, and the ordinary holder conflict applies.`,
|
|
825
|
+
})
|
|
826
|
+
},
|
|
827
|
+
},
|
|
828
|
+
]
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
/**
|
|
832
|
+
* The one non-verdict answer: the arbiter could not be reached, so nothing was
|
|
833
|
+
* decided. It is explicitly not a refusal — managed edits proceed unowned while
|
|
834
|
+
* the arbiter is down (D7), and telling the model otherwise would stop work for
|
|
835
|
+
* an infrastructure blip.
|
|
836
|
+
* @param {any} error
|
|
837
|
+
* @param {string} outcome
|
|
838
|
+
* @returns {{ degraded: true, code: string, message: string }}
|
|
839
|
+
*/
|
|
840
|
+
function unavailable(error, outcome) {
|
|
841
|
+
const code = codeOf(error)
|
|
842
|
+
return {
|
|
843
|
+
degraded: true,
|
|
844
|
+
code,
|
|
845
|
+
message: `The ownership arbiter is unreachable (${code}), so ${outcome}. This is not a conflict: managed edits proceed without ownership while the arbiter is down, and nothing needs clearing afterwards. Continue with the work.`,
|
|
846
|
+
}
|
|
847
|
+
}
|