weir-harness 1.2.2 → 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.
Files changed (89) hide show
  1. package/cordis.patch.yml +7 -18
  2. package/lib/client.file-lease-badge.js +307 -0
  3. package/lib/client.js +20 -30
  4. package/lib/client.settings-page.js +10 -34
  5. package/package.json +2 -2
  6. package/src/capabilities/store/checked-contract.d.ts +2 -2
  7. package/src/capabilities/store/fs-adapter.js +2 -3
  8. package/src/context-guard/index.js +2 -2
  9. package/src/delegate/supervision-mount.js +5 -5
  10. package/src/delegate/tool.js +2 -10
  11. package/src/file-lease/client.js +1066 -0
  12. package/src/file-lease/index.js +30 -0
  13. package/src/file-lease/lineage.js +207 -0
  14. package/src/file-lease/node-contracts.d.ts +42 -0
  15. package/src/file-lease/plugin.js +789 -0
  16. package/src/file-lease/singleton.js +155 -0
  17. package/src/file-lease/spawn.js +62 -0
  18. package/src/file-lease/tools.js +459 -0
  19. package/src/file-lease/view.js +144 -0
  20. package/src/hashline-edit/index.js +76 -42
  21. package/src/hashline-edit/write-tool.js +90 -0
  22. package/src/intent-gate/matcher.js +27 -0
  23. package/src/lease-broker/entry.js +98 -0
  24. package/src/lease-broker/node-contracts.d.ts +63 -0
  25. package/src/lease-broker/protocol.js +236 -0
  26. package/src/lease-broker/server.js +399 -0
  27. package/src/lease-broker/state.js +425 -0
  28. package/src/lsp/admin.js +24 -13
  29. package/src/lsp/index.js +5 -2
  30. package/src/lsp/registry.js +83 -17
  31. package/src/lsp/tools.js +137 -27
  32. package/src/notify/commands.js +15 -2
  33. package/src/notify/index.js +48 -10
  34. package/src/notify/web-channel.js +26 -3
  35. package/src/process/index.js +11 -0
  36. package/src/settings/index.js +2 -44
  37. package/src/settings/sections.js +29 -28
  38. package/src/shared/audit.js +6 -1
  39. package/src/todo-driver/index.js +1 -6
  40. package/src/worktree/cards.js +6 -21
  41. package/src/worktree/lane-core.js +1 -19
  42. package/src/worktree/lane-workflows.js +11 -32
  43. package/lib/client.edit-lock-maintenance.js +0 -592
  44. package/lib/client.edit-lock-panel.js +0 -405
  45. package/src/edit-lock/adapter.js +0 -57
  46. package/src/edit-lock/admin-ledger.js +0 -149
  47. package/src/edit-lock/admin-recovery.js +0 -151
  48. package/src/edit-lock/admission.js +0 -82
  49. package/src/edit-lock/binding-attestation.js +0 -29
  50. package/src/edit-lock/binding-guidance.js +0 -72
  51. package/src/edit-lock/binding-machine.js +0 -312
  52. package/src/edit-lock/call-context.js +0 -65
  53. package/src/edit-lock/derived-facts.js +0 -67
  54. package/src/edit-lock/domain.js +0 -165
  55. package/src/edit-lock/domains.js +0 -170
  56. package/src/edit-lock/host.js +0 -149
  57. package/src/edit-lock/incarnations.js +0 -150
  58. package/src/edit-lock/index.js +0 -1303
  59. package/src/edit-lock/inspect.js +0 -219
  60. package/src/edit-lock/lifecycle.js +0 -579
  61. package/src/edit-lock/limits.js +0 -61
  62. package/src/edit-lock/maintenance.js +0 -310
  63. package/src/edit-lock/manager.js +0 -875
  64. package/src/edit-lock/negotiation.js +0 -132
  65. package/src/edit-lock/operation-history.js +0 -214
  66. package/src/edit-lock/peer-client.js +0 -86
  67. package/src/edit-lock/peer-transport.js +0 -76
  68. package/src/edit-lock/peer.js +0 -87
  69. package/src/edit-lock/publisher.js +0 -191
  70. package/src/edit-lock/read-authority.js +0 -101
  71. package/src/edit-lock/recovery.js +0 -139
  72. package/src/edit-lock/remote.js +0 -362
  73. package/src/edit-lock/request-binding.js +0 -42
  74. package/src/edit-lock/request-data.js +0 -40
  75. package/src/edit-lock/reservation.js +0 -301
  76. package/src/edit-lock/reserved-runtime.js +0 -45
  77. package/src/edit-lock/resource-identity.js +0 -130
  78. package/src/edit-lock/runtime.js +0 -145
  79. package/src/edit-lock/settle.js +0 -144
  80. package/src/edit-lock/snapshot.js +0 -175
  81. package/src/edit-lock/stale-sweep.js +0 -120
  82. package/src/edit-lock/state.js +0 -462
  83. package/src/edit-lock/store.js +0 -327
  84. package/src/edit-lock/tool-scope.js +0 -29
  85. package/src/edit-lock/view.js +0 -170
  86. package/src/edit-lock/write-scopes.js +0 -48
  87. package/src/edit-lock/write-tool.js +0 -45
  88. package/src/shared/delegate-edit-lifecycle.js +0 -36
  89. 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
+ }