weir-harness 1.2.3 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/cordis.patch.yml +7 -18
- package/lib/client.file-lease-badge.js +307 -0
- package/lib/client.js +20 -30
- package/lib/client.settings-page.js +10 -34
- package/package.json +2 -2
- package/src/capabilities/store/checked-contract.d.ts +2 -2
- package/src/capabilities/store/fs-adapter.js +2 -3
- package/src/context-guard/index.js +2 -2
- package/src/delegate/supervision-mount.js +5 -5
- package/src/delegate/tool.js +2 -10
- package/src/file-lease/client.js +1066 -0
- package/src/file-lease/index.js +30 -0
- package/src/file-lease/lineage.js +207 -0
- package/src/file-lease/node-contracts.d.ts +42 -0
- package/src/file-lease/plugin.js +789 -0
- package/src/file-lease/singleton.js +155 -0
- package/src/file-lease/spawn.js +62 -0
- package/src/file-lease/tools.js +459 -0
- package/src/file-lease/view.js +144 -0
- package/src/hashline-edit/index.js +76 -42
- package/src/hashline-edit/write-tool.js +90 -0
- package/src/lease-broker/entry.js +98 -0
- package/src/lease-broker/node-contracts.d.ts +63 -0
- package/src/lease-broker/protocol.js +236 -0
- package/src/lease-broker/server.js +399 -0
- package/src/lease-broker/state.js +425 -0
- package/src/lsp/tools.js +137 -27
- package/src/settings/index.js +2 -44
- package/src/settings/sections.js +2 -24
- package/src/shared/audit.js +6 -1
- package/src/todo-driver/index.js +1 -6
- package/src/worktree/cards.js +6 -21
- package/src/worktree/lane-core.js +1 -19
- package/src/worktree/lane-workflows.js +11 -32
- package/lib/client.edit-lock-maintenance.js +0 -592
- package/lib/client.edit-lock-panel.js +0 -405
- package/src/edit-lock/adapter.js +0 -57
- package/src/edit-lock/admin-ledger.js +0 -149
- package/src/edit-lock/admin-recovery.js +0 -151
- package/src/edit-lock/admission.js +0 -82
- package/src/edit-lock/binding-attestation.js +0 -29
- package/src/edit-lock/binding-guidance.js +0 -72
- package/src/edit-lock/binding-machine.js +0 -312
- package/src/edit-lock/call-context.js +0 -65
- package/src/edit-lock/derived-facts.js +0 -67
- package/src/edit-lock/domain.js +0 -165
- package/src/edit-lock/domains.js +0 -170
- package/src/edit-lock/host.js +0 -149
- package/src/edit-lock/incarnations.js +0 -150
- package/src/edit-lock/index.js +0 -1303
- package/src/edit-lock/inspect.js +0 -219
- package/src/edit-lock/lifecycle.js +0 -579
- package/src/edit-lock/limits.js +0 -61
- package/src/edit-lock/maintenance.js +0 -310
- package/src/edit-lock/manager.js +0 -875
- package/src/edit-lock/negotiation.js +0 -132
- package/src/edit-lock/operation-history.js +0 -214
- package/src/edit-lock/peer-client.js +0 -86
- package/src/edit-lock/peer-transport.js +0 -76
- package/src/edit-lock/peer.js +0 -87
- package/src/edit-lock/publisher.js +0 -191
- package/src/edit-lock/read-authority.js +0 -101
- package/src/edit-lock/recovery.js +0 -139
- package/src/edit-lock/remote.js +0 -362
- package/src/edit-lock/request-binding.js +0 -42
- package/src/edit-lock/request-data.js +0 -40
- package/src/edit-lock/reservation.js +0 -301
- package/src/edit-lock/reserved-runtime.js +0 -45
- package/src/edit-lock/resource-identity.js +0 -130
- package/src/edit-lock/runtime.js +0 -145
- package/src/edit-lock/settle.js +0 -144
- package/src/edit-lock/snapshot.js +0 -175
- package/src/edit-lock/stale-sweep.js +0 -120
- package/src/edit-lock/state.js +0 -462
- package/src/edit-lock/store.js +0 -327
- package/src/edit-lock/tool-scope.js +0 -29
- package/src/edit-lock/view.js +0 -170
- package/src/edit-lock/write-scopes.js +0 -48
- package/src/edit-lock/write-tool.js +0 -45
- package/src/shared/delegate-edit-lifecycle.js +0 -36
- package/src/worktree/authority.js +0 -56
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// The process-wide lease client and the wiring the managed editors share
|
|
2
|
+
// (design D8, D9, D11).
|
|
3
|
+
//
|
|
4
|
+
// ONE client per endpoint for the whole process, created lazily on first use and
|
|
5
|
+
// NEVER closed: two preset mounts of this bundle share one connection and one
|
|
6
|
+
// lease ledger, so unmounting a mount must not rip the connection out from under
|
|
7
|
+
// its sibling. There is deliberately no reference counting and no dispose hook —
|
|
8
|
+
// the client's lifetime is the process's, and its own idle window plus the
|
|
9
|
+
// daemon's disconnect rule are what end leases.
|
|
10
|
+
//
|
|
11
|
+
// `createEditorLease` is the second half of the same wiring: which client, who
|
|
12
|
+
// the caller is, and which opaque scope string it reports. It is here (rather
|
|
13
|
+
// than repeated in `hashline-edit` and `lsp`) because design D9 makes owner
|
|
14
|
+
// identity a single rule — a delegated child inherits its spawner's lineage, so
|
|
15
|
+
// it never conflicts with the session that spawned it. Two copies of that rule
|
|
16
|
+
// would drift, and the drift would only show up as a stuck delegation chain.
|
|
17
|
+
//
|
|
18
|
+
// Nothing here connects, spawns or audits on import: creating a client allocates
|
|
19
|
+
// state, and its first request is what dials.
|
|
20
|
+
|
|
21
|
+
import process from 'node:process'
|
|
22
|
+
import { createLeaseClient } from './client.js'
|
|
23
|
+
import { createLineageResolver } from './lineage.js'
|
|
24
|
+
import { defaultSocketPath } from '../lease-broker/server.js'
|
|
25
|
+
import { PROTOCOL } from '../lease-broker/protocol.js'
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Opaque scope used when neither the session nor the call resolved a root. It is
|
|
29
|
+
* a `status` filter label and never an authority (design D8): reporting `'/'`
|
|
30
|
+
* for a composition with no cwd beats refusing to acquire a lease.
|
|
31
|
+
*/
|
|
32
|
+
const FALLBACK_SCOPE = '/'
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Clients this process has created, keyed by endpoint. Never emptied — see the
|
|
36
|
+
* module comment. The map is the whole "singleton": keyed by socket path, so two
|
|
37
|
+
* protocol majors (or a test's private endpoint) never share a client.
|
|
38
|
+
* @type {Map<string, any>}
|
|
39
|
+
*/
|
|
40
|
+
const clients = new Map()
|
|
41
|
+
|
|
42
|
+
/** @param {unknown} value @returns {string | undefined} */
|
|
43
|
+
function text(value) {
|
|
44
|
+
return typeof value === 'string' && value.length > 0 ? value : undefined
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The endpoint this bundle's protocol major arbitrates on.
|
|
49
|
+
*
|
|
50
|
+
* `WEIR_EDITLOCKD_SOCKET` wins when set. The daemon entry reads the same
|
|
51
|
+
* variable, so a composer that owns a private root (the integration harness) can
|
|
52
|
+
* move BOTH halves to one private socket with a single knob; leaving it unset
|
|
53
|
+
* keeps the machine-level per-user default. Nothing else consults the variable.
|
|
54
|
+
* @returns {string}
|
|
55
|
+
*/
|
|
56
|
+
export function defaultLeaseEndpoint() {
|
|
57
|
+
const override = text(process.env.WEIR_EDITLOCKD_SOCKET)
|
|
58
|
+
if (override !== undefined) return override
|
|
59
|
+
return defaultSocketPath({ protocol: PROTOCOL })
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The process-wide client for one endpoint (design D11).
|
|
64
|
+
*
|
|
65
|
+
* The FIRST call creates it; every later call for the same endpoint returns that
|
|
66
|
+
* same instance, whatever overrides it passes — the connection and the lease
|
|
67
|
+
* ledger are process state, not per-mount state. Creation itself touches
|
|
68
|
+
* nothing: the client dials (and spawns) on its first request.
|
|
69
|
+
*
|
|
70
|
+
* @param {any} [overrides] `createLeaseClient` options; `socketPath` selects the endpoint
|
|
71
|
+
* @returns {any} the shared client
|
|
72
|
+
*/
|
|
73
|
+
export function getSharedLeaseClient(overrides = {}) {
|
|
74
|
+
const socketPath = text(overrides?.socketPath) ?? defaultLeaseEndpoint()
|
|
75
|
+
const existing = clients.get(socketPath)
|
|
76
|
+
if (existing !== undefined) return existing
|
|
77
|
+
const created = createLeaseClient({ ...overrides, socketPath })
|
|
78
|
+
clients.set(socketPath, created)
|
|
79
|
+
return created
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* @typedef {object} LeaseOwner
|
|
84
|
+
* @property {string} id the lineage (opaque to the arbiter)
|
|
85
|
+
* @property {string} session the session that last touched the lease
|
|
86
|
+
* @property {string} label presentation only
|
|
87
|
+
*/
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* @typedef {object} EditorLease
|
|
91
|
+
* @property {() => any} client the client to speak through (injected, else the process-wide one)
|
|
92
|
+
* @property {(agent: any) => LeaseOwner} ownerOf who the calling agent writes as
|
|
93
|
+
* @property {(agent: any, fallback?: string) => string} scopeOf the opaque project root to report
|
|
94
|
+
*/
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The `sessions` registry, read defensively: a cold or partially mounted host has
|
|
98
|
+
* none, and the resolver degrades to "this session is its own lineage".
|
|
99
|
+
* @param {any} ctx
|
|
100
|
+
* @returns {{ get?: (id: string) => any } | undefined}
|
|
101
|
+
*/
|
|
102
|
+
function sessionsOf(ctx) {
|
|
103
|
+
try {
|
|
104
|
+
return ctx?.get?.('sessions') ?? undefined
|
|
105
|
+
} catch {
|
|
106
|
+
return undefined
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* The session this agent acts for: the durable header id when there is one, else
|
|
112
|
+
* the live session id, else the agent id. `undefined` only for an agent object
|
|
113
|
+
* with no identity at all — the caller then reports the lineage as the session.
|
|
114
|
+
* @param {any} agent
|
|
115
|
+
* @returns {string | undefined}
|
|
116
|
+
*/
|
|
117
|
+
function sessionIdOf(agent) {
|
|
118
|
+
return text(agent?.session?.header?.id) ?? text(agent?.session?.id) ?? text(agent?.id)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Create the lease wiring for one managed editor.
|
|
123
|
+
*
|
|
124
|
+
* `leaseClient` is the injection seam tests (and any future embedder) use; when
|
|
125
|
+
* it is absent the process-wide client is resolved lazily on the first managed
|
|
126
|
+
* write, so a composition that never writes never constructs one.
|
|
127
|
+
*
|
|
128
|
+
* @param {{ leaseClient?: any, ctx?: any }} [options]
|
|
129
|
+
* @returns {EditorLease}
|
|
130
|
+
*/
|
|
131
|
+
export function createEditorLease({ leaseClient, ctx } = {}) {
|
|
132
|
+
/** @type {any} */
|
|
133
|
+
const injected = leaseClient ?? null
|
|
134
|
+
/** @type {any} */
|
|
135
|
+
let shared = null
|
|
136
|
+
const resolver = createLineageResolver({ sessions: sessionsOf(ctx) })
|
|
137
|
+
return Object.freeze({
|
|
138
|
+
client() {
|
|
139
|
+
if (injected !== null) return injected
|
|
140
|
+
if (shared === null) shared = getSharedLeaseClient()
|
|
141
|
+
return shared
|
|
142
|
+
},
|
|
143
|
+
ownerOf(agent) {
|
|
144
|
+
const lineage = resolver.lineageOf(agent)
|
|
145
|
+
return {
|
|
146
|
+
id: lineage,
|
|
147
|
+
session: sessionIdOf(agent) ?? lineage,
|
|
148
|
+
label: resolver.labelOf(agent),
|
|
149
|
+
}
|
|
150
|
+
},
|
|
151
|
+
scopeOf(agent, fallback) {
|
|
152
|
+
return text(agent?.session?.header?.cwd) ?? text(fallback) ?? FALLBACK_SCOPE
|
|
153
|
+
},
|
|
154
|
+
})
|
|
155
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// Start `weir-editlockd` as a detached child of this process.
|
|
2
|
+
//
|
|
3
|
+
// This is the only place in the client half that touches the process table, and
|
|
4
|
+
// it is deliberately dumb: fire, forget, never wait, never throw. A `null`
|
|
5
|
+
// return is a normal outcome — the binary may be gone, the socket directory may
|
|
6
|
+
// be unwritable, or the platform may refuse the spawn — and the caller answers
|
|
7
|
+
// it by degrading the write instead of blocking it (design D7).
|
|
8
|
+
//
|
|
9
|
+
// Why `ELECTRON_RUN_AS_NODE=1` (do not remove):
|
|
10
|
+
//
|
|
11
|
+
// The host CLI starts DSH as `ELECTRON_RUN_AS_NODE=1 exec "DeepSeek Harness"
|
|
12
|
+
// cli.js`, so inside a live session `process.execPath` is the Electron binary,
|
|
13
|
+
// not `node`. Launching a script with it without that variable boots a second
|
|
14
|
+
// Electron app instead of running our daemon. Under a plain Node binary the
|
|
15
|
+
// variable is a harmless no-op, which is why the tests can use this same module
|
|
16
|
+
// unchanged.
|
|
17
|
+
//
|
|
18
|
+
// `detached` + `unref()` (rather than `stdio: inherit`) is what makes the
|
|
19
|
+
// daemon outlive the caller: the client is not this daemon's supervisor, and
|
|
20
|
+
// the daemon's own idle window is the only thing that ends it.
|
|
21
|
+
|
|
22
|
+
import { spawn } from 'node:child_process'
|
|
23
|
+
import process, { execPath } from 'node:process'
|
|
24
|
+
import { fileURLToPath } from 'node:url'
|
|
25
|
+
|
|
26
|
+
/** The daemon entry shipped next to this module's package. */
|
|
27
|
+
const DEFAULT_ENTRY_URL = new URL('../lease-broker/entry.js', import.meta.url)
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* @typedef {object} SpawnDaemonOptions
|
|
31
|
+
* @property {URL | string} [entryUrl] executable script; defaults to `../lease-broker/entry.js`
|
|
32
|
+
* @property {Record<string, string | undefined>} [env] extra environment (socket path, idle window)
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Spawn the lease broker daemon and forget about it.
|
|
37
|
+
*
|
|
38
|
+
* Never throws and never resolves anything: the caller learns "a daemon may now
|
|
39
|
+
* be starting" (truthy) or "no daemon was started" (`null`) and re-tries the
|
|
40
|
+
* socket either way. Failures are intentionally silent — an unstartable daemon
|
|
41
|
+
* is an infrastructure degrade, not an error the user has to see or fix.
|
|
42
|
+
*
|
|
43
|
+
* @param {SpawnDaemonOptions} [options]
|
|
44
|
+
* @returns {import('node:child_process').ChildProcess | null} the detached child, or `null` when the spawn failed
|
|
45
|
+
*/
|
|
46
|
+
export function spawnDaemon({ entryUrl = DEFAULT_ENTRY_URL, env } = {}) {
|
|
47
|
+
try {
|
|
48
|
+
const entry = fileURLToPath(entryUrl)
|
|
49
|
+
const child = spawn(execPath, [entry], {
|
|
50
|
+
env: { ...process.env, ELECTRON_RUN_AS_NODE: '1', ...env },
|
|
51
|
+
detached: true,
|
|
52
|
+
stdio: 'ignore',
|
|
53
|
+
windowsHide: true,
|
|
54
|
+
})
|
|
55
|
+
// The client is not the daemon's supervisor: nothing here may keep this
|
|
56
|
+
// process alive, and nothing here waits for the daemon to become reachable.
|
|
57
|
+
child.unref()
|
|
58
|
+
return child
|
|
59
|
+
} catch {
|
|
60
|
+
return null
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -0,0 +1,459 @@
|
|
|
1
|
+
// The model-facing tool surface of the file-lease feature (design D4, D5, D13).
|
|
2
|
+
//
|
|
3
|
+
// Six 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
|
+
import { basename, isAbsolute, relative, resolve } from 'node:path'
|
|
23
|
+
import { isLeaseConflict } from './client.js'
|
|
24
|
+
|
|
25
|
+
/** Retention bound in minutes; the protocol clamps here and so does the tool. */
|
|
26
|
+
export const MAX_HOLD_MINUTES = 60
|
|
27
|
+
|
|
28
|
+
/** The negotiation reply tool: registered only while a request is pending. */
|
|
29
|
+
export const REPLY_TOOL = 'edit_lock_reply'
|
|
30
|
+
|
|
31
|
+
/** Default retention when the model asks for one without a length. */
|
|
32
|
+
export const DEFAULT_HOLD_MINUTES = MAX_HOLD_MINUTES
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Single-field text rendering, the shape every tool in this bundle uses.
|
|
36
|
+
* @param {string} field
|
|
37
|
+
*/
|
|
38
|
+
function textOutput(field) {
|
|
39
|
+
return {
|
|
40
|
+
schema: { type: 'object' },
|
|
41
|
+
render: (/** @type {any} */ _args, /** @type {any} */ result) => [{ type: 'text', text: result[field] }],
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** @param {string} description */
|
|
46
|
+
function pathList(description) {
|
|
47
|
+
return { type: 'array', items: { type: 'string' }, minItems: 1, description }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** @param {unknown} value @returns {string | undefined} */
|
|
51
|
+
function text(value) {
|
|
52
|
+
return typeof value === 'string' && value.length > 0 ? value : undefined
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** A broker refusal code, or `internal` when the failure carries none. @param {any} error */
|
|
56
|
+
function codeOf(error) {
|
|
57
|
+
const code = error && typeof error === 'object' ? /** @type {any} */ (error).code : undefined
|
|
58
|
+
return typeof code === 'string' ? code : 'internal'
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** True for the one failure class that is infrastructure, not a verdict (D7). */
|
|
62
|
+
function isTransport(error) {
|
|
63
|
+
return codeOf(error) === 'transport'
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The session working directory, which is both the resolution base for short
|
|
68
|
+
* paths and the opaque project scope the arbiter only ever uses for `status`.
|
|
69
|
+
* @param {any} exec
|
|
70
|
+
* @returns {string}
|
|
71
|
+
*/
|
|
72
|
+
function requireCwd(exec) {
|
|
73
|
+
const cwd = text(exec?.agent?.session?.header?.cwd)
|
|
74
|
+
if (cwd === undefined) throw new Error('the session working directory is unavailable, so paths cannot be resolved')
|
|
75
|
+
return cwd
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Render a path the way the model thinks about it: relative to the session cwd,
|
|
80
|
+
* or as a bare name when it lives outside. A resolved absolute path is accepted
|
|
81
|
+
* on the way in — that symmetry is what makes the model's own absolute path
|
|
82
|
+
* echo readable instead of pasted verbatim.
|
|
83
|
+
* @param {string} path
|
|
84
|
+
* @param {string | undefined} cwd
|
|
85
|
+
* @returns {string}
|
|
86
|
+
*/
|
|
87
|
+
export function shortName(path, cwd) {
|
|
88
|
+
const name = basename(path)
|
|
89
|
+
if (cwd === undefined) return name
|
|
90
|
+
const rel = relative(cwd, path)
|
|
91
|
+
if (rel === '' || rel === '..' || rel.startsWith('../') || rel.startsWith('..\\')) return name
|
|
92
|
+
return rel
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Resolve the model's path batch to the absolute, de-duplicated list the
|
|
97
|
+
* protocol accepts.
|
|
98
|
+
* @param {unknown} paths
|
|
99
|
+
* @param {string} cwd
|
|
100
|
+
* @returns {string[]}
|
|
101
|
+
*/
|
|
102
|
+
function absoluteBatch(paths, cwd) {
|
|
103
|
+
if (!Array.isArray(paths) || paths.length === 0) throw new Error('paths must list at least one path')
|
|
104
|
+
/** @type {string[]} */
|
|
105
|
+
const out = []
|
|
106
|
+
for (const entry of paths) {
|
|
107
|
+
const raw = text(entry)
|
|
108
|
+
if (raw === undefined) throw new Error('every entry of paths must be a non-empty string')
|
|
109
|
+
const absolute = isAbsolute(raw) ? raw : resolve(cwd, raw)
|
|
110
|
+
if (!out.includes(absolute)) out.push(absolute)
|
|
111
|
+
}
|
|
112
|
+
return out
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* @param {unknown} filePath
|
|
117
|
+
* @param {string} cwd
|
|
118
|
+
* @returns {string}
|
|
119
|
+
*/
|
|
120
|
+
function absoluteOne(filePath, cwd) {
|
|
121
|
+
const raw = text(filePath)
|
|
122
|
+
if (raw === undefined) throw new Error('file_path must be a non-empty string')
|
|
123
|
+
return isAbsolute(raw) ? raw : resolve(cwd, raw)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** `2026-05-05T12:00:00.000Z` for a validity instant. @param {number} at epoch ms */
|
|
127
|
+
function instant(at) {
|
|
128
|
+
return new Date(at).toISOString()
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Remaining validity in whole seconds, rounded up so a positive window never
|
|
133
|
+
* renders as "0s" and a lapsed one never renders as still valid.
|
|
134
|
+
* @param {number} target epoch ms
|
|
135
|
+
* @param {number} at epoch ms
|
|
136
|
+
* @returns {number}
|
|
137
|
+
*/
|
|
138
|
+
function secondsUntil(target, at) {
|
|
139
|
+
return Math.max(0, Math.ceil((target - at) / 1000))
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Whether a row carries a retention deadline beyond its plain validity. */
|
|
143
|
+
function retainedUntil(/** @type {any} */ row) {
|
|
144
|
+
const expiresAt = typeof row?.expiresAt === 'number' ? row.expiresAt : 0
|
|
145
|
+
const holdUntil = typeof row?.holdUntil === 'number' ? row.holdUntil : 0
|
|
146
|
+
return holdUntil > expiresAt ? holdUntil : undefined
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* The refusal text for a `held` verdict. The obligations of the spec's "Refusal
|
|
151
|
+
* names the holder" and "Refusal offers no human step" scenarios are encoded
|
|
152
|
+
* here and nowhere else, so the wording cannot drift between the tools that can
|
|
153
|
+
* hit a conflict — including the managed editors that will acquire leases
|
|
154
|
+
* instead of calling a tool, which is why this is exported.
|
|
155
|
+
* @param {any} error a `held` LeaseFailure carrying `conflicts`
|
|
156
|
+
* @param {string} cwd
|
|
157
|
+
* @param {number} at epoch ms
|
|
158
|
+
* @returns {string}
|
|
159
|
+
*/
|
|
160
|
+
export function describeConflicts(error, cwd, at) {
|
|
161
|
+
const conflicts = Array.isArray(error?.conflicts) ? error.conflicts : []
|
|
162
|
+
const lines = conflicts.map((/** @type {any} */ conflict) => {
|
|
163
|
+
const holder = conflict?.holder ?? {}
|
|
164
|
+
const label = text(holder.label) ?? 'another session'
|
|
165
|
+
const session = text(holder.session) ?? 'unknown'
|
|
166
|
+
const target = Math.max(typeof conflict?.expiresAt === 'number' ? conflict.expiresAt : 0, typeof conflict?.holdUntil === 'number' ? conflict.holdUntil : 0)
|
|
167
|
+
return `- ${shortName(String(conflict?.path ?? ''), cwd)} is held by "${label}" (session ${session}) for about ${secondsUntil(target, at)}s more`
|
|
168
|
+
})
|
|
169
|
+
const headline = conflicts.length > 0
|
|
170
|
+
? `Refused: ${conflicts.length} of the requested path(s) are owned by another session.`
|
|
171
|
+
: 'Refused: another session owns one or more of the requested paths.'
|
|
172
|
+
return [
|
|
173
|
+
headline,
|
|
174
|
+
...lines,
|
|
175
|
+
'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.',
|
|
176
|
+
'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).',
|
|
177
|
+
].join('\n')
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Create the tool definitions.
|
|
182
|
+
*
|
|
183
|
+
* @param {object} options
|
|
184
|
+
* @param {any} options.client the lease client (or a facade with the same surface)
|
|
185
|
+
* @param {(agent: any) => { id: string, session: string, label: string }} options.ownerOf
|
|
186
|
+
* @param {(agent: any, text: string) => any} [options.deliverNotice] model-facing
|
|
187
|
+
* notice channel, used only where a tool result cannot carry the outcome
|
|
188
|
+
* @returns {any[]} six definitions, `REPLY_TOOL` included (the plugin decides
|
|
189
|
+
* when that one becomes visible)
|
|
190
|
+
*/
|
|
191
|
+
export function createLeaseTools({ client, ownerOf, deliverNotice = () => {} }) {
|
|
192
|
+
return [
|
|
193
|
+
{
|
|
194
|
+
name: 'edit_lock_acquire',
|
|
195
|
+
description:
|
|
196
|
+
'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.',
|
|
197
|
+
parameters: {
|
|
198
|
+
type: 'object',
|
|
199
|
+
properties: {
|
|
200
|
+
paths: pathList('Paths to claim, absolute or relative to the session working directory.'),
|
|
201
|
+
},
|
|
202
|
+
required: ['paths'],
|
|
203
|
+
},
|
|
204
|
+
output: textOutput('message'),
|
|
205
|
+
async execute(/** @type {any} */ args, /** @type {any} */ exec) {
|
|
206
|
+
const owner = ownerOf(exec?.agent)
|
|
207
|
+
const cwd = requireCwd(exec)
|
|
208
|
+
const paths = absoluteBatch(args?.paths, cwd)
|
|
209
|
+
try {
|
|
210
|
+
const reply = await client.acquire(owner, paths, cwd)
|
|
211
|
+
const granted = Array.isArray(reply?.granted) ? reply.granted : []
|
|
212
|
+
const claimed = granted.map((/** @type {any} */ lease) => shortName(String(lease?.path ?? ''), cwd))
|
|
213
|
+
return {
|
|
214
|
+
paths: claimed,
|
|
215
|
+
message: claimed.length > 0
|
|
216
|
+
? `Claimed ${claimed.length} path(s) for this turn: ${claimed.join(', ')}.`
|
|
217
|
+
: 'Nothing was claimed.',
|
|
218
|
+
}
|
|
219
|
+
} catch (error) {
|
|
220
|
+
if (isLeaseConflict(error)) throw new Error(describeConflicts(error, cwd, Date.now()))
|
|
221
|
+
if (isTransport(error)) return unavailable(error, 'nothing was claimed')
|
|
222
|
+
throw error
|
|
223
|
+
}
|
|
224
|
+
},
|
|
225
|
+
},
|
|
226
|
+
|
|
227
|
+
{
|
|
228
|
+
name: 'edit_lock_release',
|
|
229
|
+
description:
|
|
230
|
+
'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.',
|
|
231
|
+
parameters: {
|
|
232
|
+
type: 'object',
|
|
233
|
+
properties: {
|
|
234
|
+
paths: pathList('Paths to release, absolute or relative to the session working directory.'),
|
|
235
|
+
},
|
|
236
|
+
required: ['paths'],
|
|
237
|
+
},
|
|
238
|
+
output: textOutput('message'),
|
|
239
|
+
async execute(/** @type {any} */ args, /** @type {any} */ exec) {
|
|
240
|
+
const owner = ownerOf(exec?.agent)
|
|
241
|
+
const cwd = requireCwd(exec)
|
|
242
|
+
const paths = absoluteBatch(args?.paths, cwd)
|
|
243
|
+
let reply
|
|
244
|
+
try {
|
|
245
|
+
reply = await client.request('release', { owner, paths })
|
|
246
|
+
} catch (error) {
|
|
247
|
+
if (isTransport(error)) return unavailable(error, 'the release was not confirmed')
|
|
248
|
+
throw error
|
|
249
|
+
}
|
|
250
|
+
const released = (Array.isArray(reply?.released) ? reply.released : []).map((/** @type {any} */ path) => shortName(String(path), cwd))
|
|
251
|
+
const notOwned = (Array.isArray(reply?.notOwned) ? reply.notOwned : []).map((/** @type {any} */ path) => shortName(String(path), cwd))
|
|
252
|
+
const lines = []
|
|
253
|
+
lines.push(released.length > 0 ? `Released ${released.length} path(s): ${released.join(', ')}.` : 'Released nothing.')
|
|
254
|
+
if (notOwned.length > 0) lines.push(`Not held by this session, so there was nothing to release: ${notOwned.join(', ')}.`)
|
|
255
|
+
return { released, notOwned, message: lines.join('\n') }
|
|
256
|
+
},
|
|
257
|
+
},
|
|
258
|
+
|
|
259
|
+
{
|
|
260
|
+
name: 'edit_lock_hold',
|
|
261
|
+
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 and nothing is announced.`,
|
|
262
|
+
parameters: {
|
|
263
|
+
type: 'object',
|
|
264
|
+
properties: {
|
|
265
|
+
paths: pathList('Paths to keep, absolute or relative to the session working directory. They must already be owned.'),
|
|
266
|
+
minutes: {
|
|
267
|
+
type: 'number',
|
|
268
|
+
description: `How long to keep them, counted from now; clamped to ${MAX_HOLD_MINUTES}. Omit for the full ${DEFAULT_HOLD_MINUTES} minutes.`,
|
|
269
|
+
},
|
|
270
|
+
},
|
|
271
|
+
required: ['paths'],
|
|
272
|
+
},
|
|
273
|
+
output: textOutput('message'),
|
|
274
|
+
async execute(/** @type {any} */ args, /** @type {any} */ exec) {
|
|
275
|
+
const owner = ownerOf(exec?.agent)
|
|
276
|
+
const cwd = requireCwd(exec)
|
|
277
|
+
const paths = absoluteBatch(args?.paths, cwd)
|
|
278
|
+
const requested = args?.minutes === undefined || args?.minutes === null ? DEFAULT_HOLD_MINUTES : Number(args.minutes)
|
|
279
|
+
if (!Number.isFinite(requested) || requested <= 0) throw new Error('minutes must be a positive number of minutes')
|
|
280
|
+
const minutes = Math.min(MAX_HOLD_MINUTES, requested)
|
|
281
|
+
let reply
|
|
282
|
+
try {
|
|
283
|
+
reply = await client.hold(paths, minutes, owner)
|
|
284
|
+
} catch (error) {
|
|
285
|
+
const code = codeOf(error)
|
|
286
|
+
if (code === 'not-owned') {
|
|
287
|
+
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.`)
|
|
288
|
+
}
|
|
289
|
+
if (isTransport(error)) return unavailable(error, 'no retention was recorded')
|
|
290
|
+
throw error
|
|
291
|
+
}
|
|
292
|
+
const held = Array.isArray(reply?.held) ? reply.held : []
|
|
293
|
+
const at = Date.now()
|
|
294
|
+
const entries = held.map((/** @type {any} */ row) => {
|
|
295
|
+
const until = typeof row?.holdUntil === 'number' ? row.holdUntil : typeof row?.expiresAt === 'number' ? row.expiresAt : at
|
|
296
|
+
return `${shortName(String(row?.path ?? ''), cwd)} until ${instant(until)} (about ${Math.ceil(secondsUntil(until, at) / 60)}m from now)`
|
|
297
|
+
})
|
|
298
|
+
return {
|
|
299
|
+
// Short names only: an absolute path never appears in a tool result,
|
|
300
|
+
// not even in a structured field the model also reads.
|
|
301
|
+
held: held.map((/** @type {any} */ row) => ({ path: shortName(String(row?.path ?? ''), cwd), holdUntil: row?.holdUntil ?? null })),
|
|
302
|
+
message: entries.length > 0
|
|
303
|
+
? `Kept ${entries.length} path(s) past this turn: ${entries.join(', ')}. At most ${MAX_HOLD_MINUTES} minutes per request; a deadline that passes announces nothing — the paths simply become available.`
|
|
304
|
+
: 'Nothing was retained.',
|
|
305
|
+
}
|
|
306
|
+
},
|
|
307
|
+
},
|
|
308
|
+
|
|
309
|
+
{
|
|
310
|
+
name: 'edit_lock_try_steal',
|
|
311
|
+
description:
|
|
312
|
+
'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.',
|
|
313
|
+
parameters: {
|
|
314
|
+
type: 'object',
|
|
315
|
+
properties: {
|
|
316
|
+
file_path: { type: 'string', description: 'The owned file you need, absolute or relative to the session working directory.' },
|
|
317
|
+
},
|
|
318
|
+
required: ['file_path'],
|
|
319
|
+
},
|
|
320
|
+
output: textOutput('message'),
|
|
321
|
+
async execute(/** @type {any} */ args, /** @type {any} */ exec) {
|
|
322
|
+
const owner = ownerOf(exec?.agent)
|
|
323
|
+
const cwd = requireCwd(exec)
|
|
324
|
+
const path = absoluteOne(args?.file_path, cwd)
|
|
325
|
+
const short = shortName(path, cwd)
|
|
326
|
+
let reply
|
|
327
|
+
try {
|
|
328
|
+
reply = await client.requestTransfer(path, owner)
|
|
329
|
+
} catch (error) {
|
|
330
|
+
const code = codeOf(error)
|
|
331
|
+
if (code === 'free') return { 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.` }
|
|
332
|
+
if (code === 'self') return { state: 'self', message: `This session already owns ${short}; no transfer is needed.` }
|
|
333
|
+
if (isTransport(error)) return unavailable(error, 'no transfer request was created')
|
|
334
|
+
throw error
|
|
335
|
+
}
|
|
336
|
+
const requestId = text(reply?.requestId) ?? ''
|
|
337
|
+
const holder = reply?.holder ?? {}
|
|
338
|
+
const label = text(holder.label) ?? 'the holder'
|
|
339
|
+
const session = text(holder.session) ?? 'unknown'
|
|
340
|
+
const expiresAt = typeof reply?.expiresAt === 'number' ? reply.expiresAt : Date.now()
|
|
341
|
+
return {
|
|
342
|
+
state: 'pending',
|
|
343
|
+
request_id: requestId,
|
|
344
|
+
holder: { label, session },
|
|
345
|
+
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.`,
|
|
346
|
+
}
|
|
347
|
+
},
|
|
348
|
+
},
|
|
349
|
+
|
|
350
|
+
{
|
|
351
|
+
name: REPLY_TOOL,
|
|
352
|
+
description:
|
|
353
|
+
'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.',
|
|
354
|
+
parameters: {
|
|
355
|
+
type: 'object',
|
|
356
|
+
properties: {
|
|
357
|
+
request_id: { type: 'string', description: 'The request_id from the ownership request message.' },
|
|
358
|
+
decision: { type: 'string', enum: ['release', 'keep'], description: 'release to hand the path over, keep to hold on to it.' },
|
|
359
|
+
},
|
|
360
|
+
required: ['request_id', 'decision'],
|
|
361
|
+
},
|
|
362
|
+
output: textOutput('message'),
|
|
363
|
+
async execute(/** @type {any} */ args, /** @type {any} */ exec) {
|
|
364
|
+
const owner = ownerOf(exec?.agent)
|
|
365
|
+
const requestId = text(args?.request_id)
|
|
366
|
+
if (requestId === undefined) throw new Error('request_id must be a non-empty string')
|
|
367
|
+
const decision = args?.decision === 'release' ? 'release' : args?.decision === 'keep' ? 'keep' : undefined
|
|
368
|
+
if (decision === undefined) throw new Error('decision must be "release" or "keep"')
|
|
369
|
+
let reply
|
|
370
|
+
try {
|
|
371
|
+
reply = await client.reply({ requestId, owner, decision })
|
|
372
|
+
} catch (error) {
|
|
373
|
+
const code = codeOf(error)
|
|
374
|
+
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.`)
|
|
375
|
+
if (code === 'stale') throw new Error(`Request ${requestId} is stale: ownership changed before the answer arrived, so nothing was transferred.`)
|
|
376
|
+
if (code === 'not-holder') throw new Error(`Request ${requestId} is not addressed to this session; only the current holder can answer it.`)
|
|
377
|
+
if (isTransport(error)) return unavailable(error, 'the answer was not delivered')
|
|
378
|
+
throw error
|
|
379
|
+
}
|
|
380
|
+
const state = text(reply?.state) ?? 'declined'
|
|
381
|
+
if (state === 'transferred') {
|
|
382
|
+
// Ownership moved: the caller may have more tool calls queued for that
|
|
383
|
+
// path, so this reaches the model as a message rather than only as a
|
|
384
|
+
// result it may have already reasoned past.
|
|
385
|
+
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.`)
|
|
386
|
+
}
|
|
387
|
+
return {
|
|
388
|
+
request_id: requestId,
|
|
389
|
+
decision,
|
|
390
|
+
state,
|
|
391
|
+
message: state === 'transferred'
|
|
392
|
+
? `Request ${requestId}: transferred. The requester owns the path now; this session no longer holds it.`
|
|
393
|
+
: `Request ${requestId}: declined. Ownership is unchanged and the path is still this session's.`,
|
|
394
|
+
}
|
|
395
|
+
},
|
|
396
|
+
},
|
|
397
|
+
|
|
398
|
+
{
|
|
399
|
+
name: 'edit_lock_status',
|
|
400
|
+
description:
|
|
401
|
+
'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.',
|
|
402
|
+
parameters: { type: 'object', properties: {}, required: [] },
|
|
403
|
+
output: textOutput('message'),
|
|
404
|
+
async execute(/** @type {any} */ _args, /** @type {any} */ exec) {
|
|
405
|
+
const owner = ownerOf(exec?.agent)
|
|
406
|
+
const cwd = requireCwd(exec)
|
|
407
|
+
let reply
|
|
408
|
+
try {
|
|
409
|
+
reply = await client.status({ scope: cwd })
|
|
410
|
+
} catch (error) {
|
|
411
|
+
if (isTransport(error)) return unavailable(error, 'no ownership could be read')
|
|
412
|
+
throw error
|
|
413
|
+
}
|
|
414
|
+
const leases = Array.isArray(reply?.leases) ? reply.leases : []
|
|
415
|
+
const at = Date.now()
|
|
416
|
+
const mine = leases.filter((/** @type {any} */ lease) => text(lease?.owner?.id) === owner.id || text(lease?.owner?.session) === owner.session)
|
|
417
|
+
const others = leases.filter((/** @type {any} */ lease) => !mine.includes(lease))
|
|
418
|
+
const lines = []
|
|
419
|
+
lines.push(mine.length > 0 ? `Held by this session (${mine.length}):` : 'This session holds nothing.')
|
|
420
|
+
for (const lease of mine) {
|
|
421
|
+
const until = retainedUntil(lease)
|
|
422
|
+
const validity = until === undefined
|
|
423
|
+
? `valid for about ${secondsUntil(typeof lease?.expiresAt === 'number' ? lease.expiresAt : at, at)}s`
|
|
424
|
+
: `retained until ${instant(until)}`
|
|
425
|
+
lines.push(`- ${shortName(String(lease?.path ?? ''), cwd)} — ${validity}`)
|
|
426
|
+
}
|
|
427
|
+
if (others.length > 0) {
|
|
428
|
+
lines.push(`Held by other sessions here (${others.length}):`)
|
|
429
|
+
for (const lease of others) {
|
|
430
|
+
const holder = lease?.owner ?? {}
|
|
431
|
+
const label = text(holder.label) ?? 'another session'
|
|
432
|
+
const session = text(holder.session) ?? 'unknown'
|
|
433
|
+
const target = Math.max(typeof lease?.expiresAt === 'number' ? lease.expiresAt : 0, typeof lease?.holdUntil === 'number' ? lease.holdUntil : 0)
|
|
434
|
+
lines.push(`- ${shortName(String(lease?.path ?? ''), cwd)} — "${label}" (session ${session}), ${target > at ? `about ${secondsUntil(target, at)}s left` : 'lapsed, claimable'}`)
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
return { mine: mine.map((/** @type {any} */ lease) => shortName(String(lease?.path ?? ''), cwd)), message: lines.join('\n') }
|
|
438
|
+
},
|
|
439
|
+
},
|
|
440
|
+
]
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* The one non-verdict answer: the arbiter could not be reached, so nothing was
|
|
445
|
+
* decided. It is explicitly not a refusal — managed edits proceed unowned while
|
|
446
|
+
* the arbiter is down (D7), and telling the model otherwise would stop work for
|
|
447
|
+
* an infrastructure blip.
|
|
448
|
+
* @param {any} error
|
|
449
|
+
* @param {string} outcome
|
|
450
|
+
* @returns {{ degraded: true, code: string, message: string }}
|
|
451
|
+
*/
|
|
452
|
+
function unavailable(error, outcome) {
|
|
453
|
+
const code = codeOf(error)
|
|
454
|
+
return {
|
|
455
|
+
degraded: true,
|
|
456
|
+
code,
|
|
457
|
+
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.`,
|
|
458
|
+
}
|
|
459
|
+
}
|