One merge lane per repo across concurrent sessions: a priority queue, a Bash merge gate, auto-release on merge, and a wake-up for the next session.

One merge lane per repository, shared by every Claude Code session on this machine. When several sessions work on the same repo, only one of them may merge at a time. The others wait in a queue. When the merge lands, the lane frees itself and the next session is woken up to merge.
--high requests, then everyone else in the order they asked.gh pr merge, merging gh api calls (PUT pulls/<n>/merge, POST merges, the GraphQL mergePullRequest / enablePullRequestAutoMerge mutations), and git push to main or master. It also watches MCP GitHub merge tools (merge_pull_request). Such a call goes through only from the session that holds the lane. If the lane is free and nobody is waiting ahead, the gate takes it and lets the merge run. Otherwise it refuses the merge, adds the session to the queue, and tells it where it stands (you are #2 of 3). Other commands pass untouched, with no process run. Text that only mentions a merge (a commit message, a heredoc, a comment) also passes, as do help pages (gh pr merge --help), dry runs (git push --dry-run/-n), and read-only MCP tools whose names merely contain the words. When the text names a repo or directory the gate cannot pin down (cd "$DIR" &&, git -C "$DIR", env -C dir, -R "$REPO"), it lets the merge through rather than guess a lane, and writes a debug log line with the reason (never the command).gh pr view poll sees it MERGED or CLOSED. If the gate took the lane just for one merge and that merge failed, it is handed straight back. A holder with no merge attempt for maxHoldMinutes hands the lane on while others wait.merge-lane/v1 message. That session's copy of the mod takes the message so it never reaches the model, claims the lane, shows a toast, and (with auto on) submits one "proceed with the merge" prompt as the mod's own message. Every waiting session also checks the lane every 30 seconds, so a lost message only delays it.lane: yours (PR #12) · 2 queued, or lane: queued #2 of 3 behind feature-x (PR #7). It is cleared when the session is not on the lane.The verbs that change anything run only when the person typed them: the composer, the Remote Control bridge, or the desktop app (which hosts its sessions over the SDK, so its origin is sdk). From any other origin (a peer, a plugin, a schedule) they answer "type it yourself". Plain /lane works from anywhere. /lane runs immediately, even mid-turn.
| Command | What it does | |
|---|---|---|
/lane | Holder, since when, heartbeat, its PR and CI (gh pr view --json state,mergeStateStatus,statusCheckRollup), and the queue with positions | |
| `/lane request [pr\ | branch] [--high]` | Join the queue. Takes the lane at once if it is free and nobody waits ahead |
/lane release | Give the lane back and wake the next session, or leave the queue | |
| `/lane prioritize <id-prefix\ | pr>` | Put a queued session first (the latest prioritize goes foremost) |
/lane take [--yes] | Break the current holder and take the lane, after a confirmation dialog. Dismissing the dialog counts as No. The dialog cannot open mid-turn, so take is refused then. --yes skips the dialog only when the holder looks stale; a live holder may be mid-merge, so breaking it always needs the dialog | |
| `/lane auto on\ | off` | Whether this session auto-submits the proceed prompt when the lane comes to it |
/lane off / /lane on | Let this session merge without the lane, or gate it again |
userConfig)| Option | Default | Meaning |
|---|---|---|
auto | true | Submit the proceed-with-merge prompt when the lane comes to this session. It is the mod's own message ("The merge-lane plugin sent a message: ..."), never submitted as your words |
staleMinutes | 10 | A holder whose heartbeat is this old may be broken with /lane take (the dialog says "looks stale"). The queue head breaks it on its own 5 minutes later, but only if the holder's session is confirmed gone |
maxHoldMinutes | 30 | Hand the lane on after this long with no merge attempt while others wait. 0 turns this off |
enforce | true | Off: nothing is refused. The queue and commands still work |
allowAutoMerge | false | Allow gh pr merge --auto. It is refused by default because auto-merge lands later, without the lane and without re-checking CI against current main |
Options are set in settings under pluginConfigs → merge-lane, or in the config menu.
| Hook | Role |
|---|---|
session.start | Registers /lane, then looks at the lane once (on a timer) |
tool.call { tool: 'Bash' } | The merge gate. A guard: its .catch refuses a merge it could not judge, and passes anything else |
tool.call | The same gate for MCP merge tools |
session.receive | Consumes every merge-lane/v1 message, valid or not, so none reaches the model. A wake is only a hint: the lane is re-read from disk and taken by mkdir |
command.run { command: 'lane' } | /lane and its verbs. Answers its own command |
session.end | Gives the lane back and leaves the queue under the ending id (also on /clear). Resets /lane off and /lane auto for the next id. Sends no message (no time) |
turn.start / turn.complete | Tracks the main loop's running turn for /lane take (a subagent's turn does not count). Picks the lane back up after a hot reload |
claude plugin validate lists the Bash gate as gating hook with .catch and /lane as answers its own command.
Everything lives in ~/.claude/merge-lane/<key>/. The key is the first 16 hex characters of the SHA-256 of the normalized origin remote, so git@github.com:Owner/Repo.git and https://github.com/owner/repo share one lane. Userinfo (tokens) is stripped before anything is hashed, written or shown.
holder/ is the lock, made with mkdir, which only one session can win. holder/holder.json names the holder; it is written to tmp/ and renamed in, so it is never read half-written. holder/heartbeat is touched every 30 seconds.queue/<sessionId>.json holds one waiting session. Only that session writes it.priority/<sessionId>.json is /lane prioritize's overlay, so nobody rewrites another session's queue file.alive/<sessionId> is touched every 30 seconds by each session on the lane.Breaking or releasing a lock renames holder/ to a tomb.<uuid> first. Only one rename can succeed. The winner keeps the tomb only if it holds the very lock it judged; otherwise it puts it back. Nothing is ever rm -rf'd in place. A session is pruned from the queue, or its lock auto-broken, only when its timestamps are old and the engine says a ping to it was not delivered. After a laptop sleep every timestamp is old, but live sessions still answer, so nothing is wrongly taken.
claude --plugin-dir /path/to/mods/merge-lane
Or name the folder in CLAUDE_CODE_PLUGIN_DIRS (process environment, or the env block of ~/.claude/settings.json) for sessions the desktop app starts. Every session that should take part needs the mod loaded. A session without it is not gated and does not queue.
Checks:
claude plugin validate mods/merge-lane
claude plugin test mods/merge-lane
$.process.run, a session without the mod, or your own terminal is not seen.~/.claude. Sessions on another machine are not coordinated.origin remote, or with HOME unset, is inert: nothing is gated and /lane says so.~/.claude is not writable), merges are refused with a message naming /lane off. Every other command passes. enforce: false turns the gate off everywhere.git push whose current branch cannot be read, goes through ungated, with a debug log line naming the reason. A wrong refusal costs more than an ungated merge the person can see.-R owner/other, cd ../other &&) is gated against that repo's lane, but this session does not join that queue. Retry once it frees.session.end shares a 1.5-second budget and never runs after a crash. Liveness pings and the queue head's checks make up for it./lane, branch-name requests, and the PR poll. Without it the lane still works and frees on the engine's own merge report or /lane release.auto on, the woken session gets one prompt per wake. It is submitted as the mod's own message, so the model reads it under the plugin's name and never as your instruction; an earlier "don't merge yet" from you still stands. It only happens to sessions that queued through /lane request (you typed it) or through a refused merge (the session was already merging). Turn it off with auto: false or /lane auto off.Found in review and left open on purpose, each because the fix is larger than the risk or cannot be verified without a live engine.
holder.json is moved into whichever holder/ exists at that moment; the move cannot check that the lock is still the writer's own. Two cases can leave two sessions both believing they hold the lane: (a) two sessions break the same orphaned lock that has no holder.json (a session that crashed between mkdir and its record) while a third is mid-claim, and (b) /lane take lands inside the tens-of-milliseconds window of the holder's own record rewrite. Plain concurrent claims and breaks of a recorded lock never produce two winners (stress-tested). The full fix is a claim token inside the lock, re-checked after every record move, plus an mtime check before breaking a record-less lock; it is a protocol change, so it is deferred. --yes no longer breaks a live holder without the dialog, which narrows case (b).isDelivered: false from $.session.send as "gone". The engine also answers false when another plugin's session.send guard refuses the message, and its reason is free text with no stable values to tell the two apart. With such a plugin installed, after a sleep a live holder past staleMinutes + 5 could be auto-broken and live queue entries pruned. A session that holds the lane but has unloaded this mod also receives the ping as a peer message on each tick. The fix (a recorded pid checked with kill -0) needs the Claude process id, which the hooks do not expose.hooks/register.tsx 1202 lines1// merge-lane: one merge lane per repository, shared by every Claude Code
2// session on this machine, so concurrent sessions stop fighting over
3// PR/CI/merge. One session holds the lane; the rest queue in priority order;
4// a Bash gate refuses merges from sessions that do not hold it; the lane frees
5// itself when the PR merges and the next session is woken.
6//
7// Where things are: core.ts decides (pure), lane.ts is the on-disk protocol,
8// host.ts is the real I/O. This file wires them to the engine's events:
9// session.start registers /lane, arms the 30 s tick when this session is on a lane
10// tool.call Bash the merge gate (a guard: fails closed for merge commands only)
11// tool.call the same gate for MCP GitHub merge tools
12// session.receive takes the lane's own messages so they never reach the model
13// command.run /lane and its verbs
14// session.end gives the lane back and leaves the queue (on /clear too)
15// turn.start/complete tracks a running turn so /lane take never opens a dialog mid-turn,
16// and re-arms the tick after a hot reload (no session.start fires then)
17
18import { atom, read, update } from 'claude-code'
19import type { EngineInterface, PluginOptions, Register, Timer } from 'claude-code'
20
21import type { LaneRole } from '../types'
22import {
23 ago,
24 agoText,
25 ciSummary,
26 classifyCommand,
27 classifyMcp,
28 cleanText,
29 decideGate,
30 ENVELOPE_PREFIX,
31 isDefaultBranch,
32 isPersonOrigin,
33 judgedOf,
34 looksStale,
35 makeEnvelope,
36 mayAutoBreak,
37 mergeOutcome,
38 MUTATING_VERBS,
39 normalizeNamedRepo,
40 normalizeRemote,
41 parseEnvelope,
42 parseHolderRecord,
43 parseLaneArgs,
44 prFromArg,
45 prText,
46 repoKeyOf,
47 resolveDir,
48 SESSION_ID,
49 shortId,
50 statusLine,
51} from './core'
52import type { HolderRecord, HolderView, LaneCommand, MergeIntent, QueueEntry, RanLike, StatusView } from './core'
53import {
54 acquire,
55 aliveTimes,
56 breakHolder,
57 ensureLane,
58 lanePaths,
59 notifyHead,
60 pruneDead,
61 readHolder,
62 readQueue,
63 release,
64 removeQueueEntry,
65 sweep,
66 touchAlive,
67 updateRecord,
68 writeOverlay,
69 writeQueueEntry,
70} from './lane'
71import type { Delivery, LaneIO, LanePaths, ListedEntry, MkdirResult } from './lane'
72
73const TICK_MS = 30_000
74/** How soon after session.start (or the first hook after a hot reload) the lane is looked at. */
75const RESUME_MS = 1_000
76const POLL_MS = 60_000
77const MAX_NOTIFIED = 20
78
79const repoKeyAtom = atom({ plugin: 'merge-lane', key: 'repoKey' } as const, null)
80const roleAtom = atom({ plugin: 'merge-lane', key: 'role' } as const, 'none')
81const prAtom = atom({ plugin: 'merge-lane', key: 'pr' } as const, null)
82const nonceAtom = atom({ plugin: 'merge-lane', key: 'nonce' } as const, null)
83const notifiedAtom = atom({ plugin: 'merge-lane', key: 'notifiedNonces' } as const, [])
84const sessionAtom = atom({ plugin: 'merge-lane', key: 'sessionId' } as const, null)
85const entryAtom = atom({ plugin: 'merge-lane', key: 'entry' } as const, null)
86const autoAtom = atom({ plugin: 'merge-lane', key: 'autoOverride' } as const, null)
87const bypassAtom = atom({ plugin: 'merge-lane', key: 'isBypassed' } as const, false)
88const polledAtom = atom({ plugin: 'merge-lane', key: 'polledAt' } as const, 0)
89const turnAtom = atom({ plugin: 'merge-lane', key: 'turnId' } as const, null)
90
91const USAGE = [
92 '/lane show the lane: holder, its PR and CI, the queue',
93 '/lane request [pr|branch] [--high] join the queue (takes the lane at once when it is free)',
94 '/lane release give the lane back, or leave the queue',
95 '/lane prioritize <id|pr> put a queued session first',
96 '/lane take [--yes] break the current holder and take the lane',
97 '/lane auto on|off auto-proceed when the lane comes to this session',
98 '/lane off | /lane on let this session merge without the lane, or gate it again',
99].join('\n')
100
101const INERT = 'merge-lane: this session is not in a git repository with an origin remote (or HOME is unset), so there is no lane here and nothing is gated.'
102
103const AUTO_DENY =
104 'merge-lane: gh pr merge --auto is refused: auto-merge lands later without holding the lane or re-verifying CI against current main. Merge directly while holding the lane instead (the allowAutoMerge option turns this refusal off).'
105
106const FAILED_CLOSED =
107 'merge-lane: the merge gate failed, so this merge was refused rather than let through unchecked. Run /lane to see the lane; /lane off lets this session merge without it.'
108
109type Options = {
110 auto: boolean
111 staleMinutes: number
112 maxHoldMinutes: number
113 enforce: boolean
114 allowAutoMerge: boolean
115}
116
117/**
118 * The per-load runtime: options, the tick's timer and the in-session mutex.
119 * A hot reload builds a new one (and drops the old timer with the old module).
120 */
121type Runtime = {
122 opts: Options
123 timer: Timer | undefined
124 isTicking: boolean
125 chain: Promise<unknown>
126 /** Whether this load has looked at the lane once: session.start, or lazily after a hot reload. */
127 hasResumed: boolean
128}
129
130/** One lane as this session sees it: where it lives on disk and who is asking. */
131type Ctx = {
132 io: LaneIO
133 p: LanePaths
134 /** The normalized remote, e.g. github.com/owner/repo: safe to show (no userinfo). */
135 repo: string
136 repoKey: string
137 selfId: string
138 cwd: string
139}
140
141/** Where a gated merge lands: its lane's repo, the directory it runs in, and whether that is this session's own repo. */
142type Target = { repo: string; cwd: string; isSessionRepo: boolean }
143
144export const register: Register = (on, options) => {
145 // Nothing the lane needs to remember lives here: that is on disk and in
146 // $.state. This only holds what dies with the module anyway.
147 const rt: Runtime = {
148 opts: readOptions(options),
149 timer: undefined,
150 isTicking: false,
151 chain: Promise.resolve(),
152 hasResumed: false,
153 }
154 const opts = rt.opts
155
156 on('session.start', async ($, e, next) => {
157 try {
158 await $.command.register({
159 name: 'lane',
160 description: 'Merge lane: who holds it, the queue; request, release, prioritize or take it',
161 argumentHint: '[request [pr] [--high] | release | prioritize <id|pr> | take | auto on|off | off | on]',
162 immediate: true,
163 })
164 } catch (error) {
165 $.ui.log(`merge-lane: /lane was not registered (${errorText(error)})`, { to: 'debug' })
166 }
167 // On a lane already (a resumed queue)? Pick the tick back up.
168 resumeOnce(rt, $)
169 return next(e)
170 })
171
172 // The gate. Classify first, with no I/O: a command that does not merge
173 // passes untouched and runs no process. Subagents' calls are gated the same.
174 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
175 const intent = typeof e.command === 'string' ? classifyCommand(e.command) : null
176 if (intent === null) return next(e)
177 return gateRun($, rt, intent, () => next(e))
178 }).catch(($, e, next) => {
179 if (next.called) return next(e)
180 let isMerge = true
181 try {
182 isMerge = opts.enforce && classifyCommand(e.command) !== null
183 } catch {
184 isMerge = opts.enforce
185 }
186 return isMerge ? { deny: FAILED_CLOSED } : next(e)
187 })
188
189 on('tool.call', async ($, e, next) => {
190 const intent = classifyMcp(String(e.tool), e as unknown as Readonly<Record<string, unknown>>)
191 if (intent === null) return next(e)
192 return gateRun($, rt, intent, () => next(e))
193 }).catch(($, e, next) => {
194 if (next.called) return next(e)
195 let isMerge = true
196 try {
197 isMerge = opts.enforce && classifyMcp(String(e.tool), e as unknown as Readonly<Record<string, unknown>>) !== null
198 } catch {
199 isMerge = opts.enforce
200 }
201 return isMerge ? { deny: FAILED_CLOSED } : next(e)
202 })
203
204 // The lane's own messages. Anything carrying the prefix is consumed, valid
205 // or not, so a stale or forged "lane is free" never reaches the model. A
206 // valid one is a hint only: the lane is re-read from disk and taken only by
207 // the atomic mkdir, by the session at the head of the queue.
208 on('session.receive', async ($, e, next) => {
209 resumeOnce(rt, $)
210 const parsed = typeof e.text === 'string' ? parseEnvelope(e.text) : 'not-ours'
211 if (parsed === 'not-ours') return next(e)
212 if (parsed === 'invalid') return { consumed: 'merge-lane: unreadable lane message' }
213 if (parsed.kind === 'ping') return { consumed: 'merge-lane: ping' }
214 try {
215 const ctx = await contextFor($)
216 if (ctx !== null && ctx.repoKey === parsed.repoKey) {
217 const own = (await readQueue(ctx.io, ctx.p)).find(entry => entry.sessionId === ctx.selfId)
218 const isForUs = parsed.kind === 'lane.changed' || own?.nonce === parsed.nonce
219 if (isForUs) await serialize(rt, () => step($, ctx, rt, 'hint'))
220 }
221 } catch (error) {
222 $.ui.log(`merge-lane: lane message not handled (${errorText(error)})`, { to: 'debug' })
223 }
224 return { consumed: `merge-lane: ${parsed.kind}` }
225 }).catch(($, e, next) =>
226 typeof e.text === 'string' && e.text.startsWith(ENVELOPE_PREFIX) ? { consumed: 'merge-lane: lane message dropped' } : next(e),
227 )
228
229 on('command.run', { command: 'lane' }, async ($, e) => {
230 const command = parseLaneArgs(e.args)
231 if (MUTATING_VERBS.has(command.verb) && !isPersonOrigin(e.origin.kind)) {
232 return { text: `merge-lane: only the person at the prompt can run /lane ${command.verb}; type it yourself.` }
233 }
234 try {
235 return { text: await laneCommand($, rt, command) }
236 } catch (error) {
237 return { text: `merge-lane: /lane ${command.verb} failed (${errorText(error)}). Run /lane to see where the lane stands.` }
238 }
239 }).catch(() => ({ text: 'merge-lane: /lane did not finish in time. Run /lane to see where the lane stands.' }))
240
241 // Leaving: give the lane back and leave the queue under the ENDING id (a
242 // /clear goes on under a new one, with no session.start after it). One
243 // short shared budget, so no notify here: the head's tick finds the lane free.
244 on('session.end', async ($, e, next) => {
245 stopTick(rt)
246 try {
247 // /lane off and /lane auto were typed for the ending id: the id a /clear
248 // goes on under starts gated, following the auto option.
249 await update($, bypassAtom, () => false)
250 await update($, autoAtom, () => null)
251 } catch {
252 // the lane cleanup below matters more; never let this stop it
253 }
254 try {
255 const home = await $.env.get('HOME')
256 const repo = await sessionRepo($)
257 if (home !== undefined && home.startsWith('/') && repo !== null && SESSION_ID.test(e.sessionId)) {
258 const root = laneRoot(home)
259 const io = hostIO($, root)
260 const p = lanePaths(root, await repoKeyOf(repo))
261 const record = parseHolderRecord(await io.read(p.holderFile))
262 if (record !== null && record.sessionId === e.sessionId) {
263 await breakHolder(io, p, { sessionId: record.sessionId, since: record.since })
264 }
265 await removeQueueEntry(io, p, e.sessionId, true)
266 }
267 $.ui.status(undefined)
268 } catch (error) {
269 $.ui.log(`merge-lane: session end cleanup failed (${errorText(error)})`, { to: 'debug' })
270 }
271 return next(e)
272 })
273
274 on('turn.start', async ($, e, next) => {
275 resumeOnce(rt, $)
276 // The main loop's turn only: turn.complete clears that one alone, so a subagent's would stick.
277 // TurnStartInput does not declare agentId, so it is read defensively in case the engine sends it.
278 if ((e as typeof e & { agentId?: string }).agentId === undefined) {
279 try {
280 await update($, turnAtom, () => e.turnId)
281 } catch {
282 // observing only
283 }
284 }
285 return next(e)
286 })
287
288 on('turn.complete', async ($, e, next) => {
289 if (e.agentId === undefined) {
290 resumeOnce(rt, $)
291 try {
292 await update($, turnAtom, () => null)
293 } catch {
294 // observing only
295 }
296 }
297 return next(e)
298 })
299}
300
301/* ------------------------------------------------------------------------ */
302/* Context */
303/* ------------------------------------------------------------------------ */
304
305/** Runs lane changes of this session one at a time (the tick, the gate, a hint, a command). Never nest it. */
306function serialize<T>(rt: Runtime, fn: () => Promise<T>): Promise<T> {
307 const run = rt.chain.then(fn)
308 rt.chain = run.catch(() => undefined)
309 return run
310}
311
312/**
313 * A hot reload drops the tick with the old module and fires no session.start,
314 * so the first turn or lane message after it looks at the lane once; a
315 * session on the lane re-arms its tick there (publish), others stay quiet.
316 */
317function resumeOnce(rt: Runtime, $: EngineInterface): void {
318 if (rt.hasResumed) return
319 rt.hasResumed = true
320 // On a timer, as the tick itself runs: never as work left running after the hook returned.
321 $.clock.after(RESUME_MS, () => void runTick(rt, $))
322}
323
324function ensureTick(rt: Runtime, $: EngineInterface): void {
325 if (rt.timer === undefined) rt.timer = $.clock.every(TICK_MS, () => void runTick(rt, $))
326}
327
328function stopTick(rt: Runtime): void {
329 rt.timer?.cancel()
330 rt.timer = undefined
331}
332
333async function runTick(rt: Runtime, $: EngineInterface): Promise<void> {
334 if (rt.isTicking) return
335 rt.isTicking = true
336 try {
337 const ctx = await contextFor($)
338 if (ctx === null) stopTick(rt)
339 else await serialize(rt, () => step($, ctx, rt, 'tick'))
340 } catch (error) {
341 $.ui.log(`merge-lane: tick failed (${errorText(error)})`, { to: 'debug' })
342 } finally {
343 rt.isTicking = false
344 }
345}
346
347function readOptions(options: PluginOptions): Options {
348 const num = (value: unknown, fallback: number, min: number, max: number) =>
349 typeof value === 'number' && Number.isFinite(value) ? Math.min(max, Math.max(min, value)) : fallback
350 return {
351 auto: options.auto !== false,
352 staleMinutes: num(options.staleMinutes, 10, 2, 24 * 60),
353 maxHoldMinutes: num(options.maxHoldMinutes, 30, 0, 24 * 60),
354 enforce: options.enforce !== false,
355 allowAutoMerge: options.allowAutoMerge === true,
356 }
357}
358
359function laneRoot(home: string): string {
360 return `${home.replace(/\/+$/, '')}/.claude/merge-lane`
361}
362
363async function sessionRepo($: EngineInterface): Promise<string | null> {
364 const repo = await $.session.repo()
365 return repo?.remote ? normalizeRemote(repo.remote) : null
366}
367
368/** The lane for `repo` (the session's own by default); null where there is none: no HOME, no remote. */
369async function contextFor($: EngineInterface, repo?: string): Promise<Ctx | null> {
370 const home = await $.env.get('HOME')
371 if (home === undefined || !home.startsWith('/')) return null
372 const selfId = await $.session.id()
373 if (!SESSION_ID.test(selfId)) return null
374 const resolved = repo ?? (await sessionRepo($))
375 if (resolved === null) return null
376 const repoKey = await repoKeyOf(resolved)
377 const root = laneRoot(home)
378 return { io: hostIO($, root), p: lanePaths(root, repoKey), repo: resolved, repoKey, selfId, cwd: await $.session.cwd() }
379}
380
381/** Resets what $.state says about this session when the id changed under it (a /clear). */
382async function syncSession($: EngineInterface, ctx: Ctx): Promise<void> {
383 if ((await read($, sessionAtom)) === ctx.selfId) return
384 await update($, sessionAtom, () => ctx.selfId)
385 await update($, roleAtom, () => 'none')
386 await update($, prAtom, () => null)
387 await update($, nonceAtom, () => null)
388 await update($, entryAtom, () => null)
389 await update($, polledAtom, () => 0)
390}
391
392async function labelFor($: EngineInterface, ctx: Ctx): Promise<string> {
393 const branch = await gitLine($, ['rev-parse', '--abbrev-ref', 'HEAD'], ctx.cwd)
394 const name = branch !== null && branch !== 'HEAD' ? branch : (ctx.cwd.split('/').filter(part => part !== '').pop() ?? 'session')
395 return cleanText(name, 48)
396}
397
398/**
399 * Writes this session's standing to $.state and the status line under the
400 * composer, and keeps the tick armed exactly while it is on the lane.
401 */
402async function publish($: EngineInterface, ctx: Ctx, rt: Runtime, holder: HolderView, queue: readonly QueueEntry[]): Promise<LaneRole> {
403 const { selfId } = ctx
404 const isBypassed = await read($, bypassAtom)
405 const record = holder.kind === 'held' ? holder.record : null
406 const index = queue.findIndex(entry => entry.sessionId === selfId)
407 const own = queue[index]
408 let view: StatusView
409 if (record?.sessionId === selfId) {
410 view = { role: 'holder', pr: record.pr, queued: queue.length, isBypassed }
411 await update($, prAtom, () => record.pr)
412 await update($, entryAtom, () => null)
413 } else if (own !== undefined) {
414 const ahead = record ?? queue[index - 1]
415 view = {
416 role: 'queued',
417 position: index + 1,
418 total: queue.length,
419 behindLabel: ahead?.label ?? null,
420 behindPr: ahead?.pr ?? null,
421 isBypassed,
422 }
423 await update($, prAtom, () => own.pr)
424 await update($, nonceAtom, () => own.nonce)
425 await update($, entryAtom, () => own)
426 } else {
427 view = { role: 'none', isBypassed }
428 await update($, prAtom, () => null)
429 await update($, nonceAtom, () => null)
430 await update($, entryAtom, () => null)
431 }
432 await update($, roleAtom, () => view.role)
433 await update($, repoKeyAtom, () => ctx.repoKey)
434 $.ui.status(statusLine(view))
435 if (view.role === 'none') stopTick(rt)
436 else ensureTick(rt, $)
437 return view.role
438}
439
440/* ------------------------------------------------------------------------ */
441/* The tick */
442/* ------------------------------------------------------------------------ */
443
444/**
445 * One authoritative pass over the lane for this session, every 30 s while it
446 * is involved (and on any hint). The holder heartbeats, polls its PR and
447 * hands the lane on when it merged or when it has held too long with others
448 * waiting. A queued session prunes dead entries, and at the head of the queue
449 * takes a free lane, or breaks a holder that is stale by time AND whose
450 * session the engine says is not running. Messages only make this sooner.
451 */
452async function step($: EngineInterface, ctx: Ctx, rt: Runtime, via: 'tick' | 'hint' | 'request'): Promise<LaneRole> {
453 const { io, p, selfId } = ctx
454 await syncSession($, ctx)
455 const now = await io.now()
456 let holder = await readHolder(io, p)
457 let queue = await readQueue(io, p)
458 const record = holder.kind === 'held' ? holder.record : null
459
460 if (record?.sessionId === selfId) {
461 await io.touch(p.heartbeat)
462 await touchAlive(io, p, selfId, now)
463 if (record.pr !== null && now - (await read($, polledAtom)) >= POLL_MS) {
464 await update($, polledAtom, () => now)
465 const seen = await prView($, record.pr, ctx.repo, ctx.cwd)
466 if (seen?.state === 'MERGED' || seen?.state === 'CLOSED') {
467 await releaseLane($, ctx, rt, `PR #${record.pr} ${seen.state === 'MERGED' ? 'merged' : 'was closed'}`)
468 return 'none'
469 }
470 }
471 const maxHold = rt.opts.maxHoldMinutes * 60_000
472 if (maxHold > 0 && queue.length > 0 && now - Math.max(record.since, record.attemptAt ?? 0) > maxHold) {
473 await releaseLane($, ctx, rt, `held ${ago(now - record.since)} with no merge landing while others wait`)
474 return 'none'
475 }
476 await sweep(io, p, now)
477 return publish($, ctx, rt, holder, queue)
478 }
479
480 if ((await read($, roleAtom)) === 'holder') {
481 $.ui.toast(
482 record === null ? 'merge lane lost: it was released or broken' : `merge lane lost: ${record.label}${prText(record.pr)} holds it now`,
483 )
484 }
485
486 let own = queue.find(entry => entry.sessionId === selfId)
487 if (own === undefined) {
488 // A live entry pruned by mistake (a stalled tick and a failed ping) goes back in its place.
489 const kept = await read($, entryAtom)
490 if (kept !== null && kept.sessionId === selfId) {
491 await writeQueueEntry(io, p, kept)
492 queue = await readQueue(io, p)
493 own = queue.find(entry => entry.sessionId === selfId)
494 }
495 }
496 if (own === undefined) return publish($, ctx, rt, holder, queue)
497
498 await touchAlive(io, p, selfId, now)
499 queue = await pruneDead(io, p, queue, selfId, now)
500 if (queue[0]?.sessionId === selfId) {
501 if (holder.kind === 'held') {
502 const alive = await aliveTimes(io, p)
503 const liveness = {
504 now,
505 heartbeatAt: holder.heartbeatAt,
506 aliveAt: record === null ? undefined : alive.get(record.sessionId),
507 staleMinutes: rt.opts.staleMinutes,
508 }
509 if (looksStale(liveness)) {
510 const delivery = record === null ? 'absent' : await io.send(record.sessionId, makeEnvelope({ kind: 'ping' }))
511 if (delivery === 'absent' && mayAutoBreak({ ...liveness, isDelivered: false })) {
512 const broke = await breakHolder(io, p, judgedOf(holder))
513 if (broke === 'broken') {
514 $.ui.log(`merge-lane: broke a stale lock${record === null ? '' : ` held by ${record.label}`}`, { to: 'debug' })
515 }
516 holder = await readHolder(io, p)
517 }
518 }
519 }
520 if (holder.kind === 'free' && (await acquireFromQueue($, ctx, own, via !== 'request', rt))) {
521 return publish($, ctx, rt, await readHolder(io, p), await readQueue(io, p))
522 }
523 }
524 return publish($, ctx, rt, holder, queue)
525}
526
527/** Takes a free lane for this session's queue entry; the woken session gets its proceed prompt once per nonce. */
528async function acquireFromQueue($: EngineInterface, ctx: Ctx, own: QueueEntry, shouldPrompt: boolean, rt: Runtime): Promise<boolean> {
529 const { io, p, selfId } = ctx
530 const now = await io.now()
531 const record: HolderRecord = {
532 sessionId: selfId,
533 label: own.label,
534 pr: own.pr,
535 since: now,
536 implicit: own.source === 'gate',
537 repo: ctx.repo,
538 }
539 if ((await acquire(io, p, record)) !== 'acquired') return false
540 await removeQueueEntry(io, p, selfId)
541 await update($, entryAtom, () => null)
542 await update($, nonceAtom, () => own.nonce)
543 $.ui.toast(`merge lane is yours${prText(own.pr)}`)
544 const notified = await read($, notifiedAtom)
545 if (!notified.includes(own.nonce)) {
546 await update($, notifiedAtom, () => [...notified, own.nonce].slice(-MAX_NOTIFIED))
547 const isAuto = (await read($, autoAtom)) ?? rt.opts.auto
548 // The mod's own words, so the model reads them framed as this plugin's message, never as the person's
549 // (no asUser). Queued until idle and never awaited here: a hook awaiting its own submit can hang the turn.
550 if (shouldPrompt && isAuto) void $.prompt.submit({ text: proceedText(own.pr) }).catch(() => undefined)
551 }
552 return true
553}
554
555function proceedText(pr: string | null): string {
556 const lane = pr === null ? 'The merge lane is yours' : `The merge lane is yours for PR #${pr}`
557 return `${lane}. Proceed with the merge: re-verify CI is green on the current head against current main, merge, then the lane auto-releases on merge (or /lane release).`
558}
559
560/** Gives the lane back (only when this session holds it) and wakes the next session. */
561async function releaseLane(
562 $: EngineInterface,
563 ctx: Ctx,
564 rt: Runtime,
565 reason: string,
566 isSessionRepo = true,
567): Promise<{ isReleased: boolean; next: QueueEntry | null }> {
568 const { io, p, selfId } = ctx
569 const isReleased = (await release(io, p, selfId)) === 'released'
570 let next: QueueEntry | null = null
571 if (isReleased) {
572 next = await notifyHead(io, p, ctx.repoKey, selfId)
573 $.ui.toast(`merge lane released: ${reason}${next === null ? '' : `; ${next.label} is next`}`)
574 }
575 if (isSessionRepo) await publish($, ctx, rt, await readHolder(io, p), await readQueue(io, p))
576 return { isReleased, next }
577}
578
579/* ------------------------------------------------------------------------ */
580/* The gate */
581/* ------------------------------------------------------------------------ */
582
583type Admission = { kind: 'allow'; isImplicit: boolean } | { kind: 'deny'; text: string }
584
585async function gateRun<R extends RanLike>(
586 $: EngineInterface,
587 rt: Runtime,
588 intent: MergeIntent,
589 proceed: () => Promise<R>,
590): Promise<R | { deny: string }> {
591 if (!rt.opts.enforce || (await read($, bypassAtom))) return proceed()
592 if (intent.isAuto && !rt.opts.allowAutoMerge) return { deny: AUTO_DENY }
593 const target = await gateTarget($, intent)
594 // No remote, no lane: nothing to merge to, so nothing to serialize.
595 if (target === null) return allowUngated($, intent, 'no origin remote', proceed)
596 // A lane guessed for a repo or directory the text cannot tell could refuse a merge no lane here owns.
597 if (target === 'unresolved') return allowUngated($, intent, 'its repo or directory could not be told from the text', proceed)
598 if (intent.kind === 'push-unknown') {
599 const branch = await gitLine($, ['rev-parse', '--abbrev-ref', 'HEAD'], target.cwd)
600 if (branch === null) return allowUngated($, intent, 'the current branch could not be read', proceed)
601 if (!isDefaultBranch(branch)) return proceed()
602 }
603 const ctx = await contextFor($, target.repo)
604 if (ctx === null) return allowUngated($, intent, 'no lane (HOME or the session id is unusable)', proceed)
605 let admission: Admission
606 try {
607 admission = await serialize(rt, () => admit($, ctx, rt, intent, target.isSessionRepo))
608 } catch (error) {
609 return {
610 deny: `merge-lane: could not check the merge lane (${errorText(error)}), so the merge was refused. Run /lane off to let this session merge without the lane.`,
611 }
612 }
613 if (admission.kind === 'deny') return { deny: admission.text }
614 const ran = await proceed()
615 try {
616 await serialize(rt, () => afterMerge($, ctx, rt, intent, admission.kind === 'allow' && admission.isImplicit, ran, target))
617 } catch (error) {
618 $.ui.log(`merge-lane: after-merge bookkeeping failed (${errorText(error)})`, { to: 'debug' })
619 }
620 return ran
621}
622
623/**
624 * The lane a merge belongs to: `-R`/a URL/an API path first, then a
625 * `cd`/`git -C` directory's origin, then the session's repo. `unresolved`
626 * when the command names a repo or directory this cannot pin down (`"$DIR"`,
627 * a folder with no origin): the session's own lane would only be a guess,
628 * and a wrong guess refuses a merge that lane does not own.
629 */
630async function gateTarget($: EngineInterface, intent: MergeIntent): Promise<Target | null | 'unresolved'> {
631 if (intent.isDirUnknown === true) return 'unresolved'
632 const own = await sessionRepo($)
633 const cwd = await $.session.cwd()
634 const home = intent.dir === null ? undefined : await $.env.get('HOME')
635 const dir = intent.dir === null ? null : resolveDir(intent.dir, home, cwd)
636 if (intent.repo !== null) {
637 const named = normalizeNamedRepo(intent.repo, own)
638 if (named === null) return 'unresolved'
639 return { repo: named, cwd: dir ?? cwd, isSessionRepo: named === own }
640 }
641 if (intent.dir !== null) {
642 const remote = dir === null ? null : await gitLine($, ['-C', dir, 'remote', 'get-url', '--push', 'origin'])
643 const named = remote === null ? null : normalizeRemote(remote)
644 if (dir === null || named === null) return 'unresolved'
645 return { repo: named, cwd: dir, isSessionRepo: named === own }
646 }
647 return own === null ? null : { repo: own, cwd, isSessionRepo: true }
648}
649
650/** Lets a merge through ungated, as the gate prefers to a wrong refusal, and says why in the debug log (never the command text). */
651function allowUngated<R>($: EngineInterface, intent: MergeIntent, why: string, proceed: () => Promise<R>): Promise<R> {
652 $.ui.log(`merge-lane: let a ${intent.kind} through ungated: ${why}`, { to: 'debug' })
653 return proceed()
654}
655
656/**
657 * The gate's admission: allow the holder (recording the attempt), take a free
658 * lane nobody waits ahead for, or refuse and queue this session so it is
659 * woken when its turn comes. A lane for another repo than the session's own
660 * is served the same, minus the queue (no tick of this session watches it).
661 */
662async function admit($: EngineInterface, ctx: Ctx, rt: Runtime, intent: MergeIntent, isSessionRepo: boolean): Promise<Admission> {
663 const { io, p, selfId } = ctx
664 if (isSessionRepo) await syncSession($, ctx)
665 await ensureLane(io, p)
666 const now = await io.now()
667 let holder = await readHolder(io, p)
668 let queue = await readQueue(io, p)
669 let decision = decideGate({ intent, selfId, holder, queue })
670 if (decision.kind === 'deny' && decision.isFree) {
671 // A dead session at the head must not keep a live one from a free lane.
672 queue = await pruneDead(io, p, queue, selfId, now)
673 decision = decideGate({ intent, selfId, holder, queue })
674 }
675 if (decision.kind === 'allow-holder') {
676 await updateRecord(io, p, selfId, record => ({ ...record, attemptAt: now, pr: record.pr ?? intent.pr }))
677 return { kind: 'allow', isImplicit: holder.kind === 'held' && holder.record?.implicit === true }
678 }
679 if (decision.kind === 'acquire') {
680 const own = queue.find(entry => entry.sessionId === selfId)
681 const isImplicit = own === undefined || own.source === 'gate'
682 const record: HolderRecord = {
683 sessionId: selfId,
684 label: own?.label ?? (await labelFor($, ctx)),
685 pr: intent.pr ?? own?.pr ?? null,
686 since: now,
687 implicit: isImplicit,
688 repo: ctx.repo,
689 attemptAt: now,
690 }
691 if ((await acquire(io, p, record)) === 'acquired') {
692 if (own !== undefined) {
693 await removeQueueEntry(io, p, selfId)
694 // the session is already merging: its queued proceed prompt is spent
695 const notified = await read($, notifiedAtom)
696 await update($, notifiedAtom, () => [...notified, own.nonce].slice(-MAX_NOTIFIED))
697 }
698 if (isSessionRepo) await publish($, ctx, rt, await readHolder(io, p), await readQueue(io, p))
699 return { kind: 'allow', isImplicit }
700 }
701 holder = await readHolder(io, p)
702 queue = await readQueue(io, p)
703 if (holder.kind === 'held' && holder.record?.sessionId === selfId) return { kind: 'allow', isImplicit: holder.record.implicit }
704 }
705 if (isSessionRepo && !queue.some(entry => entry.sessionId === selfId)) {
706 await writeQueueEntry(io, p, {
707 sessionId: selfId,
708 label: await labelFor($, ctx),
709 pr: intent.pr,
710 priority: 'normal',
711 requestedAt: now,
712 nonce: io.uuid(),
713 source: 'gate',
714 })
715 await touchAlive(io, p, selfId, now)
716 queue = await readQueue(io, p)
717 }
718 if (isSessionRepo) await publish($, ctx, rt, holder, queue)
719 return { kind: 'deny', text: denyText(ctx, holder, queue, now, isSessionRepo) }
720}
721
722function denyText(ctx: Ctx, holder: HolderView, queue: readonly QueueEntry[], now: number, isQueued: boolean): string {
723 const record = holder.kind === 'held' ? holder.record : null
724 const head = queue[0]
725 const standing =
726 holder.kind === 'held'
727 ? record === null
728 ? 'being taken by another session'
729 : `held by ${record.label} · ${shortId(record.sessionId)}${prText(record.pr)} since ${agoText(now - record.since)}`
730 : `free, but ${head?.label ?? 'another session'}${prText(head?.pr ?? null)} is first in the queue`
731 const index = queue.findIndex(entry => entry.sessionId === ctx.selfId)
732 if (!isQueued || index === -1) {
733 return `merge-lane: the merge lane for ${ctx.repo} is ${standing}. Try the merge again once it frees (this session's /lane covers only its own repo's lane).`
734 }
735 return (
736 `merge-lane: the merge lane for ${ctx.repo} is ${standing}; you are #${index + 1} of ${queue.length} in the queue. ` +
737 'This session is queued and gets a prompt when its turn comes: do not retry the merge or work around this gate. ' +
738 `The person can run /lane prioritize ${shortId(ctx.selfId)} to put it first, or /lane take to break a stale lock.`
739 )
740}
741
742/**
743 * After a merge the gate let through: a merge that landed releases the lane
744 * and wakes the next session; a failed one hands it on when the gate took
745 * the lane just for it; anything unclear asks gh once, and otherwise the
746 * holder's PR poll releases it later. A lane of another repo is always
747 * handed on, since no tick of this session watches it.
748 */
749async function afterMerge($: EngineInterface, ctx: Ctx, rt: Runtime, intent: MergeIntent, isImplicit: boolean, ran: RanLike, target: Target): Promise<void> {
750 const { io, p, selfId } = ctx
751 const holder = await readHolder(io, p)
752 const record = holder.kind === 'held' ? holder.record : null
753 if (record === null || record.sessionId !== selfId) return
754 let outcome = mergeOutcome(intent, ran, record.pr)
755 if (outcome === 'unknown') {
756 const selector = intent.pr ?? record.pr ?? intent.branch
757 const seen = await prView($, selector, selector === null ? null : ctx.repo, target.cwd)
758 if (seen?.state === 'MERGED') outcome = 'merged'
759 else if (seen === null || seen.state === 'CLOSED') outcome = isImplicit ? 'failed' : 'pending'
760 else {
761 outcome = 'pending'
762 const found = seen.number
763 if (record.pr === null && found !== null) await updateRecord(io, p, selfId, held => ({ ...held, pr: found }))
764 }
765 }
766 const pr = intent.pr ?? record.pr
767 const what =
768 intent.kind === 'push-main' || intent.kind === 'push-unknown' ? 'the push to the default branch' : pr === null ? 'the merge' : `PR #${pr}`
769 if (outcome === 'merged') {
770 await releaseLane($, ctx, rt, `${what} landed`, target.isSessionRepo)
771 } else if ((outcome === 'failed' && isImplicit) || !target.isSessionRepo) {
772 await releaseLane($, ctx, rt, outcome === 'failed' ? 'the merge did not go through' : 'the merge ran', target.isSessionRepo)
773 } else if (intent.pr !== null && record.pr === null) {
774 const named = intent.pr
775 await updateRecord(io, p, selfId, held => ({ ...held, pr: named }))
776 }
777}
778
779/* ------------------------------------------------------------------------ */
780/* /lane */
781/* ------------------------------------------------------------------------ */
782
783async function laneCommand($: EngineInterface, rt: Runtime, command: LaneCommand): Promise<string> {
784 if (command.verb === 'help') return USAGE
785 if (command.verb === 'unknown') return `merge-lane: unknown /lane command.\n${USAGE}`
786 if (command.verb === 'auto') {
787 const word = command.rest[0]
788 if (word !== 'on' && word !== 'off') return 'merge-lane: /lane auto on|off'
789 await update($, autoAtom, () => word === 'on')
790 return word === 'on'
791 ? 'merge-lane: auto-proceed on for this session: when the lane comes to it, the proceed-with-merge prompt is submitted for it.'
792 : 'merge-lane: auto-proceed off for this session: when the lane comes to it you get a toast, and the merge is yours to start.'
793 }
794 if (command.verb === 'off' || command.verb === 'on') {
795 const isBypassed = command.verb === 'off'
796 await update($, bypassAtom, () => isBypassed)
797 const ctx = await contextFor($)
798 if (ctx === null) $.ui.status(statusLine({ role: 'none', isBypassed }))
799 else await serialize(rt, async () => publish($, ctx, rt, await readHolder(ctx.io, ctx.p), await readQueue(ctx.io, ctx.p)))
800 return isBypassed
801 ? 'merge-lane: the merge gate is off for this session: its merges skip the lane until /lane on. Other sessions still queue.'
802 : 'merge-lane: the merge gate is on again for this session.'
803 }
804 const ctx = await contextFor($)
805 if (ctx === null) return INERT
806 if (command.verb === 'status') return laneStatus($, ctx, rt)
807 if (command.verb === 'request') return serialize(rt, () => laneRequest($, ctx, rt, command))
808 if (command.verb === 'release') return serialize(rt, () => laneRelease($, ctx, rt))
809 if (command.verb === 'prioritize') return serialize(rt, () => lanePrioritize($, ctx, rt, command))
810 return laneTake($, ctx, rt, command)
811}
812
813async function laneStatus($: EngineInterface, ctx: Ctx, rt: Runtime): Promise<string> {
814 const { io, p, selfId } = ctx
815 await syncSession($, ctx)
816 const now = await io.now()
817 const holder = await readHolder(io, p)
818 const queue = await readQueue(io, p)
819 const alive = await aliveTimes(io, p)
820 const lines = [`Merge lane for ${ctx.repo}`]
821 if (holder.kind === 'free') {
822 lines.push(' Holder: none, the lane is free')
823 } else if (holder.record === null) {
824 lines.push(` Holder: a session mid-claim (lock made ${agoText(now - holder.dirMtime)}, no record yet)`)
825 } else {
826 const record = holder.record
827 const you = record.sessionId === selfId ? ' (this session)' : ''
828 const taken = record.takenFrom === undefined ? '' : `, taken from ${record.takenFrom}`
829 lines.push(
830 ` Holder: ${record.label} · ${shortId(record.sessionId)}${you}${prText(record.pr)}, since ${agoText(now - record.since)}, heartbeat ${agoText(now - holder.heartbeatAt)}${taken}`,
831 )
832 if (looksStale({ now, heartbeatAt: holder.heartbeatAt, aliveAt: alive.get(record.sessionId), staleMinutes: rt.opts.staleMinutes })) {
833 lines.push(' It looks stale: /lane take breaks it')
834 }
835 if (record.pr !== null) lines.push(` ${await prLine($, ctx, record.pr)}`)
836 }
837 if (queue.length === 0) {
838 lines.push(' Queue: empty')
839 } else {
840 lines.push(' Queue:')
841 queue.forEach((entry, index) => {
842 const you = entry.sessionId === selfId ? ' (this session)' : ''
843 const high = entry.priority === 'high' ? ', high' : ''
844 lines.push(` ${index + 1}. ${entry.label} · ${shortId(entry.sessionId)}${you}${prText(entry.pr)}${high}, waiting ${ago(now - entry.requestedAt)}`)
845 })
846 }
847 const isAuto = (await read($, autoAtom)) ?? rt.opts.auto
848 const gate = !rt.opts.enforce ? 'off (enforce option)' : (await read($, bypassAtom)) ? 'off (/lane off)' : 'on'
849 lines.push(` This session: auto-proceed ${isAuto ? 'on' : 'off'}, gate ${gate}`, '', USAGE)
850 await serialize(rt, () => publish($, ctx, rt, holder, queue))
851 return lines.join('\n')
852}
853
854/** The holder's PR and its CI, read with gh; never gh's raw output. */
855async function prLine($: EngineInterface, ctx: Ctx, pr: string): Promise<string> {
856 const ran = await runGh($, ['pr', 'view', pr, '--json', 'state,mergeStateStatus,statusCheckRollup', '-R', ctx.repo], ctx.cwd)
857 if (ran === null) return `PR #${pr}: CI unknown (gh is not available)`
858 if (ran.exitCode !== 0) return `PR #${pr}: CI unknown (gh pr view failed)`
859 let value: unknown
860 try {
861 value = JSON.parse(ran.stdout)
862 } catch {
863 return `PR #${pr}: CI unknown (unreadable gh output)`
864 }
865 if (typeof value !== 'object' || value === null) return `PR #${pr}: CI unknown (unreadable gh output)`
866 const { state, mergeStateStatus, statusCheckRollup } = value as Record<string, unknown>
867 const ci = ciSummary(statusCheckRollup)
868 return `PR #${pr}: ${cleanText(String(state ?? '?'), 20)} · merge state ${cleanText(String(mergeStateStatus ?? '?'), 24)} · checks ${ci.passed} passed, ${ci.failing} failing, ${ci.pending} pending`
869}
870
871/** A PR from `/lane request`'s argument: a number or URL as given, a branch through gh, none through the checked-out branch. */
872async function resolvePr($: EngineInterface, ctx: Ctx, arg: string | undefined): Promise<string | null> {
873 if (arg !== undefined) {
874 const direct = prFromArg(arg)
875 if (direct !== null) return direct
876 if (!/^[\w.][\w./-]{0,199}$/.test(arg)) return null
877 }
878 const seen = await prView($, arg ?? null, arg === undefined ? null : ctx.repo, ctx.cwd)
879 return seen?.number ?? null
880}
881
882async function laneRequest($: EngineInterface, ctx: Ctx, rt: Runtime, command: LaneCommand): Promise<string> {
883 const { io, p, selfId } = ctx
884 await syncSession($, ctx)
885 await ensureLane(io, p)
886 const now = await io.now()
887 const arg = command.rest[0]
888 const holder = await readHolder(io, p)
889 if (holder.kind === 'held' && holder.record?.sessionId === selfId) {
890 const pr = arg === undefined ? null : await resolvePr($, ctx, arg)
891 // a request makes the hold explicit: a failed merge no longer hands the lane on
892 await updateRecord(io, p, selfId, record => ({ ...record, implicit: false, pr: pr ?? record.pr }))
893 await publish($, ctx, rt, await readHolder(io, p), await readQueue(io, p))
894 return `merge-lane: this session already holds the merge lane${prText(pr ?? holder.record.pr)}.`
895 }
896 const existing = (await readQueue(io, p)).find(entry => entry.sessionId === selfId)
897 const pr = await resolvePr($, ctx, arg)
898 const entry: QueueEntry = {
899 sessionId: selfId,
900 label: await labelFor($, ctx),
901 pr: pr ?? existing?.pr ?? null,
902 priority: command.isHigh || existing?.priority === 'high' ? 'high' : 'normal',
903 requestedAt: existing?.requestedAt ?? now,
904 nonce: existing?.nonce ?? io.uuid(),
905 source: 'request',
906 }
907 await writeQueueEntry(io, p, entry)
908 await touchAlive(io, p, selfId, now)
909 await update($, entryAtom, () => entry)
910 const role = await step($, ctx, rt, 'request')
911 if (role === 'holder') {
912 return `merge-lane: the merge lane is yours${prText(entry.pr)}. It frees itself when the PR merges, or run /lane release.`
913 }
914 const after = await readQueue(io, p)
915 const index = after.findIndex(item => item.sessionId === selfId)
916 const now2 = await readHolder(io, p)
917 const ahead = now2.kind === 'held' ? now2.record : after[index - 1]
918 const behind = ahead === null || ahead === undefined ? '' : ` behind ${ahead.label}${prText(ahead.pr)}`
919 const isAuto = (await read($, autoAtom)) ?? rt.opts.auto
920 return `merge-lane: queued #${index + 1} of ${after.length}${behind}. When the lane comes to this session it gets a toast${isAuto ? ' and the proceed-with-merge prompt' : ''}.`
921}
922
923async function laneRelease($: EngineInterface, ctx: Ctx, rt: Runtime): Promise<string> {
924 const { io, p, selfId } = ctx
925 await syncSession($, ctx)
926 const holder = await readHolder(io, p)
927 if (holder.kind === 'held' && holder.record?.sessionId === selfId) {
928 const released = await releaseLane($, ctx, rt, '/lane release')
929 if (!released.isReleased) return 'merge-lane: the lane changed hands before it could be released; run /lane to see it.'
930 return released.next === null
931 ? 'merge-lane: released the merge lane; nobody is waiting.'
932 : `merge-lane: released the merge lane; woke ${released.next.label} · ${shortId(released.next.sessionId)}${prText(released.next.pr)}.`
933 }
934 const queue = await readQueue(io, p)
935 if (queue.some(entry => entry.sessionId === selfId)) {
936 await update($, entryAtom, () => null)
937 await removeQueueEntry(io, p, selfId)
938 await publish($, ctx, rt, holder, await readQueue(io, p))
939 return 'merge-lane: left the merge lane queue.'
940 }
941 return 'merge-lane: this session neither holds nor waits for the merge lane.'
942}
943
944async function lanePrioritize($: EngineInterface, ctx: Ctx, rt: Runtime, command: LaneCommand): Promise<string> {
945 const { io, p, selfId } = ctx
946 await syncSession($, ctx)
947 const now = await io.now()
948 const queue = await readQueue(io, p)
949 const arg = command.rest[0]
950 const pr = arg === undefined ? null : prFromArg(arg)
951 const isPrefix = arg !== undefined && /^[A-Za-z0-9_-]{4,128}$/.test(arg)
952 const matches = queue.filter(entry =>
953 arg === undefined ? entry.sessionId === selfId : (pr !== null && entry.pr === pr) || (isPrefix && entry.sessionId.startsWith(arg)),
954 )
955 if (matches.length === 0) {
956 const holder = await readHolder(io, p)
957 const record = holder.kind === 'held' ? holder.record : null
958 if (record !== null && arg !== undefined && (record.pr === pr || (isPrefix && record.sessionId.startsWith(arg)))) {
959 return `merge-lane: ${record.label}${prText(record.pr)} already holds the lane.`
960 }
961 return arg === undefined
962 ? 'merge-lane: this session is not queued; name a session id (from /lane) or a PR number.'
963 : `merge-lane: no queued session matches ${cleanText(arg, 40)}; /lane lists the ids.`
964 }
965 if (matches.length > 1) {
966 return `merge-lane: ${cleanText(arg ?? '', 40)} matches ${matches.length} sessions (${matches.map(entry => shortId(entry.sessionId)).join(', ')}); give more of the id.`
967 }
968 const target = matches[0]
969 if (target === undefined) return 'merge-lane: nothing to prioritize.'
970 await writeOverlay(io, p, target.sessionId, now, selfId)
971 const after = await readQueue(io, p)
972 if ((await readHolder(io, p)).kind === 'free') {
973 if (after[0]?.sessionId === selfId) await step($, ctx, rt, 'request')
974 else await notifyHead(io, p, ctx.repoKey, selfId)
975 }
976 await publish($, ctx, rt, await readHolder(io, p), await readQueue(io, p))
977 const position = (await readQueue(io, p)).findIndex(entry => entry.sessionId === target.sessionId)
978 return position === -1
979 ? `merge-lane: fronted ${target.label} · ${shortId(target.sessionId)}${prText(target.pr)}; it now holds the lane.`
980 : `merge-lane: fronted ${target.label} · ${shortId(target.sessionId)}${prText(target.pr)}; it is now #${position + 1} of ${after.length}.`
981}
982
983/**
984 * Breaks the current holder and takes the lane. The person confirms in a
985 * dialog first (never mid-turn: then they rerun it idle); a dismissed dialog
986 * is No. --yes skips the dialog only for a lock that looks stale: a live
987 * holder may be mid-merge, so breaking it always takes the dialog's yes. The
988 * break is judged against the holder as it stood when asked, so a lock that
989 * changed hands meanwhile is left alone.
990 */
991async function laneTake($: EngineInterface, ctx: Ctx, rt: Runtime, command: LaneCommand): Promise<string> {
992 const { io, p, selfId } = ctx
993 const now = await io.now()
994 const holder = await readHolder(io, p)
995 const record = holder.kind === 'held' ? holder.record : null
996 if (record?.sessionId === selfId) return 'merge-lane: this session already holds the merge lane.'
997 if (holder.kind === 'held') {
998 const alive = await aliveTimes(io, p)
999 const isStale = looksStale({
1000 now,
1001 heartbeatAt: holder.heartbeatAt,
1002 aliveAt: record === null ? undefined : alive.get(record.sessionId),
1003 staleMinutes: rt.opts.staleMinutes,
1004 })
1005 if (!(command.isYes && isStale)) {
1006 if ((await read($, turnAtom)) !== null) {
1007 return isStale
1008 ? 'merge-lane: a turn is running; run /lane take once it ends, or /lane take --yes to break the stale lock now.'
1009 : 'merge-lane: a turn is running and the holder looks live; run /lane take once the turn ends to confirm breaking it.'
1010 }
1011 const who = record === null ? 'An unlabeled lock' : `${record.label} · ${shortId(record.sessionId)}${prText(record.pr)}`
1012 const question = `${who} holds the merge lane (heartbeat ${agoText(now - holder.heartbeatAt)}, ${isStale ? 'looks stale' : 'looks live'}). Break it and take the lane?`
1013 const answer = await $.ui.ask(question, ['Take the lane', 'Keep it']).catch(() => 'Keep it')
1014 if (answer !== 'Take the lane') return 'merge-lane: kept; nothing changed.'
1015 }
1016 }
1017 return serialize(rt, async () => {
1018 await syncSession($, ctx)
1019 if (holder.kind === 'held') {
1020 const broke = await breakHolder(io, p, judgedOf(holder))
1021 if (broke === 'changed') return 'merge-lane: the lane changed hands while you decided; run /lane take again.'
1022 }
1023 const own = (await readQueue(io, p)).find(entry => entry.sessionId === selfId)
1024 const taken: HolderRecord = {
1025 sessionId: selfId,
1026 label: own?.label ?? (await labelFor($, ctx)),
1027 pr: own?.pr ?? null,
1028 since: await io.now(),
1029 implicit: false,
1030 repo: ctx.repo,
1031 }
1032 if (record !== null) taken.takenFrom = `${record.label} · ${shortId(record.sessionId)}`
1033 if ((await acquire(io, p, taken)) !== 'acquired') return 'merge-lane: another session claimed the lane first; run /lane to see it.'
1034 if (own !== undefined) {
1035 await removeQueueEntry(io, p, selfId)
1036 const notified = await read($, notifiedAtom)
1037 await update($, notifiedAtom, () => [...notified, own.nonce].slice(-MAX_NOTIFIED))
1038 }
1039 if (record !== null) await io.send(record.sessionId, makeEnvelope({ kind: 'lane.changed', repoKey: ctx.repoKey }))
1040 await publish($, ctx, rt, await readHolder(io, p), await readQueue(io, p))
1041 return holder.kind === 'free'
1042 ? 'merge-lane: the lane was free; it is yours now.'
1043 : `merge-lane: took the merge lane${record === null ? '' : ` from ${record.label} · ${shortId(record.sessionId)}`}.`
1044 })
1045}
1046
1047/* ------------------------------------------------------------------------ */
1048/* Host I/O */
1049/* ------------------------------------------------------------------------ */
1050//
1051// The lane's I/O on the real host and the few git/gh reads the mod makes. It
1052// lives in this file because the engine follows `$` only into functions of
1053// the hooks module itself; the protocol (lane.ts) sees only the LaneIO.
1054
1055const FS_TIMEOUT_MS = 10_000
1056const GH_TIMEOUT_MS = 20_000
1057const GIT_TIMEOUT_MS = 10_000
1058
1059/**
1060 * LaneIO over `$`. `mkdir`, `mv`, `rm` and `touch` are the atomic primitives
1061 * `$.fs` lacks, run by argv (no shell). Every path must sit under the lane
1062 * root: a path built wrong is refused before any process runs, so `rm -rf`
1063 * can never reach outside ~/.claude/merge-lane.
1064 */
1065function hostIO($: EngineInterface, root: string): LaneIO {
1066 const inside = (path: string): string => {
1067 if (!path.startsWith(`${root}/`) || path.split('/').includes('..')) {
1068 throw new Error('merge-lane refused a path outside its lane directory')
1069 }
1070 return path
1071 }
1072 const run = (argv: readonly string[]) => $.process.run(argv, { timeoutMs: FS_TIMEOUT_MS })
1073 return {
1074 now: () => $.clock.now(),
1075 uuid: () => crypto.randomUUID(),
1076 async mkdir(path): Promise<MkdirResult> {
1077 const made = await run(['mkdir', inside(path)])
1078 if (made.exitCode === 0) return 'created'
1079 // mkdir exits 1 for "File exists" and for a missing parent or EACCES alike
1080 if (/File exists/i.test(made.stderr)) return 'exists'
1081 return (await $.fs.exists(path)) ? 'exists' : 'error'
1082 },
1083 async mkdirp(paths) {
1084 return (await run(['mkdir', '-p', ...paths.map(inside)])).exitCode === 0
1085 },
1086 async rename(from, to) {
1087 return (await run(['mv', inside(from), inside(to)])).exitCode === 0
1088 },
1089 async remove(paths) {
1090 if (paths.length > 0) await run(['rm', '-rf', ...paths.map(inside)])
1091 },
1092 async touch(path) {
1093 return (await run(['touch', inside(path)])).exitCode === 0
1094 },
1095 async read(path) {
1096 try {
1097 return await $.fs.read(inside(path))
1098 } catch {
1099 return undefined
1100 }
1101 },
1102 async write(path, text) {
1103 await $.fs.write(inside(path), text)
1104 },
1105 async list(dir): Promise<readonly ListedEntry[]> {
1106 try {
1107 return (await $.fs.list(inside(dir))).map(entry => ({
1108 name: entry.name,
1109 kind: entry.kind,
1110 mtimeMs: entry.mtimeMs,
1111 }))
1112 } catch {
1113 return []
1114 }
1115 },
1116 async mtime(path) {
1117 try {
1118 return (await $.fs.stat(inside(path))).mtimeMs
1119 } catch {
1120 return undefined
1121 }
1122 },
1123 async send(sessionId, text): Promise<Delivery> {
1124 try {
1125 const sent = await $.session.send({ to: { sessionId }, text })
1126 return sent.isDelivered ? 'delivered' : 'absent'
1127 } catch {
1128 // A send that failed says nothing about the other session: never prune on it.
1129 return 'unknown'
1130 }
1131 },
1132 }
1133}
1134
1135type GhResult = { exitCode: number; stdout: string }
1136
1137/**
1138 * Runs `gh` by argv with a 20 s bound. A desktop-launched process may not have
1139 * Homebrew on its PATH, so a gh that cannot start is retried through a login
1140 * zsh, its arguments passed as positional parameters (never spliced into the
1141 * script). Null when gh cannot run at all.
1142 */
1143async function runGh($: EngineInterface, args: readonly string[], cwd?: string): Promise<GhResult | null> {
1144 const init = cwd === undefined ? { timeoutMs: GH_TIMEOUT_MS } : { timeoutMs: GH_TIMEOUT_MS, cwd }
1145 try {
1146 const ran = await $.process.run(['gh', ...args], init)
1147 return { exitCode: ran.exitCode, stdout: ran.stdout }
1148 } catch {
1149 try {
1150 const ran = await $.process.run(['/bin/zsh', '-lc', 'exec gh "$@"', 'gh', ...args], init)
1151 return ran.exitCode === 127 ? null : { exitCode: ran.exitCode, stdout: ran.stdout }
1152 } catch {
1153 return null
1154 }
1155 }
1156}
1157
1158/** One line of a git read (`git <args>`), trimmed; null when git fails. */
1159async function gitLine($: EngineInterface, args: readonly string[], cwd?: string): Promise<string | null> {
1160 try {
1161 const init = cwd === undefined ? { timeoutMs: GIT_TIMEOUT_MS } : { timeoutMs: GIT_TIMEOUT_MS, cwd }
1162 const ran = await $.process.run(['git', ...args], init)
1163 const line = ran.stdout.trim().split('\n')[0]?.trim() ?? ''
1164 return ran.exitCode === 0 && line !== '' ? line : null
1165 } catch {
1166 return null
1167 }
1168}
1169
1170/** A PR's state and number from `gh pr view`, or null when gh cannot say. */
1171async function prView(
1172 $: EngineInterface,
1173 selector: string | null,
1174 repo: string | null,
1175 cwd: string,
1176): Promise<{ state: string; number: string | null } | null> {
1177 const args = ['pr', 'view']
1178 if (selector !== null) args.push(selector)
1179 args.push('--json', 'number,state')
1180 // -R needs a selector; without one gh reads the branch checked out in cwd
1181 if (selector !== null && repo !== null) args.push('-R', repo)
1182 const ran = await runGh($, args, cwd)
1183 if (ran === null || ran.exitCode !== 0) return null
1184 try {
1185 const value: unknown = JSON.parse(ran.stdout)
1186 if (typeof value !== 'object' || value === null) return null
1187 const { state, number } = value as { state?: unknown; number?: unknown }
1188 if (typeof state !== 'string') return null
1189 return {
1190 state: cleanText(state, 20).toUpperCase(),
1191 number: typeof number === 'number' && Number.isInteger(number) ? String(number) : null,
1192 }
1193 } catch {
1194 return null
1195 }
1196}
1197
1198function errorText(error: unknown): string {
1199 const message = error instanceof Error ? error.message : String(error)
1200 return cleanText(message, 160) || 'unknown error'hooks/core.ts 983 lines1// merge-lane's pure core: every decision the mod makes, with no I/O.
2//
3// The lane protocol (lane.ts) and the hooks (register.tsx) gather facts from
4// disk and the session, then ask these functions what to do. Keeping the
5// decisions here means the rules the feasibility review pinned down (queue
6// order, the gate's verdict, the envelope check, the liveness rule, the
7// tombstone check after a lock break) are tested as plain functions.
8
9import type { LaneQueueEntry } from '../types'
10
11/** Every cross-session message this mod sends starts with this; nothing else does. */
12export const ENVELOPE_PREFIX = 'merge-lane/v1 '
13
14/** An alive file touched more recently than this proves its session is ticking. */
15export const ALIVE_FRESH_MS = 2 * 60_000
16
17/** The queue head breaks a stale holder by itself only this long after it turned stale. */
18export const AUTO_BREAK_EXTRA_MS = 5 * 60_000
19
20/** Session ids become file names: anything else is refused before a path is built. */
21export const SESSION_ID = /^[A-Za-z0-9_-]{1,128}$/
22
23export type QueueEntry = LaneQueueEntry
24
25/** What holder/holder.json says about the session that holds the lane. */
26export type HolderRecord = {
27 sessionId: string
28 label: string
29 pr: string | null
30 since: number
31 /** True when the gate took the lane for one merge, not a /lane request. */
32 implicit: boolean
33 repo: string
34 takenFrom?: string
35 /** When the holder last ran a merge command through the gate. */
36 attemptAt?: number
37}
38
39/**
40 * The holder as read from disk. A lock directory whose holder.json is missing
41 * or unreadable is still held (it was just made, or its writer died between
42 * mkdir and the write): `record` is null and its age comes from the directory.
43 */
44export type HolderView =
45 | { kind: 'free' }
46 | { kind: 'held'; record: HolderRecord | null; heartbeatAt: number; dirMtime: number }
47
48/* ------------------------------------------------------------------------ */
49/* Repo key */
50/* ------------------------------------------------------------------------ */
51
52/**
53 * Normalizes a git remote so every clone of one repository names one lane:
54 * `git@github.com:Owner/Repo.git`, `ssh://git@github.com:22/owner/repo` and
55 * `https://user:token@github.com/owner/repo/` all become
56 * `github.com/owner/repo`. Userinfo (which can carry a token) is always
57 * dropped, so the result is safe to show. Null for an empty remote.
58 */
59export function normalizeRemote(remote: string): string | null {
60 let s = remote.trim()
61 if (s === '') return null
62 const scheme = /^([a-z][a-z0-9+.-]*):\/\//i.exec(s)
63 if (scheme) {
64 s = s.slice(scheme[0].length)
65 } else if (!/^[a-z]:[\\/]/i.test(s)) {
66 // scp-like `[user@]host:path` (a Windows drive letter is not a host)
67 const scp = /^(?:[^@/\s]+@)?([^:/\s]+):(.*)$/.exec(s)
68 if (scp) s = `${scp[1]}/${scp[2]}`
69 }
70 // userinfo runs to the authority's last `@`: a raw `@` inside a password must not leave a fragment behind
71 const slash = s.indexOf('/')
72 const at = s.lastIndexOf('@', slash === -1 ? s.length : slash)
73 if (at !== -1) s = s.slice(at + 1)
74 s = s.replace(/^([^/:]+):\d+(?=\/|$)/, '$1') // port
75 s = s.replace(/\/+$/, '').replace(/\.git$/i, '').replace(/\/+$/, '')
76 s = s.replace(/\/{2,}/g, '/').toLowerCase()
77 return s === '' ? null : s
78}
79
80/**
81 * Turns a repository named in a command (`-R owner/repo`, `HOST/OWNER/REPO`,
82 * a URL) into the normalized form, borrowing the host from the session's own
83 * remote (else github.com) when the name has none.
84 */
85export function normalizeNamedRepo(named: string, sessionRepo: string | null): string | null {
86 const value = named.trim()
87 if (value === '' || /[\s$`{}]/.test(value)) return null
88 if (/^[a-z][a-z0-9+.-]*:\/\//i.test(value) || value.includes('@')) return normalizeRemote(value)
89 const parts = value.replace(/\.git$/i, '').split('/').filter(part => part !== '')
90 const sessionHost = sessionRepo?.split('/')[0]
91 const host = sessionHost !== undefined && sessionHost.includes('.') ? sessionHost : 'github.com'
92 if (parts.length === 2) return normalizeRemote(`${host}/${parts[0]}/${parts[1]}`)
93 if (parts.length === 3) return normalizeRemote(parts.join('/'))
94 return null
95}
96
97/** The lane's directory name: the first 16 hex characters of the SHA-256 of the normalized remote. */
98export async function repoKeyOf(normalized: string): Promise<string> {
99 const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(normalized))
100 return [...new Uint8Array(digest)]
101 .slice(0, 8)
102 .map(byte => byte.toString(16).padStart(2, '0'))
103 .join('')
104}
105
106/* ------------------------------------------------------------------------ */
107/* Queue order */
108/* ------------------------------------------------------------------------ */
109
110/**
111 * The queue in lane order. A session someone prioritized (an overlay file,
112 * written by whoever ran /lane prioritize, never the entry's own file) comes
113 * first, the most recently prioritized foremost; then `--high` requests; then
114 * the rest. Within a tier, first requested goes first; the session id breaks
115 * ties so every session computes the same order.
116 */
117export function orderQueue(
118 entries: readonly QueueEntry[],
119 overlays: ReadonlyMap<string, number> = new Map(),
120): QueueEntry[] {
121 const tier = (entry: QueueEntry) =>
122 overlays.has(entry.sessionId) ? 0 : entry.priority === 'high' ? 1 : 2
123 return [...entries].sort((a, b) => {
124 const byTier = tier(a) - tier(b)
125 if (byTier !== 0) return byTier
126 if (tier(a) === 0) {
127 const byOverlay = (overlays.get(b.sessionId) ?? 0) - (overlays.get(a.sessionId) ?? 0)
128 if (byOverlay !== 0) return byOverlay
129 }
130 const byTime = a.requestedAt - b.requestedAt
131 if (byTime !== 0) return byTime
132 return a.sessionId < b.sessionId ? -1 : a.sessionId > b.sessionId ? 1 : 0
133 })
134}
135
136/** Reads a queue file's text into an entry, or null when it is not one (a torn write, a stranger). */
137export function parseQueueEntry(text: string | undefined, fileStem: string): QueueEntry | null {
138 const value = parseJsonObject(text)
139 if (value === null) return null
140 const { sessionId, label, pr, priority, requestedAt, nonce, source } = value
141 if (typeof sessionId !== 'string' || sessionId !== fileStem || !SESSION_ID.test(sessionId)) return null
142 if (typeof nonce !== 'string' || nonce === '' || nonce.length > 100) return null
143 if (typeof requestedAt !== 'number' || !Number.isFinite(requestedAt)) return null
144 return {
145 sessionId,
146 label: typeof label === 'string' ? cleanText(label, 80) : sessionId.slice(0, 8),
147 pr: typeof pr === 'string' && /^\d{1,9}$/.test(pr) ? pr : null,
148 priority: priority === 'high' ? 'high' : 'normal',
149 requestedAt,
150 nonce,
151 source: source === 'gate' ? 'gate' : 'request',
152 }
153}
154
155/** Reads holder.json, or null when it is missing, torn or not a holder record. */
156export function parseHolderRecord(text: string | undefined): HolderRecord | null {
157 const value = parseJsonObject(text)
158 if (value === null) return null
159 const { sessionId, label, pr, since, implicit, repo, takenFrom, attemptAt } = value
160 if (typeof sessionId !== 'string' || !SESSION_ID.test(sessionId)) return null
161 if (typeof since !== 'number' || !Number.isFinite(since)) return null
162 const record: HolderRecord = {
163 sessionId,
164 label: typeof label === 'string' ? cleanText(label, 80) : sessionId.slice(0, 8),
165 pr: typeof pr === 'string' && /^\d{1,9}$/.test(pr) ? pr : null,
166 since,
167 implicit: implicit === true,
168 repo: typeof repo === 'string' ? cleanText(repo, 200) : '',
169 }
170 if (typeof takenFrom === 'string') record.takenFrom = cleanText(takenFrom, 80)
171 if (typeof attemptAt === 'number' && Number.isFinite(attemptAt)) record.attemptAt = attemptAt
172 return record
173}
174
175/* ------------------------------------------------------------------------ */
176/* Liveness */
177/* ------------------------------------------------------------------------ */
178
179export type Liveness = {
180 now: number
181 /** The holder's last heartbeat (or, for a lock with no record, its directory's time). */
182 heartbeatAt: number
183 /** When the holder's alive file was last touched; undefined when it has none. */
184 aliveAt: number | undefined
185 staleMinutes: number
186}
187
188/**
189 * Whether the timestamps alone make the holder suspect. This is never enough
190 * to break a lock (after a laptop sleep every timestamp is old while every
191 * session is alive): the caller must also see a positive liveness failure,
192 * an undelivered ping. Both the heartbeat and the alive file must be old.
193 */
194export function looksStale({ now, heartbeatAt, aliveAt, staleMinutes }: Liveness): boolean {
195 const isBeatOld = now - heartbeatAt > staleMinutes * 60_000
196 const isAliveOld = aliveAt === undefined || now - aliveAt > ALIVE_FRESH_MS
197 return isBeatOld && isAliveOld
198}
199
200/**
201 * Whether the queue head may break the holder by itself: stale by time, a
202 * ping the engine says was not delivered (the session is not running), and
203 * five more minutes past the stale mark so an operator's /lane take comes first.
204 */
205export function mayAutoBreak(liveness: Liveness & { isDelivered: boolean }): boolean {
206 return (
207 looksStale(liveness) &&
208 !liveness.isDelivered &&
209 liveness.now - liveness.heartbeatAt > liveness.staleMinutes * 60_000 + AUTO_BREAK_EXTRA_MS
210 )
211}
212
213/** Whether a queue entry's owner is suspect enough to ping before pruning it. */
214export function isEntrySuspect(now: number, aliveAt: number | undefined): boolean {
215 return aliveAt === undefined || now - aliveAt > ALIVE_FRESH_MS
216}
217
218/** What a breaker judged stale: the identity it must find again in the tomb. */
219export type Judged = { sessionId: string | null; since: number | null }
220
221export function judgedOf(view: HolderView): Judged {
222 if (view.kind === 'free') return { sessionId: null, since: null }
223 return view.record === null
224 ? { sessionId: null, since: null }
225 : { sessionId: view.record.sessionId, since: view.record.since }
226}
227
228/**
229 * After renaming holder/ to a tomb, the breaker keeps the tomb only when it
230 * holds the very lock it judged. Anything else means a fresh holder took the
231 * lane between the judgement and the rename, and the tomb goes back.
232 */
233export function isSameHolder(tomb: HolderRecord | null, judged: Judged): boolean {
234 if (judged.sessionId === null) return tomb === null
235 return tomb !== null && tomb.sessionId === judged.sessionId && tomb.since === judged.since
236}
237
238/* ------------------------------------------------------------------------ */
239/* Gate */
240/* ------------------------------------------------------------------------ */
241
242export type MergeKind = 'pr-merge' | 'api-merge' | 'graphql-merge' | 'push-main' | 'push-unknown' | 'mcp-merge'
243
244/** A command that merges (or may push to the default branch), as the gate reads it. */
245export type MergeIntent = {
246 kind: MergeKind
247 /** The PR number the command names, when it names one. */
248 pr: string | null
249 /** The repository the command names (`-R`, a URL, an API path), unnormalized. */
250 repo: string | null
251 /** `gh pr merge <branch>`: the branch named instead of a number. */
252 branch: string | null
253 /** A `cd` before it in the same command, or `git -C`: unresolved, relative or `~`. */
254 dir: string | null
255 /** `gh pr merge --auto`, or the GraphQL auto-merge mutation. */
256 isAuto: boolean
257 /** A `cd` or `git -C` target the text cannot tell (`"$DIR"`, `cd -`): any lane picked for it would be a guess. */
258 isDirUnknown?: boolean
259}
260
261export type GateDecision =
262 | { kind: 'pass' }
263 | { kind: 'allow-holder' }
264 | { kind: 'acquire' }
265 | { kind: 'deny'; position: number; total: number; isFree: boolean }
266
267/**
268 * The gate's verdict over what it read. No merge intent: pass untouched. This
269 * session holds the lane: allow. The lane is free and nobody waits ahead:
270 * acquire, then allow. Otherwise deny, naming this session's place, counting
271 * it in the queue as the gate is about to enqueue it.
272 */
273export function decideGate(input: {
274 intent: MergeIntent | null
275 selfId: string
276 holder: HolderView
277 queue: readonly QueueEntry[]
278}): GateDecision {
279 const { intent, selfId, holder, queue } = input
280 if (intent === null) return { kind: 'pass' }
281 if (holder.kind === 'held' && holder.record?.sessionId === selfId) return { kind: 'allow-holder' }
282 const head = queue[0]
283 const isFree = holder.kind === 'free'
284 if (isFree && (head === undefined || head.sessionId === selfId)) return { kind: 'acquire' }
285 const index = queue.findIndex(entry => entry.sessionId === selfId)
286 return index === -1
287 ? { kind: 'deny', position: queue.length + 1, total: queue.length + 1, isFree }
288 : { kind: 'deny', position: index + 1, total: queue.length, isFree }
289}
290
291/** What happened to a merge the gate let through. */
292export type MergeOutcome = 'merged' | 'failed' | 'pending' | 'unknown'
293
294/** The parts of a tool.call result the outcome reads; every field optional. */
295export type RanLike = {
296 deny?: string
297 isError?: boolean
298 text?: string
299 result?: unknown
300}
301
302type GitOperation = {
303 push?: { branch?: unknown }
304 pr?: { number?: unknown; action?: unknown }
305}
306
307/**
308 * Reads whether the merge landed. The Bash result has no exit code, so this
309 * trusts the engine's own `gitOperation` classification, never the word
310 * "Merged" in the output: `merged` for the PR the lane is held for, and
311 * `auto-merge-enabled` or a backgrounded command is still pending. A push that
312 * did not error has landed unless git printed a rejection.
313 */
314export function mergeOutcome(intent: MergeIntent, ran: RanLike, holderPr: string | null): MergeOutcome {
315 if (ran.deny !== undefined || ran.isError === true) return 'failed'
316 const result = isObject(ran.result) ? ran.result : {}
317 if (typeof result.backgroundTaskId === 'string' && result.backgroundTaskId !== '') return 'pending'
318 if (result.interrupted === true) return 'unknown'
319 const stderr = typeof result.stderr === 'string' ? result.stderr : ''
320 const stdout = typeof result.stdout === 'string' ? result.stdout : (ran.text ?? '')
321 if (intent.kind === 'push-main' || intent.kind === 'push-unknown') {
322 return /!\s*\[(?:remote )?rejected\]|error: failed to push|^fatal:/m.test(stderr) ? 'failed' : 'merged'
323 }
324 const op = (isObject(result.gitOperation) ? result.gitOperation : {}) as GitOperation
325 if (isObject(op.pr)) {
326 if (op.pr.action === 'merged') {
327 const wanted = intent.pr ?? holderPr
328 return wanted === null || String(op.pr.number) === wanted ? 'merged' : 'pending'
329 }
330 if (op.pr.action === 'auto-merge-enabled') return 'pending'
331 }
332 if (intent.kind === 'api-merge' && /"merged"\s*:\s*true/.test(stdout)) return 'merged'
333 return 'unknown'
334}
335
336/* ------------------------------------------------------------------------ */
337/* Command classification */
338/* ------------------------------------------------------------------------ */
339
340const GH_VALUE_FLAGS = new Set([
341 '-R', '--repo', '-b', '--body', '-F', '--body-file', '-t', '--subject', '-A', '--author-email',
342 '--match-head-commit', '-X', '--method', '-f', '--raw-field', '--field', '-H', '--header', '--input',
343 '-q', '--jq', '--template', '--hostname', '--cache', '-p', '--preview',
344])
345const PUSH_VALUE_FLAGS = new Set(['-o', '--push-option', '--repo', '--receive-pack', '--exec'])
346const GIT_GLOBAL_VALUE_FLAGS = new Set([
347 '-c', '--git-dir', '--work-tree', '--namespace', '--config-env', '--super-prefix', '--exec-path',
348])
349const WRAPPERS = new Set(['command', 'builtin', 'exec', 'nohup', 'time', '{', '!', 'then', 'do', 'else', 'if', 'elif', 'while', 'until'])
350const SHELLS = /^(?:ba|z|da|k|fi)?sh$/
351const DEFAULT_BRANCH = /^(?:main|master)$/
352const MAX_DEPTH = 3
353
354/**
355 * Reads a Bash command for a merge: `gh pr merge`, a `gh api` call that
356 * merges (PUT on `pulls/<n>/merge`, POST on `merges`, the GraphQL merge
357 * mutations), or a `git push` that lands on main/master. Quoted text, heredoc
358 * bodies and comments are words, never commands, so a commit message that
359 * mentions `gh pr merge` passes; `sh -c`, `eval`, `xargs`, `env` and the like
360 * are looked through. Null when nothing in it merges. Pure and cheap: the
361 * gate runs it before anything else and runs no process for a null.
362 */
363export function classifyCommand(command: string): MergeIntent | null {
364 return classifyIn(stripHeredocs(command), 0)
365}
366
367function classifyIn(source: string, depth: number): MergeIntent | null {
368 let dir: string | null = null
369 let isDirUnknown = false
370 for (const segment of splitCommands(source)) {
371 const tokens = stripPrefix(segment)
372 const first = tokens[0]
373 if (first === undefined) continue
374 // a prefix that changes directory (`env -C dir`, `sudo -D dir`) lands the command in a repo the text does not pin
375 const isPrefixDir = segment.slice(0, segment.length - tokens.length).some(token => /^(?:-C|-D|--chdir(?:=.*)?)$/.test(token))
376 const base = basename(first)
377 if (base === 'cd' || base === 'pushd') {
378 const target = tokens[1] ?? '~'
379 if (target === '-' || /[$`]/.test(target)) {
380 dir = null
381 isDirUnknown = true
382 } else {
383 // only an absolute target forgets an unknown one; a relative one still hangs off it
384 if (target.startsWith('/') || target.startsWith('~')) isDirUnknown = false
385 dir = joinDir(dir, target)
386 }
387 continue
388 }
389 let intent: MergeIntent | null = null
390 if (SHELLS.test(base)) {
391 const script = shellScript(tokens)
392 if (script !== undefined && depth < MAX_DEPTH) intent = classifyIn(stripHeredocs(script), depth + 1)
393 } else if (base === 'eval') {
394 if (depth < MAX_DEPTH) intent = classifyIn(tokens.slice(1).join(' '), depth + 1)
395 } else if (base === 'gh') {
396 intent = classifyGh(tokens.slice(1))
397 } else if (base === 'git') {
398 intent = classifyGitPush(tokens.slice(1))
399 }
400 if (intent !== null) {
401 return {
402 ...intent,
403 dir: intent.dir === null ? dir : joinDir(dir, intent.dir),
404 isDirUnknown: isDirUnknown || isPrefixDir || intent.isDirUnknown === true,
405 }
406 }
407 }
408 return null
409}
410
411function classifyGh(args: readonly string[]): MergeIntent | null {
412 const { positionals, flags } = parseArgs(args, GH_VALUE_FLAGS)
413 // a help page merges nothing, whatever else the line names
414 if (flags.has('--help') || flags.has('-h')) return null
415 const named = flagValue(flags, '-R') ?? flagValue(flags, '--repo')
416 if (positionals[0] === 'pr' && positionals[1] === 'merge') {
417 if (flags.has('--disable-auto')) return null
418 const selector = positionals[2]
419 let pr: string | null = null
420 let repo = named
421 let branch: string | null = null
422 if (selector !== undefined) {
423 const url = /^https?:\/\/([^/]+)\/([^/]+)\/([^/]+)\/pull\/(\d+)/i.exec(selector)
424 if (url) {
425 pr = url[4] ?? null
426 repo = repo ?? `${url[1]}/${url[2]}/${url[3]}`
427 } else if (/^#?\d{1,9}$/.test(selector)) {
428 pr = selector.replace('#', '')
429 } else {
430 branch = selector
431 }
432 }
433 return { kind: 'pr-merge', pr, repo, branch, dir: null, isAuto: flags.has('--auto') }
434 }
435 if (positionals[0] !== 'api') return null
436 const endpoint = positionals[1] ?? ''
437 if (endpoint === 'graphql') {
438 const all = args.join(' ')
439 const isAuto = /enablePullRequestAutoMerge/.test(all)
440 if (!isAuto && !/mergePullRequest|mergeBranch/.test(all)) return null
441 return { kind: 'graphql-merge', pr: null, repo: named, branch: null, dir: null, isAuto }
442 }
443 const method = flagValue(flags, '-X') ?? flagValue(flags, '--method')
444 const hasFields = ['-f', '-F', '--field', '--raw-field', '--input'].some(name => flags.has(name))
445 const isReadOnly = method !== null ? /^(?:GET|HEAD)$/i.test(method) : !hasFields
446 if (isReadOnly) return null
447 const path = endpoint.replace(/^https?:\/\/[^/]+/i, '').replace(/[?#].*$/, '')
448 const pull = /(?:^|\/)repos\/([^/]+)\/([^/]+)\/pulls\/(\d+)\/merge\/?$/.exec(path)
449 const branchMerge = /(?:^|\/)repos\/([^/]+)\/([^/]+)\/merges\/?$/.exec(path)
450 const hit = pull ?? branchMerge
451 if (hit === null) return null
452 const owner = hit[1] ?? ''
453 const name = hit[2] ?? ''
454 const isPlaceholder = /[{}:]/.test(owner + name)
455 return {
456 kind: 'api-merge',
457 pr: pull?.[3] ?? null,
458 repo: isPlaceholder ? named : `${owner}/${name}`,
459 branch: null,
460 dir: null,
461 isAuto: false,
462 }
463}
464
465function classifyGitPush(args: readonly string[]): MergeIntent | null {
466 let index = 0
467 let dir: string | null = null
468 let isDirUnknown = false
469 while (index < args.length) {
470 const arg = args[index] ?? ''
471 if (!arg.startsWith('-')) break
472 if (arg === '-C') {
473 const target = args[index + 1] ?? ''
474 if (/[$`]/.test(target)) {
475 dir = null
476 isDirUnknown = true
477 } else dir = joinDir(dir, target)
478 index += 2
479 continue
480 }
481 index += GIT_GLOBAL_VALUE_FLAGS.has(arg) ? 2 : 1
482 }
483 if (args[index] !== 'push') return null
484 const { positionals, flags } = parseArgs(args.slice(index + 1), PUSH_VALUE_FLAGS)
485 // a dry run or a help page lands nothing on any branch
486 if (['--dry-run', '-n', '--help', '-h'].some(name => flags.has(name))) return null
487 const intent = (kind: MergeKind): MergeIntent => ({ kind, pr: null, repo: null, branch: null, dir, isAuto: false, isDirUnknown })
488 if (flags.has('--all') || flags.has('--mirror') || flags.has('--branches')) return intent('push-main')
489 const refspecs = positionals.slice(1)
490 if (refspecs.some(isDefaultBranchRef)) return intent('push-main')
491 if (refspecs.length === 0) return flags.has('--tags') ? null : intent('push-unknown')
492 if (refspecs.some(ref => /^\+?(?:HEAD|@)$/.test(ref))) return intent('push-unknown')
493 return null
494}
495
496/** A refspec whose destination is main or master (`main`, `HEAD:main`, `+x:refs/heads/master`, `:main`). */
497export function isDefaultBranchRef(refspec: string): boolean {
498 const bare = refspec.replace(/^\+/, '')
499 const destination = bare.includes(':') ? bare.slice(bare.lastIndexOf(':') + 1) : bare
500 return DEFAULT_BRANCH.test(destination.replace(/^refs\/heads\//, ''))
501}
502
503export function isDefaultBranch(branch: string): boolean {
504 return DEFAULT_BRANCH.test(branch.trim())
505}
506
507/**
508 * Reads an MCP GitHub merge tool's call (`merge_pull_request`), or null for
509 * any other tool. Anchored on the name's last segment, so a read-only tool
510 * that merely contains the words (`get_merge_pr_status`) is never gated.
511 */
512export function classifyMcp(tool: string, input: Readonly<Record<string, unknown>>): MergeIntent | null {
513 if (!/(?:^|__)(?:merge_pull_request|merge_pr)$/i.test(tool)) return null
514 const owner = input.owner
515 const repo = input.repo
516 const number = input.pullNumber ?? input.pull_number ?? input.number
517 return {
518 kind: 'mcp-merge',
519 pr: typeof number === 'number' || (typeof number === 'string' && /^\d{1,9}$/.test(number)) ? String(number) : null,
520 repo: typeof owner === 'string' && typeof repo === 'string' ? `${owner}/${repo}` : null,
521 branch: null,
522 dir: null,
523 isAuto: false,
524 }
525}
526
527/**
528 * Drops heredoc bodies (and their terminator lines); an unterminated one is
529 * kept, so nothing hides. The delimiter is any shell word, quoted or not
530 * (`<<EOF`, `<<-'MSG-END'`, `<<"END.1"`), its quotes removed as the shell
531 * does, so a body line that only mentions a merge never reads as one.
532 */
533export function stripHeredocs(source: string): string {
534 const lines = source.split('\n')
535 const out: string[] = []
536 let index = 0
537 while (index < lines.length) {
538 const line = lines[index] ?? ''
539 out.push(line)
540 index += 1
541 // `<<WORD`, `<<-WORD`, `<<'A-B'`, `<<"A"B`; never a `<<<` here-string (a word cannot start with `<`)
542 const delimiters = [...line.matchAll(/(^|[^<])<<-?[ \t]*((?:'[^'\n]*'|"[^"\n]*"|\\.|[^\s;&|<>()'"\\])+)/g)].map(m =>
543 (m[2] ?? '').replace(/'([^']*)'|"([^"]*)"|\\(.)/g, (_all, single, double, escaped) => single ?? double ?? escaped ?? ''),
544 )
545 for (const delimiter of delimiters) {
546 let end = index
547 while (end < lines.length && (lines[end] ?? '').trim() !== delimiter) end += 1
548 if (end >= lines.length) break
549 index = end + 1
550 }
551 }
552 return out.join('\n')
553}
554
555/**
556 * Splits a command into its simple commands' words: quotes join and are
557 * removed, `;` `&` `|` `(` `)` and newlines end a command, a `#` at a word's
558 * start comments the line out, and each `$(...)` or backtick body is split as
559 * commands of its own (it runs, even inside double quotes).
560 */
561export function splitCommands(source: string, depth = 0): string[][] {
562 const segments: string[][] = []
563 let segment: string[] = []
564 let word = ''
565 let isInWord = false
566 const endWord = () => {
567 if (isInWord) segment.push(word)
568 word = ''
569 isInWord = false
570 }
571 const endSegment = () => {
572 endWord()
573 if (segment.length > 0) segments.push(segment)
574 segment = []
575 }
576 const nested = (body: string) => {
577 if (depth < MAX_DEPTH) segments.push(...splitCommands(body, depth + 1))
578 }
579 for (let i = 0; i < source.length; i++) {
580 const c = source[i] ?? ''
581 if (c === '\\') {
582 if (source[i + 1] === '\n') {
583 i += 1
584 continue
585 }
586 if (i + 1 < source.length) {
587 word += source[i + 1]
588 isInWord = true
589 i += 1
590 }
591 continue
592 }
593 if (c === "'") {
594 const close = source.indexOf("'", i + 1)
595 const stop = close === -1 ? source.length : close
596 word += source.slice(i + 1, stop)
597 isInWord = true
598 i = stop
599 continue
600 }
601 if (c === '"') {
602 let j = i + 1
603 while (j < source.length && source[j] !== '"') {
604 const d = source[j] ?? ''
605 if (d === '\\' && j + 1 < source.length) {
606 word += source[j + 1]
607 j += 2
608 continue
609 }
610 if (d === '$' && source[j + 1] === '(') {
611 const close = matchParen(source, j + 1)
612 nested(source.slice(j + 2, close))
613 word += '$()'
614 j = close + 1
615 continue
616 }
617 if (d === '`') {
618 const close = source.indexOf('`', j + 1)
619 const stop = close === -1 ? source.length : close
620 nested(source.slice(j + 1, stop))
621 j = stop + 1
622 continue
623 }
624 word += d
625 j += 1
626 }
627 isInWord = true
628 i = j
629 continue
630 }
631 if (c === '`') {
632 const close = source.indexOf('`', i + 1)
633 const stop = close === -1 ? source.length : close
634 endSegment()
635 nested(source.slice(i + 1, stop))
636 i = stop
637 continue
638 }
639 if (c === '#' && !isInWord) {
640 const newline = source.indexOf('\n', i)
641 i = newline === -1 ? source.length : newline - 1
642 continue
643 }
644 if (c === ' ' || c === '\t') {
645 endWord()
646 continue
647 }
648 if (c === '\n' || c === ';' || c === '&' || c === '|' || c === '(' || c === ')') {
649 endSegment()
650 continue
651 }
652 word += c
653 isInWord = true
654 }
655 endSegment()
656 return segments
657}
658
659/** The index of the `)` closing the `(` at `open`, skipping quoted text; the end when unclosed. */
660function matchParen(source: string, open: number): number {
661 let depth = 0
662 for (let k = open; k < source.length; k++) {
663 const d = source[k]
664 if (d === '\\') {
665 k += 1
666 continue
667 }
668 if (d === "'") {
669 const close = source.indexOf("'", k + 1)
670 if (close === -1) return source.length
671 k = close
672 continue
673 }
674 if (d === '"') {
675 let m = k + 1
676 while (m < source.length && source[m] !== '"') m += source[m] === '\\' ? 2 : 1
677 k = m
678 continue
679 }
680 if (d === '(') depth += 1
681 if (d === ')') {
682 depth -= 1
683 if (depth === 0) return k
684 }
685 }
686 return source.length
687}
688
689/** Drops what runs a command rather than being it: assignments, `env`, `sudo`, `xargs`, `timeout`, `nice`, ... */
690function stripPrefix(segment: readonly string[]): string[] {
691 let tokens = [...segment]
692 for (let guard = 0; guard < 12 && tokens.length > 0; guard++) {
693 const first = tokens[0] ?? ''
694 if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(first) || WRAPPERS.has(first)) {
695 tokens = tokens.slice(1)
696 continue
697 }
698 const base = basename(first)
699 if (base === 'env') {
700 tokens = skipFlags(tokens.slice(1), new Set(['-u', '--unset', '-C', '--chdir']))
701 } else if (base === 'sudo' || base === 'doas') {
702 tokens = skipFlags(tokens.slice(1), new Set(['-u', '-g', '-h', '-p', '-C', '-U', '-r', '-t', '-T']))
703 } else if (base === 'nice') {
704 tokens = skipFlags(tokens.slice(1), new Set(['-n']))
705 } else if (base === 'timeout' || base === 'gtimeout') {
706 tokens = skipFlags(tokens.slice(1), new Set(['-s', '--signal', '-k', '--kill-after'])).slice(1)
707 } else if (base === 'xargs') {
708 tokens = skipFlags(
709 tokens.slice(1),
710 new Set(['-I', '-i', '-n', '-P', '-L', '-l', '-d', '-E', '-e', '-s', '-a', '--arg-file', '--delimiter', '--max-args', '--max-procs', '--replace']),
711 )
712 } else {
713 break
714 }
715 }
716 return tokens
717}
718
719function skipFlags(tokens: readonly string[], valueFlags: ReadonlySet<string>): string[] {
720 let i = 0
721 while (i < tokens.length) {
722 const token = tokens[i] ?? ''
723 if (token === '--') {
724 i += 1
725 break
726 }
727 if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(token)) {
728 i += 1
729 continue
730 }
731 if (!token.startsWith('-') || token === '-') break
732 i += valueFlags.has(token) ? 2 : 1
733 }
734 return tokens.slice(i)
735}
736
737/** The script of `sh -c '<script>'` (any flag cluster holding `c`), or undefined. */
738function shellScript(tokens: readonly string[]): string | undefined {
739 const flag = tokens.findIndex((token, index) => index > 0 && /^-[A-Za-z]*c[A-Za-z]*$/.test(token))
740 return flag === -1 ? undefined : tokens[flag + 1]
741}
742
743type Parsed = { positionals: string[]; flags: Map<string, string | true> }
744
745function parseArgs(args: readonly string[], valueFlags: ReadonlySet<string>): Parsed {
746 const positionals: string[] = []
747 const flags = new Map<string, string | true>()
748 for (let i = 0; i < args.length; i++) {
749 const arg = args[i] ?? ''
750 if (arg === '--') {
751 positionals.push(...args.slice(i + 1))
752 break
753 }
754 if (arg.startsWith('--')) {
755 const eq = arg.indexOf('=')
756 if (eq > 0) flags.set(arg.slice(0, eq), arg.slice(eq + 1))
757 else if (valueFlags.has(arg)) {
758 flags.set(arg, args[i + 1] ?? '')
759 i += 1
760 } else flags.set(arg, true)
761 continue
762 }
763 if (arg.startsWith('-') && arg.length > 1) {
764 const name = arg.slice(0, 2)
765 if (valueFlags.has(name)) {
766 if (arg.length > 2) flags.set(name, arg.slice(2).replace(/^=/, ''))
767 else {
768 flags.set(name, args[i + 1] ?? '')
769 i += 1
770 }
771 } else {
772 for (const letter of arg.slice(1)) flags.set(`-${letter}`, true)
773 }
774 continue
775 }
776 positionals.push(arg)
777 }
778 return { positionals, flags }
779}
780
781function flagValue(flags: ReadonlyMap<string, string | true>, name: string): string | null {
782 const value = flags.get(name)
783 return typeof value === 'string' && value !== '' ? value : null
784}
785
786function basename(path: string): string {
787 return path.replace(/^.*\//, '')
788}
789
790/** Joins a `cd` target onto the directory before it; absolute and `~` targets restart. */
791export function joinDir(base: string | null, next: string): string {
792 if (next.startsWith('/') || next.startsWith('~') || base === null) return next
793 return `${base.replace(/\/+$/, '')}/${next}`
794}
795
796/** Resolves a classified directory against HOME and the session's cwd; null when it cannot. */
797export function resolveDir(dir: string, home: string | undefined, cwd: string): string | null {
798 if (dir === '~' || dir.startsWith('~/')) return home === undefined ? null : `${home}${dir.slice(1)}`
799 if (dir.startsWith('~')) return null
800 if (dir.startsWith('/')) return dir
801 return `${cwd.replace(/\/+$/, '')}/${dir}`
802}
803
804/* ------------------------------------------------------------------------ */
805/* Envelope */
806/* ------------------------------------------------------------------------ */
807
808export type Envelope =
809 | { kind: 'lane.available'; repoKey: string; nonce: string }
810 | { kind: 'lane.changed'; repoKey: string }
811 | { kind: 'ping' }
812
813export function makeEnvelope(envelope: Envelope): string {
814 return ENVELOPE_PREFIX + JSON.stringify(envelope)
815}
816
817/**
818 * Reads a delivery. `not-ours` for text without the prefix (it passes on
819 * untouched); `invalid` for anything with the prefix that does not parse to a
820 * known kind (it is still consumed, so it never reaches the model). A parsed
821 * envelope is only a hint: the receiver re-reads the lane from disk.
822 */
823export function parseEnvelope(text: string): Envelope | 'not-ours' | 'invalid' {
824 if (!text.startsWith(ENVELOPE_PREFIX)) return 'not-ours'
825 const value = parseJsonObject(text.slice(ENVELOPE_PREFIX.length))
826 if (value === null) return 'invalid'
827 const isKey = (key: unknown): key is string => typeof key === 'string' && /^[0-9a-f]{16}$/.test(key)
828 if (value.kind === 'ping') return { kind: 'ping' }
829 if (value.kind === 'lane.changed' && isKey(value.repoKey)) return { kind: 'lane.changed', repoKey: value.repoKey }
830 if (
831 value.kind === 'lane.available' &&
832 isKey(value.repoKey) &&
833 typeof value.nonce === 'string' &&
834 value.nonce !== '' &&
835 value.nonce.length <= 100
836 ) {
837 return { kind: 'lane.available', repoKey: value.repoKey, nonce: value.nonce }
838 }
839 return 'invalid'
840}
841
842/* ------------------------------------------------------------------------ */
843/* /lane arguments */
844/* ------------------------------------------------------------------------ */
845
846export type LaneVerb = 'status' | 'request' | 'release' | 'prioritize' | 'take' | 'auto' | 'off' | 'on' | 'help'
847
848export type LaneCommand = {
849 verb: LaneVerb | 'unknown'
850 /** The words after the verb, flags removed. */
851 rest: string[]
852 isHigh: boolean
853 isYes: boolean
854}
855
856/** Verbs that change the lane or this session's standing: only the person may run them. */
857export const MUTATING_VERBS: ReadonlySet<string> = new Set(['request', 'release', 'prioritize', 'take', 'auto', 'off', 'on'])
858
859export function parseLaneArgs(args: string): LaneCommand {
860 const words = args.trim().split(/\s+/).filter(word => word !== '')
861 const verb = (words[0] ?? 'status').toLowerCase()
862 const known: readonly string[] = ['status', 'request', 'release', 'prioritize', 'take', 'auto', 'off', 'on', 'help']
863 const rest = words.slice(1)
864 return {
865 verb: known.includes(verb) ? (verb as LaneVerb) : 'unknown',
866 rest: rest.filter(word => !word.startsWith('--')),
867 isHigh: rest.includes('--high'),
868 isYes: rest.includes('--yes'),
869 }
870}
871
872/**
873 * Origins whose /lane is the person's own: the prompt's Enter, the Remote
874 * Control bridge, and `sdk`, since the desktop app hosts its sessions over
875 * the SDK. Every other origin (peers, plugins, schedules) is not the person.
876 */
877export function isPersonOrigin(kind: string): boolean {
878 return kind === 'composer' || kind === 'bridge' || kind === 'sdk'
879}
880
881/** A PR named in `/lane request` or `/lane prioritize`: a number, `#n`, or a pull URL. */
882export function prFromArg(arg: string): string | null {
883 const url = /\/pull\/(\d{1,9})(?:\/|$|[?#])/.exec(arg)
884 if (url) return url[1] ?? null
885 return /^#?\d{1,9}$/.test(arg) ? arg.replace('#', '') : null
886}
887
888/* ------------------------------------------------------------------------ */
889/* Text */
890/* ------------------------------------------------------------------------ */
891
892/** Drops control characters (a label is another session's text drawn in this terminal) and caps the length. */
893export function cleanText(text: string, max: number): string {
894 // eslint-disable-next-line no-control-regex
895 const clean = text.replace(/[\u0000-\u001f\u007f-\u009f--]/g, '').trim()
896 return clean.length > max ? `${clean.slice(0, max - 1)}…` : clean
897}
898
899export function shortId(sessionId: string): string {
900 return sessionId.slice(0, 8)
901}
902
903export function prText(pr: string | null): string {
904 return pr === null ? '' : ` (PR #${pr})`
905}
906
907/** A duration as a person reads it: `just now`, `45 s`, `12 min`, `3 h`. */
908export function ago(ms: number): string {
909 const seconds = Math.max(0, Math.round(ms / 1000))
910 if (seconds < 5) return 'just now'
911 if (seconds < 90) return `${seconds} s`
912 const minutes = Math.round(seconds / 60)
913 if (minutes < 90) return `${minutes} min`
914 const hours = Math.round(minutes / 60)
915 return hours < 48 ? `${hours} h` : `${Math.round(hours / 24)} d`
916}
917
918/** A past moment as a phrase: `just now`, or `12 min ago` (never "just now ago"). */
919export function agoText(ms: number): string {
920 const span = ago(ms)
921 return span === 'just now' ? span : `${span} ago`
922}
923
924export type StatusView =
925 | { role: 'none'; isBypassed: boolean }
926 | { role: 'holder'; pr: string | null; queued: number; isBypassed: boolean }
927 | {
928 role: 'queued'
929 position: number
930 total: number
931 behindLabel: string | null
932 behindPr: string | null
933 isBypassed: boolean
934 }
935
936/** The line under the composer; undefined clears it when this session is not involved. */
937export function statusLine(view: StatusView): string | undefined {
938 const gate = view.isBypassed ? ' · gate off' : ''
939 if (view.role === 'holder') {
940 return `lane: yours${prText(view.pr)}${view.queued > 0 ? ` · ${view.queued} queued` : ''}${gate}`
941 }
942 if (view.role === 'queued') {
943 const behind = view.behindLabel === null ? '' : ` behind ${view.behindLabel}${prText(view.behindPr)}`
944 return `lane: queued #${view.position} of ${view.total}${behind}${gate}`
945 }
946 return view.isBypassed ? 'lane: gate off' : undefined
947}
948
949/** The summary of a PR's checks from `gh pr view --json statusCheckRollup`. */
950export function ciSummary(rollup: unknown): { passed: number; failing: number; pending: number } {
951 const counts = { passed: 0, failing: 0, pending: 0 }
952 if (!Array.isArray(rollup)) return counts
953 for (const check of rollup) {
954 if (!isObject(check)) continue
955 const verdict = String(check.conclusion ?? check.state ?? '').toUpperCase()
956 const status = String(check.status ?? '').toUpperCase()
957 if (verdict === 'SUCCESS' || verdict === 'NEUTRAL' || verdict === 'SKIPPED') counts.passed += 1
958 else if (['FAILURE', 'ERROR', 'CANCELLED', 'TIMED_OUT', 'ACTION_REQUIRED', 'STARTUP_FAILURE'].includes(verdict)) {
959 counts.failing += 1
960 } else if (status === 'COMPLETED' && verdict === '') counts.passed += 1
961 else counts.pending += 1
962 }
963 return counts
964}
965
966/* ------------------------------------------------------------------------ */
967/* Helpers */
968/* ------------------------------------------------------------------------ */
969
970function isObject(value: unknown): value is Record<string, unknown> {
971 return typeof value === 'object' && value !== null && !Array.isArray(value)
972}
973
974export function parseJsonObject(text: string | undefined): Record<string, unknown> | null {
975 if (text === undefined || text.length > 64_000) return null
976 try {
977 const value: unknown = JSON.parse(text)
978 return isObject(value) ? value : null
979 } catch {
980 return null
981 }
982}
983hooks/lane.ts 306 lines1// The on-disk lane protocol, over a small I/O interface so tests can run it
2// against an in-memory world.
3//
4// One lane per repository lives in ~/.claude/merge-lane/<key>/:
5// holder/ the lock: made with mkdir (exit 0 means acquired)
6// holder/holder.json who holds it; written to tmp/ and renamed in, never in place
7// holder/heartbeat touched by the holder every tick (touch never creates holder/)
8// queue/<id>.json one file per waiting session, written by that session alone
9// priority/<id>.json /lane prioritize's overlay, so nobody rewrites another's queue file
10// alive/<id> touched by every involved session every tick
11// tomb.<uuid>/ a lock being broken or released (renamed away, then removed)
12// No file is read-modify-written by two sessions. The only claims are mkdir
13// and rename, each atomic, and a breaker keeps a renamed lock only when it is
14// the one it judged (core.isSameHolder); otherwise it puts it back.
15
16import type { HolderRecord, HolderView, Judged, QueueEntry } from './core'
17import {
18 isEntrySuspect,
19 isSameHolder,
20 makeEnvelope,
21 orderQueue,
22 parseHolderRecord,
23 parseJsonObject,
24 parseQueueEntry,
25 SESSION_ID,
26} from './core'
27
28export type MkdirResult = 'created' | 'exists' | 'error'
29
30/** Whether a message reached a running session: the engine's own word, or unknown when the send failed. */
31export type Delivery = 'delivered' | 'absent' | 'unknown'
32
33export type ListedEntry = { name: string; kind: string; mtimeMs: number }
34
35/**
36 * Everything the protocol does to the world. The host's implementation runs
37 * `mkdir`, `mv`, `rm`, `touch` through `$.process.run` and the rest through
38 * `$.fs` and `$.session.send`; the tests' runs in memory.
39 */
40export interface LaneIO {
41 now(): Promise<number>
42 uuid(): string
43 /** `mkdir <path>` without -p: the lock claim. */
44 mkdir(path: string): Promise<MkdirResult>
45 /** `mkdir -p` for the lane's own directories. */
46 mkdirp(paths: readonly string[]): Promise<boolean>
47 /** `mv <from> <to>` onto a name that does not exist: false when `from` is gone. */
48 rename(from: string, to: string): Promise<boolean>
49 /** `rm -rf`, inside the lane root only. */
50 remove(paths: readonly string[]): Promise<void>
51 /** `touch`: creates the file, never its directory. */
52 touch(path: string): Promise<boolean>
53 read(path: string): Promise<string | undefined>
54 /** A plain write that creates directories; only for files one session owns. */
55 write(path: string, text: string): Promise<void>
56 list(dir: string): Promise<readonly ListedEntry[]>
57 mtime(path: string): Promise<number | undefined>
58 send(sessionId: string, text: string): Promise<Delivery>
59}
60
61export type LanePaths = {
62 root: string
63 lane: string
64 holder: string
65 holderFile: string
66 heartbeat: string
67 queue: string
68 alive: string
69 priority: string
70 tmp: string
71}
72
73export class LaneError extends Error {}
74
75/** Leftover tombs and temp files older than this are swept. */
76const SWEEP_AFTER_MS = 10 * 60_000
77const MAX_NOTIFY = 20
78
79export function lanePaths(root: string, repoKey: string): LanePaths {
80 const lane = `${root}/${repoKey}`
81 return {
82 root,
83 lane,
84 holder: `${lane}/holder`,
85 holderFile: `${lane}/holder/holder.json`,
86 heartbeat: `${lane}/holder/heartbeat`,
87 queue: `${lane}/queue`,
88 alive: `${lane}/alive`,
89 priority: `${lane}/priority`,
90 tmp: `${lane}/tmp`,
91 }
92}
93
94export async function ensureLane(io: LaneIO, p: LanePaths): Promise<void> {
95 if (!(await io.mkdirp([p.queue, p.alive, p.priority, p.tmp]))) {
96 throw new LaneError('could not create the lane directory')
97 }
98}
99
100/**
101 * The holder as it stands. A lock with a missing or torn holder.json is held
102 * (just made, or its writer died mid-claim); its age is the directory's, so
103 * it only ever turns stale by time and an orphan does not block forever.
104 */
105export async function readHolder(io: LaneIO, p: LanePaths): Promise<HolderView> {
106 const dirMtime = await io.mtime(p.holder)
107 if (dirMtime === undefined) return { kind: 'free' }
108 const record = parseHolderRecord(await io.read(p.holderFile))
109 const beat = await io.mtime(p.heartbeat)
110 return { kind: 'held', record, heartbeatAt: Math.max(beat ?? 0, dirMtime), dirMtime }
111}
112
113/** The queue in lane order, priority overlays applied; unreadable files are skipped for this read. */
114export async function readQueue(io: LaneIO, p: LanePaths): Promise<QueueEntry[]> {
115 const entries: QueueEntry[] = []
116 for (const file of await io.list(p.queue)) {
117 const stem = jsonStem(file)
118 if (stem === null) continue
119 const entry = parseQueueEntry(await io.read(`${p.queue}/${file.name}`), stem)
120 if (entry !== null) entries.push(entry)
121 }
122 const overlays = new Map<string, number>()
123 if (entries.length > 0) {
124 for (const file of await io.list(p.priority)) {
125 const stem = jsonStem(file)
126 if (stem === null || !entries.some(entry => entry.sessionId === stem)) continue
127 const at = parseJsonObject(await io.read(`${p.priority}/${file.name}`))?.at
128 if (typeof at === 'number' && Number.isFinite(at)) overlays.set(stem, at)
129 }
130 }
131 return orderQueue(entries, overlays)
132}
133
134/** When each session's alive file was last touched, by session id. */
135export async function aliveTimes(io: LaneIO, p: LanePaths): Promise<Map<string, number>> {
136 const times = new Map<string, number>()
137 for (const file of await io.list(p.alive)) {
138 if (file.kind === 'file' && SESSION_ID.test(file.name)) times.set(file.name, file.mtimeMs)
139 }
140 return times
141}
142
143export async function touchAlive(io: LaneIO, p: LanePaths, selfId: string, now: number): Promise<void> {
144 await io.write(`${p.alive}/${selfId}`, String(now))
145}
146
147export async function writeQueueEntry(io: LaneIO, p: LanePaths, entry: QueueEntry): Promise<void> {
148 await io.write(`${p.queue}/${entry.sessionId}.json`, JSON.stringify(entry))
149}
150
151/** Removes a session's queue file and any overlay on it (its alive file too, when it is gone for good). */
152export async function removeQueueEntry(io: LaneIO, p: LanePaths, sessionId: string, isGone = false): Promise<void> {
153 const paths = [`${p.queue}/${sessionId}.json`, `${p.priority}/${sessionId}.json`]
154 await io.remove(isGone ? [...paths, `${p.alive}/${sessionId}`] : paths)
155}
156
157/** Fronts a queued session: its overlay, written by whoever prioritized it. */
158export async function writeOverlay(io: LaneIO, p: LanePaths, sessionId: string, at: number, by: string): Promise<void> {
159 await io.write(`${p.priority}/${sessionId}.json`, JSON.stringify({ at, by }))
160}
161
162/** Writes holder.json beside the lock by rename, so no reader sees it torn; false when holder/ is gone. */
163async function writeRecord(io: LaneIO, p: LanePaths, record: HolderRecord): Promise<boolean> {
164 const tmp = `${p.tmp}/${record.sessionId}.${io.uuid()}.json`
165 await io.write(tmp, JSON.stringify(record))
166 if (await io.rename(tmp, p.holderFile)) return true
167 await io.remove([tmp])
168 return false
169}
170
171/**
172 * Claims the lane: `mkdir holder/`, then holder.json. `held` when another
173 * session has it; a LaneError when the lock cannot be made at all (a missing
174 * parent, permissions), which the caller must not read as `held`.
175 */
176export async function acquire(io: LaneIO, p: LanePaths, record: HolderRecord): Promise<'acquired' | 'held'> {
177 await ensureLane(io, p)
178 const made = await io.mkdir(p.holder)
179 if (made === 'exists') return 'held'
180 if (made === 'error') throw new LaneError('could not create the lane lock')
181 if (!(await writeRecord(io, p, record)) && !(await writeRecord(io, p, record))) {
182 // The lock is ours but unlabeled: give it up rather than hold a lane nobody can name.
183 const tomb = `${p.lane}/tomb.${io.uuid()}`
184 if (await io.rename(p.holder, tomb)) await io.remove([tomb])
185 throw new LaneError('could not record the lane holder')
186 }
187 await io.touch(p.heartbeat)
188 return 'acquired'
189}
190
191/**
192 * Breaks the lock by renaming it to a tomb: only one rename of holder/
193 * succeeds, so of two breakers one wins and the other reads `gone`. The
194 * winner keeps the tomb only if it holds the lock it judged; a fresh lock
195 * that slipped in between is put back (`changed`). Never `rm -rf holder/`
196 * in place, which could delete a lock someone just took.
197 */
198export async function breakHolder(io: LaneIO, p: LanePaths, judged: Judged): Promise<'broken' | 'gone' | 'changed'> {
199 const tomb = `${p.lane}/tomb.${io.uuid()}`
200 if (!(await io.rename(p.holder, tomb))) return 'gone'
201 const found = parseHolderRecord(await io.read(`${tomb}/holder.json`))
202 if (!isSameHolder(found, judged)) {
203 await restore(io, p, tomb)
204 return 'changed'
205 }
206 await io.remove([tomb])
207 return 'broken'
208}
209
210/** Puts a wrongly taken lock back. mkdir is the claim: if another session took the lane meanwhile, the moved holder has lost it and sees so on its next tick. */
211async function restore(io: LaneIO, p: LanePaths, tomb: string): Promise<void> {
212 if ((await io.mkdir(p.holder)) === 'created') {
213 await io.rename(`${tomb}/holder.json`, p.holderFile)
214 await io.rename(`${tomb}/heartbeat`, p.heartbeat)
215 }
216 await io.remove([tomb])
217}
218
219/** Gives the lane back, only when holder.json names this session; through a tomb like a break. */
220export async function release(io: LaneIO, p: LanePaths, selfId: string): Promise<'released' | 'not-holder'> {
221 const view = await readHolder(io, p)
222 if (view.kind !== 'held' || view.record === null || view.record.sessionId !== selfId) return 'not-holder'
223 const outcome = await breakHolder(io, p, { sessionId: selfId, since: view.record.since })
224 return outcome === 'broken' ? 'released' : 'not-holder'
225}
226
227/** Rewrites this session's holder.json with changes (its PR, a merge attempt); false when it no longer holds. */
228export async function updateRecord(
229 io: LaneIO,
230 p: LanePaths,
231 selfId: string,
232 change: (record: HolderRecord) => HolderRecord,
233): Promise<boolean> {
234 const record = parseHolderRecord(await io.read(p.holderFile))
235 if (record === null || record.sessionId !== selfId) return false
236 return writeRecord(io, p, change(record))
237}
238
239/**
240 * Drops queue entries whose session is gone: only on a positive failure, an
241 * alive file older than two minutes AND a ping the engine says was not
242 * delivered. A sleeping laptop's old timestamps alone prune nothing.
243 */
244export async function pruneDead(
245 io: LaneIO,
246 p: LanePaths,
247 queue: readonly QueueEntry[],
248 selfId: string,
249 now: number,
250): Promise<QueueEntry[]> {
251 const alive = await aliveTimes(io, p)
252 const kept: QueueEntry[] = []
253 for (const entry of queue) {
254 if (entry.sessionId === selfId || !isEntrySuspect(now, alive.get(entry.sessionId))) {
255 kept.push(entry)
256 continue
257 }
258 if ((await io.send(entry.sessionId, makeEnvelope({ kind: 'ping' }))) !== 'absent') {
259 kept.push(entry)
260 continue
261 }
262 await removeQueueEntry(io, p, entry.sessionId, true)
263 }
264 return kept
265}
266
267/**
268 * Wakes the queue head after a release. The message is a hint (the head's own
269 * tick takes a free lane anyway): a head the engine cannot deliver to and
270 * whose alive file is old is pruned and the next is tried, so a dead head
271 * never stalls the sessions behind it.
272 */
273export async function notifyHead(io: LaneIO, p: LanePaths, repoKey: string, selfId: string): Promise<QueueEntry | null> {
274 for (let attempt = 0; attempt < MAX_NOTIFY; attempt++) {
275 if ((await readHolder(io, p)).kind !== 'free') return null
276 const head = (await readQueue(io, p))[0]
277 if (head === undefined || head.sessionId === selfId) return head ?? null
278 const delivery = await io.send(head.sessionId, makeEnvelope({ kind: 'lane.available', repoKey, nonce: head.nonce }))
279 if (delivery !== 'absent') return head
280 const now = await io.now()
281 if (!isEntrySuspect(now, (await aliveTimes(io, p)).get(head.sessionId))) return head
282 await removeQueueEntry(io, p, head.sessionId, true)
283 }
284 return null
285}
286
287/** Removes tombs and temp files a crash left behind. */
288export async function sweep(io: LaneIO, p: LanePaths, now: number): Promise<void> {
289 const stale: string[] = []
290 for (const entry of await io.list(p.lane)) {
291 if (!entry.name.startsWith('tomb.')) continue
292 const mtime = await io.mtime(`${p.lane}/${entry.name}`)
293 if (mtime !== undefined && now - mtime > SWEEP_AFTER_MS) stale.push(`${p.lane}/${entry.name}`)
294 }
295 for (const entry of await io.list(p.tmp)) {
296 if (entry.kind === 'file' && now - entry.mtimeMs > SWEEP_AFTER_MS) stale.push(`${p.tmp}/${entry.name}`)
297 }
298 if (stale.length > 0) await io.remove(stale)
299}
300
301function jsonStem(file: ListedEntry): string | null {
302 if (file.kind !== 'file' || !file.name.endsWith('.json')) return null
303 const stem = file.name.slice(0, -'.json'.length)
304 return SESSION_ID.test(stem) ? stem : null
305}
306types/index.d.ts 44 lines1/** Who this session is on its repo's merge lane, as last read from disk. */
2export type LaneRole = 'holder' | 'queued' | 'none'
3
4/** Why a queue entry exists: the person's /lane request, or the gate queuing a refused merge. */
5export type QueueSource = 'request' | 'gate'
6
7/** One waiting session's request, as written to <lane>/queue/<sessionId>.json by that session alone. */
8export type LaneQueueEntry = {
9 sessionId: string
10 label: string
11 pr: string | null
12 priority: 'high' | 'normal'
13 requestedAt: number
14 nonce: string
15 source: QueueSource
16}
17
18declare module 'claude-code' {
19 interface PluginState {
20 'merge-lane': {
21 /** The lane this session last resolved (16 hex of the remote's SHA-256), or null when inert. */
22 repoKey: string | null
23 role: LaneRole
24 pr: string | null
25 /** The nonce of the queue request that won the lane, or of the one still waiting. */
26 nonce: string | null
27 /** Nonces whose proceed prompt was already submitted, newest last (capped). */
28 notifiedNonces: string[]
29 /** The session id the values above belong to; a /clear rotates the id and voids them. */
30 sessionId: string | null
31 /** This session's own queue entry, kept so a wrongly pruned live entry is re-asserted. */
32 entry: LaneQueueEntry | null
33 /** /lane auto on|off for this session; null follows the auto option. */
34 autoOverride: boolean | null
35 /** /lane off: the gate lets this session merge without the lane. */
36 isBypassed: boolean
37 /** When the holder last polled its PR's state (ms since epoch). */
38 polledAt: number
39 /** The main loop's running turn, so /lane take never opens a dialog mid-turn. */
40 turnId: string | null
41 }
42 }
43}
44