SLOPSHOPPER

lane-complete

Type @ at a word start to complete lane-mail lanes (@@ passes through to the stock menu)

newbandpromptprocesstimer
★ 2v0.1.0MITupdated 2026-10-09mgalgs/fork-sandbox/mods/lane-complete
A shopper browsing a rack in a slop shop
README

lane-complete

A Claude Code mod (a plugin with a hooks module; needs Claude Code 2.1.287 or newer) that makes @ at the prompt complete lane-mail lanes: @fs, @docs, @fs:otherhost.

What it does

  • @ typed at a word start (start of the box, or after whitespace) opens a picker in the band above the prompt. A mid-word @ (an email address) is left alone. While the picker is open the box shows a full-width @ and what you have typed after it (@al), then one space with the cursor before it (Claude Code sends no Ctrl-F for a cursor that cannot move, and the cursor would otherwise sit at the box's end); the space is removed again whenever the picker closes and is never sent. The mark is not an ASCII @ because that opens Claude Code's own menu over the picker; and the box must not be empty, because Claude Code sends no Enter or Backspace to a mod for an empty one.
  • The characters you type next filter the list: prefix matches first, then substring matches. The list is live lanes (registered sessions), then lanes that only have a mailbox, then remote lanes that have written to this host (see below), then each local lane qualified with each peer host from the peers file (@lane:host).
  • Enter inserts the highlighted match (the top one until you move it, see Keys) where the @ was, followed by a space. It does not send the prompt. With no match, the @ and what you typed are put back as plain text (also not sent).
  • A space or other non-lane character, or a cursor move, cancels: the @ and what you typed become a plain @ and text (and Claude Code's own menu may open on it).
  • @@: a second @ right after the first closes the picker and lets a single @ through, so Claude Code's own agent/file menu opens as usual.
  • With no lanes known, @ is not swallowed at all.
  • On submit the text is never rewritten. If it names a known lane, a note for the model is attached saying it is a lane-mail address and how to message it (lane-mail.sh send --from @<own lane> --to @<lane> --subject ... --body -). Whether to send, or only to refer to the lane, is Claude's call from your wording. The mod never sends anything itself.

Remote lanes that have written here

A lane on another host, such as @builder:hostb, is offered once it has sent this host a message. bin/list-lanes.sh reads the headers of every message in the local thread store ($LANE_MAIL_ROOT/threads/) and takes each From: @lane:host address. Picking one inserts the full @lane:host, and a prompt that mentions it gets the same lane-mail note as a local lane.

Limits:

  • Only From: counts. A To: or Cc: address (a mistyped one nobody answered, say) is never offered, and neither is an address quoted in a message body: only the header block, up to the first blank line, is read.
  • A lane that has never written here is not offered; type it as @lane:host (or pick the peer-qualified form of a local lane name).
  • Addresses on this host's own peer name are skipped; malformed lane or host names are dropped.
  • Offline: no ssh and no peer query, just one pass over the local store each time the list is refreshed. A lane that stops writing stays listed for as long as its messages are kept.

Keys

Claude Code hands a mod only the keys the editor takes as an edit (letters, Backspace, Left/Right, ...). Tab, Esc, Up and Down never reach it, so the picker uses none of them; Enter reaches it as a submit.

KeyIn the picker
letters, digits, - . :filter
Ctrl-Fhighlight the next match (down the band); stops at the last
Ctrl-Bhighlight the previous match (up the band); stops at the first
Enterinsert the highlighted match (the band's > row); nothing is sent
Backspacetrim the filter; on an empty filter, drop the @
@ (on an empty filter)@@: close, pass one @ to the stock menu
space or other punctuation, a cursor movenot a lane: cancel, keeping what was typed

Ctrl-F and Ctrl-B are the keys because they are the ones that reach a mod (Up, Down, Tab, Ctrl-N/P do not); while the picker is open they never move the cursor or change the box, and with it closed they are the editor's own cursor-forward/back. Typing or trimming the filter puts the highlight back on the top match; typing more of the name (@fs: narrows to the cross-host forms) is the other way to reach a lane. The cursor ends at the end of the prompt after an Enter, wherever the @ was.

Try it

claude --plugin-dir mods/lane-complete

The lane list is read in the background every few seconds by bin/list-lanes.sh (never per keystroke), which takes the lane-mail root, peers file and registry directory from the lane-mail scripts. It looks for them in this checkout's scripts/ first, then wherever lane-mail.sh is on PATH.

Development

claude plugin validate mods/lane-complete
claude plugin test mods/lane-complete
tests/lane-complete-list-lanes-test.sh

The picker logic is in hooks/lanes.ts and has no engine in it; hooks/register.tsx wires it to prompt.edit, prompt.submit and the band.

Source 3 files
hooks/register.tsx 104 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { LanePicker } from '../types'
5import { VISIBLE, finish, highlighted, matchLanes, mentionContext, mentions, parseLanes, plain, shown, step } from './lanes'
6import type { Lane } from './lanes'
7
8// How often the lane list is re-read, in the background; never per keystroke.
9const REFRESH_MS = 5000
10
11const picker = atom({ plugin: 'lane-complete', key: 'picker' } as const, null as LanePicker | null)
12
13// The lane list the hooks read: refreshed by a timer, so never read per
14// keystroke. A reload starts it empty and session.start fills it again; a
15// failed read keeps the last good list (a missed refresh is not worth a toast).
16let lanes: Lane[] = []
17
18async function refresh($: EngineInterface) {
19  try {
20    const ran = await $.process.run(['bash', `${$.plugin.root}/bin/list-lanes.sh`], { timeoutMs: 5000 })
21    if (ran.exitCode === 0) lanes = parseLanes(ran.stdout)
22  } catch {
23    // keep the last list
24  }
25}
26
27export const register: Register = on => {
28  on('session.start', async ($, e, next) => {
29    await refresh($)
30    $.clock.every(REFRESH_MS, () => refresh($))
31
32    return next(e)
33  })
34
35  // Every edit goes through the picker. Closed, it passes all but a word-start
36  // `@`; open, it owns the edits until Backspace-on-empty, a cursor move or a
37  // non-lane character closes it, or Enter takes the choice (below). The text
38  // itself is edited here, in the answer, so nothing depends on the band
39  // taking keyboard focus. Only edits arrive here: Enter, Tab, Esc and
40  // Up/Down never do.
41  on('prompt.edit', async ($, e, next) => {
42    const open = await read($, picker)
43    if (open === null && !(e.inputText === '@' && e.start === e.end)) return next(e)
44
45    const result = step(open, lanes, e)
46    if (result.picker !== open) await update($, picker, () => result.picker)
47
48    if (result.replay !== undefined) return next({ ...e, ...result.replay })
49    return result.box ?? next(e)
50  })
51
52  // Enter with the picker open is the choice, not a send: the submit is
53  // dropped and the box put back with the best match (or, with none, as it
54  // stands, `@` and filter in it as plain text) in place of the `@` and what was typed.
55  // Otherwise a submit never rewrites the text: it only adds a note for the
56  // model when the prompt names a lane.
57  on('prompt.submit', async ($, e, next) => {
58    const open = await read($, picker)
59    if (open !== null) {
60      await update($, picker, () => null)
61      if (e.text.trim() === shown(open).text.trim()) {
62        // The cursor ends at the box's end: fill has no way to place it.
63        await $.prompt.fill({ text: finish(open, lanes).text, mode: 'replace' })
64        return { drop: 'lane picked' }
65      }
66      // A box that is not the one shown was changed behind the picker: send it
67      // as it is, with a real `@` for the mark and without the space we added.
68      e = { ...e, text: plain(open, e.text).text }
69    }
70
71    const named = mentions(e.text, lanes)
72    if (named.length === 0) return next(e)
73
74    return next({ ...e, context: [...(e.context ?? []), mentionContext(named)] })
75  }).catch(($, e, next) => next(e)) // a failed note never costs the operator the prompt
76
77  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
78    const open = await read($, picker)
79    if (open === null) return next(e)
80
81    const { Box, Text } = $.ui.resolve(e)
82    const matches = matchLanes(lanes, open.filter)
83    const shown = matches.slice(0, VISIBLE)
84    const current = highlighted(open, lanes)
85
86    return (
87      <Box flexDirection="column">
88        <Text bold>
89          lane @{open.filter}
90          <Text dimColor> ({matches.length} of {lanes.length})</Text>
91        </Text>
92        {shown.length === 0 && <Text dimColor>  no lane matches; Enter keeps @{open.filter} as typed</Text>}
93        {shown.map((lane, i) => (
94          <Text key={lane.address} bold={i === current} color={i === current ? 'cyan' : undefined} dimColor={lane.source === 'peer' && i !== current}>
95            {i === current ? '> ' : '  '}
96            {lane.address}
97          </Text>
98        ))}
99        <Text dimColor>Enter inserts the marked row · C-f next, C-b previous · keep typing to narrow · Backspace trims · space cancels · @ again: stock menu</Text>
100      </Box>
101    )
102  })
103}
104
hooks/lanes.ts 268 lines
1// The lane-complete logic with no engine in it: parsing the lane list,
2// filtering it, stepping the picker on one prompt.edit, and finding lane
3// mentions in a submitted prompt. register.tsx wires these to the hooks; the
4// tests drive them directly (prompt.edit has no call on the test's `$`).
5
6import type { LanePicker } from '../types'
7
8export type Lane = {
9  /** The lane-mail address as it is typed: `@lane` or `@lane:host`. */
10  address: string
11  source: 'live' | 'mailbox' | 'remote' | 'peer'
12}
13
14/** The part of a prompt.edit input the picker reads. */
15export type Edit = {
16  key?: { key: string; ctrl?: true; shift?: true; meta?: true }
17  text: string
18  cursor: number
19  start: number
20  end: number
21  inputText: string
22}
23
24/** What one edit did: the picker after it, and who answers the edit. */
25export type Step = {
26  picker: LanePicker | null
27  /** Set: answer the edit with this box and never call `next` (the key is ours). */
28  box?: { text: string; cursor: number }
29  /** Set: pass the edit on to `next` as if typed into this box (the `@` of an `@@`). */
30  replay?: { text: string; cursor: number; start: number; end: number }
31}
32
33/** How many matches the band shows at once. */
34export const VISIBLE = 6
35
36const NAME = /^[a-z0-9][a-z0-9.-]*$/
37const REMOTE = /^[a-z0-9][a-z0-9-]*:[a-z0-9][a-z0-9.-]*$/
38const FILTER_CHARS = /^[A-Za-z0-9:.-]+$/
39
40/**
41 * Parses list-lanes.sh output (`<source>\t<name>` lines) into addresses:
42 * live lanes, then mailbox-only lanes, then remote lanes that have written
43 * here (`lane:host`), then each local lane qualified with each peer host (the
44 * peers file names hosts, not their lanes, and a lane is usually run on both
45 * ends of a link).
46 */
47export function parseLanes(stdout: string): Lane[] {
48  const live: string[] = []
49  const mailbox: string[] = []
50  const remote: string[] = []
51  const peers: string[] = []
52  for (const line of stdout.split('\n')) {
53    const [source, name] = line.split('\t')
54    if (name === undefined) continue
55    if (source === 'remote') {
56      if (REMOTE.test(name)) remote.push(name)
57      continue
58    }
59    if (!NAME.test(name)) continue
60    if (source === 'live') live.push(name)
61    else if (source === 'mailbox') mailbox.push(name)
62    else if (source === 'peer') peers.push(name)
63  }
64  const lanes: Lane[] = []
65  const seen = new Set<string>()
66  const add = (address: string, source: Lane['source']) => {
67    if (seen.has(address)) return
68    seen.add(address)
69    lanes.push({ address, source })
70  }
71  for (const name of live) add(`@${name}`, 'live')
72  for (const name of mailbox) add(`@${name}`, 'mailbox')
73  for (const name of remote) add(`@${name}`, 'remote')
74  for (const name of [...live, ...mailbox]) {
75    for (const peer of peers) add(`@${name}:${peer}`, 'peer')
76  }
77  return lanes
78}
79
80/** The lanes a typed filter leaves: prefix matches first, then substring ones. */
81export function matchLanes(lanes: readonly Lane[], filter: string): Lane[] {
82  const f = filter.toLowerCase()
83  const prefix: Lane[] = []
84  const inside: Lane[] = []
85  for (const lane of lanes) {
86    const name = lane.address.slice(1)
87    if (name.startsWith(f)) prefix.push(lane)
88    else if (name.includes(f)) inside.push(lane)
89  }
90  return [...prefix, ...inside]
91}
92
93function splice(text: string, at: number, insert: string) {
94  return { text: text.slice(0, at) + insert + text.slice(at), cursor: at + insert.length }
95}
96
97/** A `@` typed here would be a mention, not the tail of an email address. */
98function isWordStart(text: string, at: number): boolean {
99  return at === 0 || /\s/.test(text.charAt(at - 1))
100}
101
102/** The row the picker has highlighted, kept inside the visible matches. */
103export function highlighted(p: LanePicker, lanes: readonly Lane[], by = 0): number {
104  const rows = Math.min(matchLanes(lanes, p.filter).length, VISIBLE)
105  return Math.max(0, Math.min((p.index ?? 0) + by, rows - 1))
106}
107
108/** The box with the picker's choice at its anchor, in place of the `@` and filter; one space after it unless one follows. */
109function accept(p: LanePicker, address: string) {
110  const at = Math.min(p.anchor, p.text.length)
111  const space = /\s/.test(p.text.charAt(at)) ? '' : ' '
112  return splice(p.text, at, `${address}${space}`)
113}
114
115/**
116 * What stands in the box for the `@` while the picker is open: a full-width
117 * commercial at, which looks like one but is not one to Claude Code's own
118 * `@` menu (an ASCII `@` in the box opens that menu over the picker).
119 */
120export const MARK = '\uFF20'
121
122/**
123 * The box while the picker is open: the mark and what was typed after it sit
124 * in the text at the anchor, so the box is never empty (Claude Code fires no
125 * submit and no Backspace edit for an empty one) and the operator sees what
126 * they typed. `p.text` is the box without them.
127 *
128 * One space follows the filter, with the cursor before it: the editor emits
129 * no edit for a cursor-forward that cannot move (the cursor at the box's end,
130 * where it sits after a start-of-box `@al`), and C-f must always arrive. The
131 * space is ours alone: `plain` takes it out again on every way out.
132 */
133export function shown(p: LanePicker) {
134  const at = Math.min(p.anchor, p.text.length)
135  return { text: splice(p.text, at, `${MARK}${p.filter} `).text, cursor: at + 1 + p.filter.length }
136}
137
138/** Where `shown` puts its space (the cursor sits here). */
139function padAt(p: LanePicker): number {
140  return Math.min(p.anchor, p.text.length) + 1 + p.filter.length
141}
142
143/**
144 * A box as `shown` made it, as plain text: a real `@` where the mark is and
145 * the space taken out, which is what any way out of the picker leaves. A
146 * position in the shown box maps to `at(i)` here. A box that is not quite the
147 * shown one (changed behind the picker) loses only the parts that are there.
148 */
149export function plain(p: LanePicker, text: string) {
150  const anchor = Math.min(p.anchor, text.length)
151  const pad = padAt(p)
152  const marked = text.charAt(anchor) === MARK
153  const unmarked = marked ? `${text.slice(0, anchor)}@${text.slice(anchor + 1)}` : text
154  const padded = marked && text.charAt(pad) === ' '
155  return {
156    text: padded ? unmarked.slice(0, pad) + unmarked.slice(pad + 1) : unmarked,
157    at: (i: number) => (padded && i > pad ? i - 1 : i),
158  }
159}
160
161/**
162 * The box an Enter pressed with the picker open leaves: the highlighted match
163 * in place of the `@` and filter, or with none the `@` and filter as plain text.
164 * The caller drops the submit and puts this box back.
165 */
166export function finish(p: LanePicker, lanes: readonly Lane[]) {
167  const best = matchLanes(lanes, p.filter)[highlighted(p, lanes)]
168  return best === undefined ? { text: plain(p, shown(p).text).text, cursor: shown(p).cursor } : accept(p, best.address)
169}
170
171/**
172 * One prompt.edit through the picker. `picker` is null while closed. A closed
173 * picker opens on a swallowed word-start `@` (only when there are lanes to
174 * pick: with none, the `@` goes on to the stock menu), answering with the box
175 * that has the `@` in it; an open one swallows every edit until it closes.
176 *
177 * Only keys the editor takes as an edit reach prompt.edit: Enter, Tab, Esc and
178 * the arrows Up/Down never do, so none of them is handled here. Enter arrives
179 * as a prompt.submit and is answered by `finish`.
180 */
181export function step(picker: LanePicker | null, lanes: readonly Lane[], e: Edit): Step {
182  // A picker whose box is not the one shown is stale (the box was cleared or
183  // changed behind it): forget it and treat this edit as one with no picker.
184  if (picker !== null && shown(picker).text !== e.text) picker = null
185
186  if (picker === null) {
187    const isAt = e.inputText === '@' && e.start === e.end && isWordStart(e.text, e.start)
188    if (!isAt || lanes.length === 0) return { picker: null }
189    const opened = { anchor: e.start, text: e.text, filter: '' }
190    return { picker: opened, box: shown(opened) }
191  }
192
193  const key = e.key
194  const name = key !== undefined && key.key.length > 1 ? key.key : undefined
195  const isMod = key !== undefined && (key.ctrl === true || key.meta === true)
196  const open = (next: LanePicker): Step => ({ picker: next, box: shown(next) })
197  // Closed with the box as the edit leaves it, `@` and filter kept as plain text.
198  const close = (box: { text: string; cursor: number }): Step => ({ picker: null, box })
199
200  // Backspace trims the filter; on an empty one it undoes the `@` itself.
201  const isBackspace = name === 'backspace' || (name === undefined && e.inputText === '' && e.end > e.start && !isMod)
202  if (isBackspace) {
203    if (picker.filter === '') return close({ text: picker.text, cursor: Math.min(picker.anchor, picker.text.length) })
204    return open({ anchor: picker.anchor, text: picker.text, filter: picker.filter.slice(0, -1) })
205  }
206
207  // C-f / C-b are the editor's cursor-forward/back, which would cancel the
208  // picker; here they move the highlight (next / previous), clamped at both
209  // ends, and the box is answered as shown so the cursor never moves.
210  if (key !== undefined && key.ctrl === true && key.meta !== true && (key.key === 'f' || key.key === 'b')) {
211    return open({ ...picker, index: highlighted({ ...picker, index: highlighted(picker, lanes) }, lanes, key.key === 'f' ? 1 : -1) })
212  }
213
214  // `@@`: a second `@` right after the swallowed one goes on to the stock
215  // menu, as the one `@` it would have been: the edit is replayed on the box
216  // without ours.
217  if (e.inputText === '@' && picker.filter === '') {
218    const at = Math.min(picker.anchor, picker.text.length)
219    return { picker: null, replay: { text: picker.text, cursor: at, start: at, end: at } }
220  }
221
222  if (name === undefined && !isMod && FILTER_CHARS.test(e.inputText)) {
223    return open({ anchor: picker.anchor, text: picker.text, filter: picker.filter + e.inputText.toLowerCase() })
224  }
225
226  // Anything else (a space, punctuation, a paste of prose, a cursor move, an
227  // unknown key) is not a lane: the `@` and what was typed stay as plain text,
228  // with the character typed after them.
229  const kept = plain(picker, e.text)
230  if (name === undefined && !isMod && e.inputText !== '') {
231    const start = kept.at(e.start)
232    const text = kept.text.slice(0, start) + e.inputText + kept.text.slice(kept.at(e.end))
233    return close({ text, cursor: start + e.inputText.length })
234  }
235  return close({ text: kept.text, cursor: kept.at(e.cursor) })
236}
237
238/**
239 * The known lanes a submitted prompt mentions, in order of first mention.
240 * A mention is a whole `@lane` or `@lane:host` word (a trailing sentence
241 * mark is allowed) that is in the lane list: an `@file.ts` or an email
242 * address is never one.
243 */
244export function mentions(text: string, lanes: readonly Lane[]): string[] {
245  const known = new Set(lanes.map(l => l.address))
246  const found: string[] = []
247  const re = /(?:^|\s)(@[a-z0-9][a-z0-9.-]*(?::[a-z0-9][a-z0-9.-]*)?)(?=[.,;:!?)\]"']*(?:\s|$))/g
248  for (const m of text.matchAll(re)) {
249    // The pattern is greedy over dots and a sentence-final one is not part of the name.
250    const address = (m[1] ?? '').replace(/[.]+$/, '')
251    if (known.has(address) && !found.includes(address)) found.push(address)
252  }
253  return found
254}
255
256/** The note the model reads beside a prompt that mentions lanes. */
257export function mentionContext(addresses: readonly string[]): string {
258  const list = addresses.join(', ')
259  return [
260    `The operator's prompt mentions lane-mail ${addresses.length === 1 ? 'lane' : 'lanes'}: ${list}.`,
261    'Those are lane-mail addresses (an agent session\'s mailbox), not files or agents.',
262    'A lane can be messaged with:',
263    '  lane-mail.sh send --from @<your own lane> --to @<lane> --subject ... --body -',
264    '(message body on stdin; `lane-mail.sh registration` prints your own lane; `@lane:host` is a lane on another host).',
265    'Decide from the prompt\'s wording whether the operator wants a message sent or is only referring to the lane; send nothing unless they asked.',
266  ].join('\n')
267}
268
types/index.d.ts 18 lines
1// The mod's $.state contract: the lane picker, null while it is closed.
2export type LanePicker = {
3  /** Where the picked address goes: the cursor when the picker opened. */
4  anchor: number
5  /** The box text the picker opened on, without the `@` and filter it shows in the box; a box other than the one shown means it is stale. */
6  text: string
7  /** What the operator has typed since the swallowed `@`; Enter takes the highlighted match of it. */
8  filter: string
9  /** The highlighted row among the visible matches (C-f down, C-b up); absent is the top one, and any change of the filter puts it back there. */
10  index?: number
11}
12
13declare module 'claude-code' {
14  interface PluginState {
15    'lane-complete': { picker: LanePicker | null }
16  }
17}
18