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.
Files changed (81) 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/lease-broker/entry.js +98 -0
  23. package/src/lease-broker/node-contracts.d.ts +63 -0
  24. package/src/lease-broker/protocol.js +236 -0
  25. package/src/lease-broker/server.js +399 -0
  26. package/src/lease-broker/state.js +425 -0
  27. package/src/lsp/tools.js +137 -27
  28. package/src/settings/index.js +2 -44
  29. package/src/settings/sections.js +2 -24
  30. package/src/shared/audit.js +6 -1
  31. package/src/todo-driver/index.js +1 -6
  32. package/src/worktree/cards.js +6 -21
  33. package/src/worktree/lane-core.js +1 -19
  34. package/src/worktree/lane-workflows.js +11 -32
  35. package/lib/client.edit-lock-maintenance.js +0 -592
  36. package/lib/client.edit-lock-panel.js +0 -405
  37. package/src/edit-lock/adapter.js +0 -57
  38. package/src/edit-lock/admin-ledger.js +0 -149
  39. package/src/edit-lock/admin-recovery.js +0 -151
  40. package/src/edit-lock/admission.js +0 -82
  41. package/src/edit-lock/binding-attestation.js +0 -29
  42. package/src/edit-lock/binding-guidance.js +0 -72
  43. package/src/edit-lock/binding-machine.js +0 -312
  44. package/src/edit-lock/call-context.js +0 -65
  45. package/src/edit-lock/derived-facts.js +0 -67
  46. package/src/edit-lock/domain.js +0 -165
  47. package/src/edit-lock/domains.js +0 -170
  48. package/src/edit-lock/host.js +0 -149
  49. package/src/edit-lock/incarnations.js +0 -150
  50. package/src/edit-lock/index.js +0 -1303
  51. package/src/edit-lock/inspect.js +0 -219
  52. package/src/edit-lock/lifecycle.js +0 -579
  53. package/src/edit-lock/limits.js +0 -61
  54. package/src/edit-lock/maintenance.js +0 -310
  55. package/src/edit-lock/manager.js +0 -875
  56. package/src/edit-lock/negotiation.js +0 -132
  57. package/src/edit-lock/operation-history.js +0 -214
  58. package/src/edit-lock/peer-client.js +0 -86
  59. package/src/edit-lock/peer-transport.js +0 -76
  60. package/src/edit-lock/peer.js +0 -87
  61. package/src/edit-lock/publisher.js +0 -191
  62. package/src/edit-lock/read-authority.js +0 -101
  63. package/src/edit-lock/recovery.js +0 -139
  64. package/src/edit-lock/remote.js +0 -362
  65. package/src/edit-lock/request-binding.js +0 -42
  66. package/src/edit-lock/request-data.js +0 -40
  67. package/src/edit-lock/reservation.js +0 -301
  68. package/src/edit-lock/reserved-runtime.js +0 -45
  69. package/src/edit-lock/resource-identity.js +0 -130
  70. package/src/edit-lock/runtime.js +0 -145
  71. package/src/edit-lock/settle.js +0 -144
  72. package/src/edit-lock/snapshot.js +0 -175
  73. package/src/edit-lock/stale-sweep.js +0 -120
  74. package/src/edit-lock/state.js +0 -462
  75. package/src/edit-lock/store.js +0 -327
  76. package/src/edit-lock/tool-scope.js +0 -29
  77. package/src/edit-lock/view.js +0 -170
  78. package/src/edit-lock/write-scopes.js +0 -48
  79. package/src/edit-lock/write-tool.js +0 -45
  80. package/src/shared/delegate-edit-lifecycle.js +0 -36
  81. package/src/worktree/authority.js +0 -56
@@ -0,0 +1,144 @@
1
+ // The read-only ownership view of the status surface (design D13).
2
+ //
3
+ // This module is the ONE place that decides what a user may see about
4
+ // ownership, and it is deliberately the narrowest possible projection:
5
+ //
6
+ // - three facts and nothing else — who holds which short path, when the hold
7
+ // started, and how long it can last. No epoch, no generation, no
8
+ // reservation, no recovery record, no operation history, no fence. Those
9
+ // concepts were deleted with the durable authority (D12); a view that still
10
+ // carried them would let the panel grow a story that no longer exists.
11
+ // - short names only. An absolute path in `path` would leak the user's home
12
+ // directory into a UI string and, worse, invite someone to "fix" a row by
13
+ // path. `pathFull` is the separate, mechanically faithful field the
14
+ // technical layer may use; the panel does not render it by default.
15
+ // - purity. No IO, no clock, no arbiter, no cordis: `now` arrives as an
16
+ // argument so the expiry comparison is the caller's clock, exactly the
17
+ // "read-time comparison" the lease protocol specifies (D2). A lapsed row is
18
+ // therefore not ownership any more — showing it would invent a state whose
19
+ // only exit is somebody cleaning it up, which the rebuild forbids.
20
+ //
21
+ // The arbiter's own health is a closed three-value vocabulary (D7): a healthy
22
+ // arbiter arbitrates, a degraded one lost the connection it once had, and an
23
+ // unavailable one was never reached in this process. Anything else is reported
24
+ // as `unavailable` — an unknown state must never be presented as healthy.
25
+
26
+ import { shortName as shortPathName } from './tools.js'
27
+
28
+ /** The only shape of this view the panel understands. */
29
+ export const VIEW_SCHEMA_VERSION = 1
30
+
31
+ /** Closed arbiter vocabulary (design D7); anything else renders as unavailable. */
32
+ export const ARBITER_STATES = Object.freeze(['ok', 'degraded', 'unavailable'])
33
+
34
+ /** @returns {string | undefined} the value when it is a non-empty string */
35
+ function text(value) {
36
+ return typeof value === 'string' && value.length > 0 ? value : undefined
37
+ }
38
+
39
+ /**
40
+ * A millisecond instant, or `null` when the field is absent. `null` (not 0) is
41
+ * the view's "no such deadline" — the panel distinguishes "not retained" from
42
+ * "retained until the epoch".
43
+ * @param {unknown} value
44
+ * @returns {number | null}
45
+ */
46
+ function instant(value) {
47
+ return typeof value === 'number' && Number.isFinite(value) ? value : null
48
+ }
49
+
50
+ /**
51
+ * When a lease stops being ownership: `max(expiresAt, holdUntil ?? 0)`, the
52
+ * protocol's one validity rule (D2). Not a second authority — the arbiter
53
+ * already applied it; this is what makes the view agree with the arbiter
54
+ * without asking it again.
55
+ * @param {any} lease
56
+ * @returns {number}
57
+ */
58
+ function validUntil(lease) {
59
+ const expiresAt = instant(lease?.expiresAt) ?? 0
60
+ const holdUntil = instant(lease?.holdUntil) ?? 0
61
+ return Math.max(expiresAt, holdUntil)
62
+ }
63
+
64
+ /**
65
+ * The short name of one lease path: relative inside the session's cwd, the
66
+ * basename outside it. Never absolute (the rule itself lives in `tools.js`, so
67
+ * the model-facing status text and this view cannot disagree).
68
+ * @param {string} path
69
+ * @param {string | undefined} cwd
70
+ * @returns {string}
71
+ */
72
+ function shortName(path, cwd) {
73
+ try {
74
+ return shortPathName(path, cwd)
75
+ } catch {
76
+ // A malformed path must degrade to something renderable, never throw in
77
+ // the endpoint: the panel is an observability surface, not a validator.
78
+ return path
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Build the read-only ownership view.
84
+ *
85
+ * @param {object} input
86
+ * @param {any[]} [input.leases] the arbiter's `status` snapshot (`LeaseView[]`)
87
+ * @param {string | undefined} [input.mineLineage] the calling session's lineage id (design D9)
88
+ * @param {string | undefined} [input.cwd] the session's working directory (short-name base and scope)
89
+ * @param {'ok' | 'degraded' | 'unavailable' | string} [input.arbiter] the client's read-only connection state
90
+ * @param {number} [input.now] read-time instant (epoch ms) used for the expiry comparison
91
+ * @returns {{
92
+ * schemaVersion: number,
93
+ * arbiter: 'ok' | 'degraded' | 'unavailable',
94
+ * mine: { path: string, pathFull: string, label: string, heldSince: number | null, holdUntil: number | null }[],
95
+ * others: { path: string, pathFull: string, holder: { label: string, session: string }, expiresAt: number | null, holdUntil: number | null }[],
96
+ * }}
97
+ */
98
+ export function buildLeaseView({ leases, mineLineage, cwd, arbiter, now } = {}) {
99
+ const at = instant(now) ?? 0
100
+ const base = text(cwd)
101
+ const mineId = text(mineLineage)
102
+ /** @type {any[]} */
103
+ const mine = []
104
+ /** @type {any[]} */
105
+ const others = []
106
+ for (const lease of Array.isArray(leases) ? leases : []) {
107
+ if (lease === null || typeof lease !== 'object') continue
108
+ const path = text(lease.path)
109
+ if (path === undefined) continue
110
+ // Lapsed rows are not ownership: ownership ends by itself (D2/D10), and the
111
+ // view says so instead of presenting a row somebody would have to clear.
112
+ if (validUntil(lease) <= at) continue
113
+ const owner = lease.owner !== null && typeof lease.owner === 'object' ? lease.owner : {}
114
+ const label = text(owner.label) ?? text(owner.session) ?? ''
115
+ const short = shortName(path, base)
116
+ const holdUntil = instant(lease.holdUntil)
117
+ if (mineId !== undefined && text(owner.id) === mineId) {
118
+ mine.push({ path: short, pathFull: path, label, heldSince: instant(lease.acquiredAt), holdUntil })
119
+ continue
120
+ }
121
+ others.push({
122
+ path: short,
123
+ pathFull: path,
124
+ holder: { label, session: text(owner.session) ?? '' },
125
+ expiresAt: instant(lease.expiresAt),
126
+ holdUntil,
127
+ })
128
+ }
129
+ // Deterministic order, independent of the snapshot's own ordering: by the
130
+ // short name the user actually reads (two basenames may collide), then by the
131
+ // full path, which is unique per lease and therefore a total order.
132
+ const byPath = (/** @type {any} */ left, /** @type {any} */ right) => {
133
+ if (left.path !== right.path) return left.path < right.path ? -1 : 1
134
+ return left.pathFull < right.pathFull ? -1 : left.pathFull > right.pathFull ? 1 : 0
135
+ }
136
+ mine.sort(byPath)
137
+ others.sort(byPath)
138
+ return {
139
+ schemaVersion: VIEW_SCHEMA_VERSION,
140
+ arbiter: ARBITER_STATES.includes(/** @type {any} */ (arbiter)) ? /** @type {any} */ (arbiter) : 'unavailable',
141
+ mine,
142
+ others,
143
+ }
144
+ }
@@ -4,28 +4,63 @@ import { anchorFor } from './anchors.js'
4
4
  import { applyOps, renderMismatch, validateOps } from './apply-ops.js'
5
5
  import { diffResult } from './diff.js'
6
6
  import { escalationHintMarker, probeEscalation, resolveCallPolicy, sandboxDenialMarker } from './sandbox.js'
7
+ import { createManagedWriteTool, MANAGED_WRITE_NAME } from './write-tool.js'
8
+ import { createEditorLease } from '../file-lease/singleton.js'
9
+ import { isLeaseConflict } from '../file-lease/client.js'
7
10
 
8
11
  const name = 'weir-hashline-edit'
9
12
  const inject = ['tools', 'fs']
10
13
 
11
14
  export const HASH_EDIT_NAME = 'hash_edit'
12
15
  const STOCK_EDIT_NAME = 'edit'
16
+ const STOCK_WRITE_NAME = 'write'
13
17
 
14
18
  /**
15
- * Hide the stock edit tool. Uniform mechanism: per-agent restriction on
16
- * creation. At an agent's scope the stock edit is always *inherited* (from
17
- * the global layer, or from the preset layer in preset compositions), and
18
- * inherited tools are restrictable — the agent's catalog hides it. This
19
- * covers host-level mounts (headless compositions) and preset mounts alike;
20
- * a mount-time restrict cannot work in either (unscoped at host level; at
21
- * preset standing scope the fs row's scoped edit is not restrictable).
22
- * Listeners are awaited before creation resolves, so the restriction lands
23
- * before the agent's first request.
19
+ * Install the managed editors on every created agent (design D11): hide the
20
+ * stock `edit` AND the stock `write`, then provide our own `write` at the SAME
21
+ * scope.
22
+ *
23
+ * Uniform mechanism: per-agent restriction on creation. At an agent's scope the
24
+ * stock tools are always *inherited* (from the global layer, or from the preset
25
+ * layer in preset compositions), and inherited tools are restrictable — the
26
+ * agent's catalog hides them — while a definition registered at that same scope
27
+ * wins over the denied name. This covers host-level mounts (headless
28
+ * compositions) and preset mounts alike; a mount-time restrict cannot work in
29
+ * either (unscoped at host level; at preset standing scope the fs row's scoped
30
+ * tools are not restrictable). Listeners are awaited before creation resolves,
31
+ * so the denial and the registration both land before the agent's first
32
+ * request.
33
+ *
34
+ * The two steps must stay in one listener: a deny without the registration
35
+ * leaves the agent with no writer at all, and a registration without the deny
36
+ * leaves the unmanaged stock writer reachable. The registration is verified —
37
+ * if our definition is not the one this agent resolves, it is disposed and the
38
+ * stock writers stay hidden, because an unmanaged write is exactly what this
39
+ * path exists to prevent.
40
+ * @param {any} ctx @param {any} lease @param {() => any} sandboxPolicy
24
41
  */
25
- function hideStockEdit(ctx) {
42
+ function installManagedEditors(ctx, lease, sandboxPolicy) {
43
+ /** @type {WeakMap<object, any>} definitions this mount installed, per agent */
44
+ const installed = new WeakMap()
26
45
  ctx.on('agent/created', ({ agent }) => {
27
- if (!ctx.tools.get(STOCK_EDIT_NAME, agent)) return // this agent has no stock edit
28
- agent.ctx.tools.restrict({ deny: [STOCK_EDIT_NAME] })
46
+ const scope = agent?.ctx?.tools
47
+ if (typeof scope?.restrict !== 'function') return
48
+ // Nothing to replace: this agent sees neither stock editor (a composition
49
+ // without the host fs row). Registering our writer there would only add a
50
+ // tool whose observation guard could never be answered.
51
+ if (typeof ctx.tools?.get === 'function'
52
+ && !ctx.tools.get(STOCK_EDIT_NAME, agent)
53
+ && !ctx.tools.get(STOCK_WRITE_NAME, agent)) return
54
+ if (installed.has(agent)) return
55
+ scope.restrict({ deny: [STOCK_EDIT_NAME, STOCK_WRITE_NAME] })
56
+ const definition = createManagedWriteTool(ctx, lease, sandboxPolicy())
57
+ const dispose = typeof scope.register === 'function' ? scope.register(definition) : undefined
58
+ installed.set(agent, definition)
59
+ const resolved = typeof ctx.tools?.get === 'function' ? ctx.tools.get(MANAGED_WRITE_NAME, agent) : definition
60
+ if (resolved === definition) return
61
+ installed.delete(agent)
62
+ try { dispose?.() } catch { /* the scope's own teardown may have run first */ }
63
+ ctx.logger?.warn?.('hashline-edit: the managed write was not installed for this agent; the stock writers stay hidden')
29
64
  })
30
65
  }
31
66
 
@@ -87,11 +122,11 @@ function isSandboxDenial(error) {
87
122
  }
88
123
 
89
124
  function apply(ctx, config = {}) {
90
- // Capture once: disposal of managed editing must fail closed, not downgrade.
91
- // A sibling row's service becomes visible only after its own apply, so a
92
- // deferred provider is accepted too (see the inject after registration).
93
- let editLock = ctx.get?.('weirEditLock')
94
- let editLockRemoved = false
125
+ // The injection seam (unit tests, embedders): an explicit client wins and
126
+ // creates no singleton. Read BEFORE the settings overlay so a settings value
127
+ // can never shadow it. Default: the process-wide client (D11), resolved on
128
+ // the first managed write.
129
+ const leaseClient = config.leaseClient
95
130
  // Settings overlay (absent service = no-op): hashlineEdit section wins over row config.
96
131
  const settingsOverride = ctx.get?.('weirSettings')?.get('hashlineEdit')
97
132
  if (settingsOverride && typeof settingsOverride === 'object') {
@@ -104,6 +139,7 @@ function apply(ctx, config = {}) {
104
139
  ctx.inject?.(['sandboxPolicy'], (scope) => {
105
140
  sandboxPolicyRef = scope.sandboxPolicy
106
141
  })
142
+ const lease = createEditorLease({ leaseClient, ctx })
107
143
 
108
144
  // Sandbox escalation capability fact (mirrors the stock fs tools): a
109
145
  // confining backend exposes ctx.fs.sandboxMode; only then does hash_edit
@@ -120,9 +156,9 @@ function apply(ctx, config = {}) {
120
156
  return { ...downstream, content: rewritten }
121
157
  })
122
158
 
123
- // Optional: hide the stock edit tool from this preset's agents.
159
+ // Managed editors: hide the stock `edit` and `write`, provide our `write`.
124
160
  if (config.hideStockEdit === true) {
125
- hideStockEdit(ctx)
161
+ installManagedEditors(ctx, lease, () => sandboxPolicyRef)
126
162
  }
127
163
 
128
164
  const definition = {
@@ -199,21 +235,31 @@ function apply(ctx, config = {}) {
199
235
  }
200
236
 
201
237
  const after = applyOps(lines, args.edits).join('\n')
202
- if (editLockRemoved) throw new Error('hash_edit: the Edit Lock service was removed; refusing an unmanaged write.')
238
+ // Managed commit (design D2, D11): the lease is acquired for this lineage
239
+ // BEFORE the write and kept for the remainder of the turn, so a sibling
240
+ // session is refused with the target untouched. A real conflict is never
241
+ // rewritten as anything else; an unhealthy arbiter degrades and this edit
242
+ // proceeds (D7). Anchors, the version guard and the sandbox policy are
243
+ // unchanged by the wrap.
203
244
  let outcome
204
245
  try {
205
- outcome = editLock ? await editLock.publish(exec, {
206
- tool: 'hash_edit', filePath: args.file_path, args, content: after,
207
- expected: { kind: 'replaceIfVersion', version: info.version },
208
- cwd: resolveCwd, effectivePolicy: policy,
209
- }) : await ctx.fs.writeText(
210
- target,
211
- after,
212
- { kind: 'replaceIfVersion', version: info.version },
213
- exec.signal,
214
- policy,
246
+ outcome = await lease.client().withLeases(
247
+ lease.ownerOf(exec.agent),
248
+ [target.displayPath],
249
+ lease.scopeOf(exec.agent, resolveCwd),
250
+ () => ctx.fs.writeText(
251
+ target,
252
+ after,
253
+ { kind: 'replaceIfVersion', version: info.version },
254
+ exec.signal,
255
+ policy,
256
+ ),
215
257
  )
216
258
  } catch (error) {
259
+ // A refused claim is the arbiter's answer: pass it through untouched so
260
+ // the model sees the conflict (and never a stale-version or sandbox
261
+ // story that did not happen).
262
+ if (isLeaseConflict(error)) throw error
217
263
  if (String(error?.code ?? error?.message ?? '').includes('FS_STALE_VERSION')) {
218
264
  throw new Error('hash_edit: the file changed on disk between your read and this edit. Re-read it and copy the current anchors before retrying.')
219
265
  }
@@ -235,19 +281,7 @@ function apply(ctx, config = {}) {
235
281
  }
236
282
  },
237
283
  }
238
- // Managed mounts claim their exact definition; Edit Lock denies any
239
- // unclaimed hash_edit so a mis-ordered composition cannot bypass it.
240
- editLock?.claim?.(definition)
241
284
  ctx.tools.register(definition)
242
- if (!editLock) {
243
- ctx.inject?.(['weirEditLock'], (scope) => {
244
- if (editLock || editLockRemoved) return
245
- editLock = scope.weirEditLock
246
- editLock?.claim?.(definition)
247
- // Once managed, removal never re-enables direct writes.
248
- return () => { editLockRemoved = true }
249
- })
250
- }
251
285
  }
252
286
 
253
287
  export { name, inject, apply }
@@ -0,0 +1,90 @@
1
+ // The managed `write` tool (design D11), now owned by hashline-edit.
2
+ //
3
+ // Byte-for-byte the tool that used to live in `src/edit-lock/write-tool.js`:
4
+ // same description of the contract, same stock observation guard, same diff
5
+ // metadata, same `fs/observed` event and the same error texts. The single
6
+ // difference is the commit itself — instead of asking a publisher service to
7
+ // write on our behalf, the write happens INSIDE
8
+ // `client.withLeases(owner, [path], scope, () => ctx.fs.writeText(...))`.
9
+ //
10
+ // Why that is not a downgrade: the version guard (`replaceIfVersion` /
11
+ // `createIfAbsent`) still runs in the host filesystem, so no unread overwrite is
12
+ // possible; the lease adds what the version guard cannot give — a sibling
13
+ // session is refused BEFORE any target-side effect, and that claim lasts for the
14
+ // remainder of the turn (design D2). A real conflict refuses the write; an
15
+ // unhealthy arbiter degrades and lets the write through (design D7).
16
+ //
17
+ // Nothing here imports `src/edit-lock/**`, and nothing imports `@deepseek-ai/*`:
18
+ // the filesystem arrives through `ctx`.
19
+
20
+ import { probeEscalation, resolveCallPolicy } from './sandbox.js'
21
+ import { diffResult } from './diff.js'
22
+
23
+ /** The stock name this definition replaces at the agent's scope. */
24
+ export const MANAGED_WRITE_NAME = 'write'
25
+
26
+ /** The observation guards the stock policy may hand back (`fs/write-intent`). */
27
+ const OBSERVATION_GUARDS = ['createIfAbsent', 'replaceIfVersion']
28
+
29
+ /**
30
+ * Build the definition only. The caller must hide the inherited stock `write`
31
+ * at the agent's scope and register THIS definition there, so the managed
32
+ * writer is the one the agent resolves (fail-closed otherwise).
33
+ *
34
+ * @param {any} ctx the row context (`fs`, `waterfall`, `emit`, `emit`-style hooks)
35
+ * @param {any} lease `{ client(), ownerOf(agent), scopeOf(agent, fallback) }` — see `file-lease/singleton.js`
36
+ * @param {any} sandboxPolicy the effective-policy provider captured at mount (may be null)
37
+ * @returns {any} a plain tool definition
38
+ */
39
+ export function createManagedWriteTool(ctx, lease, sandboxPolicy) {
40
+ const escalation = probeEscalation(ctx.fs)
41
+ return {
42
+ name: MANAGED_WRITE_NAME,
43
+ description: 'Create or fully replace a UTF-8 text file through the file lease. Read an existing file before overwriting it.',
44
+ parameters: { type: 'object', properties: {
45
+ file_path: { type: 'string', description: 'Path to write, resolved by the filesystem backend.' },
46
+ content: { type: 'string', description: 'Full UTF-8 text content to write.' },
47
+ ...(escalation?.fields ?? {}),
48
+ }, required: ['file_path', 'content'] },
49
+ output: {
50
+ schema: { type: 'object' },
51
+ render: (/** @type {any} */ _args, /** @type {any} */ value) => [{ type: 'text', text: `${value.operation === 'create' ? 'Created' : 'Updated'} file: ${value.path}` }],
52
+ presentationMeta: (/** @type {any} */ _args, /** @type {any} */ value) => ({ diffs: value.fragments }),
53
+ },
54
+ async execute(/** @type {any} */ args, /** @type {any} */ exec) {
55
+ if (typeof args.file_path !== 'string' || !args.file_path.trim() || typeof args.content !== 'string') throw new Error('write requires file_path and content strings')
56
+ const { policy, resolveCwd } = await resolveCallPolicy(args, {
57
+ escalation, sandboxPolicy, session: exec.agent?.session, sessionCwd: exec.agent?.session?.header?.cwd,
58
+ approval: { approver: ctx.get?.('approval'), agent: exec.agent, toolName: MANAGED_WRITE_NAME,
59
+ ...(exec.callId !== undefined ? {callId: exec.callId} : {}), ...(exec.signal ? {signal: exec.signal} : {}) },
60
+ })
61
+ if (!resolveCwd || !policy) throw new Error('managed write requires trusted cwd and effective policy')
62
+ const target = await ctx.fs.resolve(args.file_path, {cwd: resolveCwd})
63
+ // Keep the stock observation policy's exact guard; do not stat a fresh
64
+ // version and thereby silently authorize an unread overwrite.
65
+ const intent = await ctx.waterfall('fs/write-intent', target, exec, () => undefined)
66
+ if (!intent || !OBSERVATION_GUARDS.includes(intent.kind)) throw new Error('managed write requires an explicit observation guard')
67
+ const before = intent.kind === 'replaceIfVersion' ? await ctx.fs.readText(target, exec.signal) : null
68
+ // The claim is taken before the write and NOT released here: it belongs to
69
+ // the turn (D2), so a second write of this file in the same turn reuses it.
70
+ const outcome = await lease.client().withLeases(
71
+ lease.ownerOf(exec.agent),
72
+ [target.displayPath],
73
+ lease.scopeOf(exec.agent, resolveCwd),
74
+ () => ctx.fs.writeText(target, args.content, intent, exec.signal, policy),
75
+ )
76
+ if (!outcome || !('version' in outcome)) throw new Error('write publication not acknowledged')
77
+ ctx.emit('fs/observed', target, {kind: 'present', version: outcome.version}, exec)
78
+ const fragments = before === null ? [] : diffResult(target.displayPath, before, args.content).fragments
79
+ return {
80
+ path: target.displayPath,
81
+ // The guard is what decides create-vs-update: `createIfAbsent` can only
82
+ // have produced a new file, `replaceIfVersion` only replaced one.
83
+ operation: intent.kind === 'createIfAbsent' ? 'create' : 'update',
84
+ before,
85
+ after: args.content,
86
+ fragments,
87
+ }
88
+ },
89
+ }
90
+ }
@@ -0,0 +1,98 @@
1
+ #!/usr/bin/env node
2
+ // Executable entry for `weir-editlockd`, the edit-lock lease broker.
3
+ //
4
+ // Plain Node: it knows nothing about DSH, cordis or `ctx` — the adapter that
5
+ // talks to it lives in the bundle and speaks the protocol in
6
+ // docs/features/edit-lock-protocol.md.
7
+ //
8
+ // Environment:
9
+ // WEIR_EDITLOCKD_SOCKET endpoint address (default: defaultSocketPath())
10
+ // WEIR_EDITLOCKD_IDLE_MS idle window before self-exit (default 60000)
11
+ // WEIR_EDITLOCKD_SWEEP_MS memory-reclaim period (default 30000)
12
+ //
13
+ // The process ends in exactly two ways: SIGTERM/SIGINT (close, then exit 0), or
14
+ // the idle window elapsing with no connected client (close, then the event loop
15
+ // simply drains). Nothing is written to disk, so both are equally recoverable.
16
+
17
+ import { readFileSync } from 'node:fs'
18
+ import process from 'node:process'
19
+ import { PROTOCOL } from './protocol.js'
20
+ import { createBroker } from './state.js'
21
+ import { DEFAULT_IDLE_MS, DEFAULT_SWEEP_MS, defaultSocketPath, startServer } from './server.js'
22
+
23
+ const VERSION_FALLBACK = '0.0.0'
24
+
25
+ /**
26
+ * Version reported by the handshake, read from this package's manifest.
27
+ * @returns {string}
28
+ */
29
+ function readDaemonVersion() {
30
+ try {
31
+ const manifest = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8'))
32
+ return typeof manifest.version === 'string' && manifest.version.length > 0 ? manifest.version : VERSION_FALLBACK
33
+ } catch {
34
+ // A missing manifest must not stop the daemon: the version is diagnostic.
35
+ return VERSION_FALLBACK
36
+ }
37
+ }
38
+
39
+ /**
40
+ * Read an interval override; anything that is not a positive integer falls back
41
+ * to the default so a typo cannot turn the daemon into a busy loop or a
42
+ * permanent resident.
43
+ * @param {string} name
44
+ * @param {number} fallback
45
+ * @returns {number}
46
+ */
47
+ function readInterval(name, fallback) {
48
+ const raw = process.env[name]
49
+ if (raw === undefined) return fallback
50
+ const value = Number.parseInt(raw, 10)
51
+ return Number.isFinite(value) && value > 0 ? value : fallback
52
+ }
53
+
54
+ const daemonVersion = readDaemonVersion()
55
+ const socketPath = process.env.WEIR_EDITLOCKD_SOCKET || defaultSocketPath()
56
+ const idleMs = readInterval('WEIR_EDITLOCKD_IDLE_MS', DEFAULT_IDLE_MS)
57
+ const sweepMs = readInterval('WEIR_EDITLOCKD_SWEEP_MS', DEFAULT_SWEEP_MS)
58
+
59
+ /** @type {Awaited<ReturnType<typeof startServer>> | null} */
60
+ let server = null
61
+
62
+ // The kernel hands events to the transport by connection id; the sink reads the
63
+ // late-bound server handle because the broker must exist before startServer can
64
+ // register connections on it.
65
+ const broker = createBroker({
66
+ onEvent: (connectionId, event) => {
67
+ if (server !== null) server.send(connectionId, event)
68
+ },
69
+ })
70
+
71
+ try {
72
+ server = await startServer({ socketPath, broker, protocol: PROTOCOL, daemonVersion, idleMs, sweepMs })
73
+ } catch (error) {
74
+ const detail = error instanceof Error ? error.message : String(error)
75
+ process.stderr.write(`weir-editlockd: ${detail}\n`)
76
+ process.exit(1)
77
+ }
78
+
79
+ process.stderr.write(`weir-editlockd ${daemonVersion} listening on ${socketPath}\n`)
80
+
81
+ let shuttingDown = false
82
+
83
+ async function shutdown() {
84
+ if (shuttingDown) return
85
+ shuttingDown = true
86
+ const handle = server
87
+ if (handle !== null) {
88
+ try {
89
+ await handle.close()
90
+ } catch {
91
+ // Shutting down regardless: there is no state to preserve.
92
+ }
93
+ }
94
+ process.exit(0)
95
+ }
96
+
97
+ process.on('SIGTERM', shutdown)
98
+ process.on('SIGINT', shutdown)
@@ -0,0 +1,63 @@
1
+ // Node transport contracts consumed by the lease-broker daemon, declared next
2
+ // to the code that uses them.
3
+ //
4
+ // `checkJs` runs with `types: []` (no @types/node), so the Node surface this
5
+ // package compiles against is declared in types/platform.d.ts. That file covers
6
+ // the modules the rest of the bundle consumes; the daemon is the only consumer
7
+ // of `node:net`, `node:os`, the global `process`, and `unlinkSync`, and its
8
+ // subtree is a self-contained pure-Node module (no DSH, no cordis), so its
9
+ // daemon-only contracts stay here. Ambient declarations merge with the shared
10
+ // ones, which is why the added `unlinkSync` below extends `node:fs` instead of
11
+ // redeclaring it.
12
+
13
+ declare module 'node:net' {
14
+ interface SocketError extends Error {
15
+ code?: string;
16
+ }
17
+ export interface Socket {
18
+ destroyed: boolean;
19
+ setEncoding(encoding: string): void;
20
+ setNoDelay(noDelay?: boolean): void;
21
+ write(chunk: string): boolean;
22
+ end(data?: string): void;
23
+ destroy(error?: Error): void;
24
+ on(event: 'data', listener: (chunk: string) => void): Socket;
25
+ on(event: 'close' | 'end', listener: () => void): Socket;
26
+ on(event: 'error', listener: (error: SocketError) => void): Socket;
27
+ once(event: 'connect', listener: () => void): Socket;
28
+ once(event: 'error', listener: (error: SocketError) => void): Socket;
29
+ }
30
+ export interface Server {
31
+ listen(options: { path: string }, callback?: () => void): Server;
32
+ close(callback?: () => void): Server;
33
+ on(event: 'connection', listener: (socket: Socket) => void): Server;
34
+ on(event: 'error' | 'close', listener: (error?: SocketError) => void): Server;
35
+ }
36
+ export function createServer(options: { allowHalfOpen?: boolean }, connectionListener?: (socket: Socket) => void): Server;
37
+ export function createConnection(options: { path: string }): Socket;
38
+ }
39
+
40
+ declare module 'node:os' {
41
+ export function tmpdir(): string;
42
+ export function homedir(): string;
43
+ }
44
+
45
+ declare module 'node:fs' {
46
+ export function unlinkSync(path: string): void;
47
+ }
48
+
49
+ // The global `process` that arrives with the compiler's own libs is not the
50
+ // Node one (no `exit`/`on`/`stderr`/`getuid`), so the daemon imports the module
51
+ // explicitly instead of relying on the global.
52
+ declare module 'node:process' {
53
+ const process: {
54
+ platform: string;
55
+ pid: number;
56
+ env: Record<string, string | undefined>;
57
+ getuid?: () => number;
58
+ exit(code?: number): never;
59
+ on(event: string, listener: (...args: any[]) => void): void;
60
+ stderr: { write(chunk: string): boolean };
61
+ };
62
+ export default process;
63
+ }