SLOPSHOPPER

session-bridge

Exposes a local HTTP endpoint that sends a prompt to a live Claude Code session, chosen by the directory it runs in.

newcommandtoastprocesstimer
★ 3v0.1.0MITupdated 2026-10-25ZHLX2005/sl/mods/session-bridge
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · session-bridge
› fix the failing auth test and add an audit log call ╭────────────────────────────────────────────╮ │ session-bridge │ ⏺ Read(src/auth.ts) │ session-bridge: the HTTP bridge keeps │ ⎿ Read 6 lines │ failing, so it stays off. │ ⏺ Update(src/auth.ts) ╰────────────────────────────────────────────╯ ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /session-bridge ⎿ session-bridge: session-bridge ⎿ session-bridge: endpoint : http://127.0.0.1:8787 ⎿ session-bridge: token file : /Users/dev/.claude/session-bridge/token ⎿ session-bridge: registry : /Users/dev/.claude/session-bridge ⎿ session-bridge: this session: /work/app ⎿ session-bridge: live sessions: none ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

claude-session-bridge

A local HTTP endpoint that sends a prompt to a live Claude Code session, chosen by the directory that session runs in.

Every session that loads this mod starts one small Node process (bridge/server.mjs). The first session to come up takes the hub port (default 8787) and keeps it; the others retry until it is free, so the hub outlives the session that started it. The hub keeps a registry of the live sessions on disk under ~/.claude/session-bridge/ and routes each request to the session whose directory was named. The prompt enters that session as an ordinary turn, and the turn's answer is what the request gets back.

Using it

The bridge serves a small console on the hub port, so it is easiest to open it in a browser:

http://127.0.0.1:8787/

The console shows the live sessions, looks up the session IDs of a directory, and sends a prompt to a session, showing what it answered. It runs on the bridge's own origin, so it needs no CORS setup; it reads the token from the bridge itself (GET /token) and keeps it in the browser's local storage.

The token lives in ~/.claude/session-bridge/token; every API request must carry it, in the x-bridge-token header or in the body's token field. Inside the session, /session-bridge prints the endpoint, the token file and the live sessions.

TOKEN=$(cat ~/.claude/session-bridge/token)

# the live sessions
curl -s http://127.0.0.1:8787/sessions -H "x-bridge-token: $TOKEN"

# just the session IDs for one cwd (cwd matches the session directory or project root)
curl -sG http://127.0.0.1:8787/sessions \
  -H "x-bridge-token: $TOKEN" \
  --data-urlencode "cwd=D:/code/my-project"

# a prompt for the session running in a directory
curl -s -X POST http://127.0.0.1:8787/prompt \
  -H "content-type: application/json" \
  -H "x-bridge-token: $TOKEN" \
  -d '{"cwd": "D:/code/my-project", "prompt": "run the tests and report"}'

The body of POST /prompt takes cwd (the directory of the session to talk to; its project root is accepted too), prompt (the text to send) and session (which session to pick when several run in that directory; the newest one is taken otherwise). The answer is the session's final text for that turn.

What it needs

Node on the machine (node on the PATH; the mod looks for it there), and nothing else: the bridge is plain Node with no dependencies. The port, the on/off switch and the answer timeout are the mod's own settings, under /config.

What it is not

The endpoint listens on 127.0.0.1 only, and every request needs the token, so only programs on this machine that can read the token file can drive a session. The bridge does not run the prompt itself: the session does, exactly as it would for a prompt typed at its own prompt box.

Source 1 files
hooks/register.ts 384 lines
1import type { EngineInterface, Register } from 'claude-code'
2
3/**
4 * claude-session-bridge
5 *
6 * A local HTTP endpoint that sends a prompt to a live Claude Code session,
7 * chosen by the directory that session runs in.
8 *
9 * The mod holds no server of its own: it starts one small Node process
10 * (`bridge/server.mjs`) per session. That process serves the fixed hub port,
11 * keeps a registry of the live sessions on disk and routes a request to the
12 * session whose directory was named. A prompt arrives here as one JSON line
13 * on the child's stdout; the mod submits it with `$.prompt.submit` and writes
14 * the turn's answer back where the child is waiting for it.
15 */
16
17const PLUGIN = 'session-bridge'
18
19type BridgeLine =
20  | { type: 'ready'; port: number; hubPort: number; cwd: string; sessionId: string }
21  | { type: 'hub'; port: number }
22  | { type: 'prompt'; id: string; text: string }
23
24type Reply = {
25  ok: boolean
26  text?: string
27  error?: string
28  aborted?: boolean
29}
30
31type RegistryEntry = {
32  cwd: string
33  root: string
34  sessionId: string
35  port: number
36  pid: number
37  startedAt: number
38}
39
40// The session's own state. A hot reload starts the module over, and the
41// session's start hook fills these in again.
42
43let hubPort = 8787
44let replyTimeoutMs = 600_000
45let enabled = true
46
47let registryDir = ''
48let sessionCwd = ''
49let sessionRoot = ''
50let sessionId = ''
51let heartbeatPath = ''
52let stopping = false
53let failures = 0
54
55/** The requests whose turn has started, in the order the turns started. */
56const submitted: string[] = []
57let completed = 0
58/** Who answers each request, once its turn has ended. */
59const waiting = new Map<string, (reply: Reply) => void>()
60
61export const register: Register = (on, options) => {
62  hubPort = readNumber(options.port, 8787)
63  replyTimeoutMs = readNumber(options.timeoutMs, 600_000)
64  enabled = options.enabled !== false
65
66  on('session.start', async ($, e, next) => {
67    const started = await next(e)
68    sessionCwd = e.cwd
69    sessionRoot = await $.session.root().catch(() => e.cwd)
70    sessionId = await $.session.id().catch(() => '')
71    registryDir = await resolveRegistryDir($)
72    heartbeatPath = joinPath(registryDir, 'heartbeat', `${sessionId || 'session'}.txt`)
73
74    if (enabled) {
75      await beat($)
76      $.clock.every(5000, () => {
77        void beat($)
78      })
79      void runBridge($).catch((error) => {
80        $.ui.log(`[${PLUGIN}] ${describe(error)}`, { to: 'debug' })
81      })
82    }
83
84    await $.command.register({
85      name: 'session-bridge',
86      description: 'Shows the local HTTP bridge: its endpoint, its token and the live sessions.',
87    })
88
89    return started
90  })
91
92  on('turn.complete', async ($, e, next) => {
93    const result = await next(e)
94
95    if (e.agentId === undefined) {
96      const id = submitted[completed]
97      if (id !== undefined) {
98        completed += 1
99        const answer = waiting.get(id)
100        waiting.delete(id)
101        if (answer) {
102          answer({
103            ok: !e.isAborted,
104            text: e.answer,
105            aborted: e.isAborted,
106            error: e.isAborted ? 'the turn was interrupted' : undefined,
107          })
108        }
109      }
110    }
111
112    return result
113  })
114
115  on('session.end', async ($, e, next) => {
116    // A /clear or a resume ends the session but not the process: the bridge
117    // stays, and the session it serves carries on under another id.
118    if (e.reason !== 'clear' && e.reason !== 'resume') {
119      stopping = true
120    }
121    for (const answer of waiting.values()) {
122      answer({ ok: false, error: 'the session ended before the turn did' })
123    }
124    waiting.clear()
125    return next(e)
126  })
127
128  on('command.run', { command: 'session-bridge' }, async ($) => {
129    const sessions = await readRegistry($)
130    const tokenFile = joinPath(registryDir, 'token')
131    const lines = [
132      PLUGIN,
133      `  endpoint   : http://127.0.0.1:${hubPort}`,
134      `  token file : ${tokenFile}`,
135      `  registry   : ${registryDir}`,
136      `  this session: ${sessionCwd}`,
137    ]
138    if (sessions.length === 0) {
139      lines.push('  live sessions: none')
140    } else {
141      lines.push('  live sessions:')
142      for (const entry of sessions) {
143        lines.push(`    ${entry.cwd}  (session ${entry.sessionId}, port ${entry.port})`)
144      }
145    }
146    lines.push('')
147    lines.push('  send a prompt:')
148    lines.push(`    curl -s -X POST http://127.0.0.1:${hubPort}/prompt \\`)
149    lines.push('      -H "content-type: application/json" \\')
150    lines.push(`      -H "x-bridge-token: $(cat '${tokenFile}')" \\`)
151    lines.push(
152      `      -d '{"cwd": ${JSON.stringify(sessionCwd)}, "prompt": "your prompt here"}'`,
153    )
154    return { text: lines.join('\n') }
155  })
156
157  return undefined
158}
159
160// ------------------------------------------------------------------- bridge
161
162/**
163 * The mod's own pulse. The bridge reads it and leaves when it stops: a bridge
164 * whose session died would otherwise hold the hub port for ever.
165 */
166async function beat($: EngineInterface): Promise<void> {
167  if (!heartbeatPath) return
168  try {
169    await $.fs.write(heartbeatPath, String(Date.now()))
170  } catch {
171    // the bridge will notice and wait for the next beat
172  }
173}
174
175/**
176 * The bridge, and the loop that reads what it says. The loop is the child's
177 * life: it ends with the child, or with this module.
178 */
179async function runBridge($: EngineInterface): Promise<void> {
180  const node = await findNode($)
181  if (!node) {
182    failures += 1
183    $.ui.toast(`${PLUGIN}: node was not found, so the HTTP bridge stays off.`)
184    return
185  }
186
187  const script = joinPath($.plugin.root, 'bridge', 'server.mjs')
188  const child = $.process.spawn({
189    argv: [
190      node,
191      script,
192      '--registry',
193      registryDir,
194      '--cwd',
195      sessionCwd,
196      '--root',
197      sessionRoot,
198      '--session',
199      sessionId,
200      '--hub-port',
201      String(hubPort),
202      '--heartbeat',
203      heartbeatPath,
204      '--timeout-ms',
205      String(replyTimeoutMs),
206    ],
207    cwd: sessionCwd,
208  })
209
210  let buffer = ''
211  for await (const chunk of child) {
212    if (chunk.stream === 'stderr') {
213      $.ui.log(`[${PLUGIN}] ${chunk.text.trim()}`, { to: 'debug' })
214      continue
215    }
216    buffer += chunk.text
217    let newline = buffer.indexOf('\n')
218    while (newline >= 0) {
219      const line = buffer.slice(0, newline).trim()
220      buffer = buffer.slice(newline + 1)
221      if (line) {
222        void onLine($, line)
223      }
224      newline = buffer.indexOf('\n')
225    }
226  }
227
228  if (stopping) return
229
230  failures += 1
231  if (failures > 5) {
232    $.ui.toast(`${PLUGIN}: the HTTP bridge keeps failing, so it stays off.`)
233    return
234  }
235  await $.clock.sleep(2000)
236  await runBridge($)
237}
238
239async function onLine($: EngineInterface, line: string): Promise<void> {
240  let data: BridgeLine
241  try {
242    data = JSON.parse(line) as BridgeLine
243  } catch {
244    return
245  }
246
247  if (data.type === 'ready') {
248    failures = 0
249    $.ui.toast(`${PLUGIN}: http://127.0.0.1:${hubPort} is open for this session.`)
250    return
251  }
252  if (data.type === 'hub') {
253    return
254  }
255  if (data.type === 'prompt') {
256    void handlePrompt($, data)
257  }
258}
259
260async function handlePrompt(
261  $: EngineInterface,
262  request: { id: string; text: string },
263): Promise<void> {
264  const reply = await deliver($, request)
265  try {
266    await $.fs.write(
267      joinPath(registryDir, 'results', `${request.id}.json`),
268      JSON.stringify(reply),
269    )
270  } catch (error) {
271    $.ui.log(`[${PLUGIN}] the answer could not be written back: ${describe(error)}`, {
272      to: 'debug',
273    })
274  }
275}
276
277/** Submits the prompt and waits for the turn it starts to end. */
278function deliver(
279  $: EngineInterface,
280  request: { id: string; text: string },
281): Promise<Reply> {
282  return new Promise<Reply>((resolve) => {
283    waiting.set(request.id, resolve)
284    void (async () => {
285      try {
286        await $.prompt.submit({ text: request.text, asUser: true })
287        submitted.push(request.id)
288      } catch (error) {
289        waiting.delete(request.id)
290        resolve({ ok: false, error: describe(error) })
291      }
292    })()
293  })
294}
295
296// ------------------------------------------------------------------ helpers
297
298function describe(error: unknown): string {
299  if (error instanceof Error) return error.message
300  return String(error)
301}
302
303function readNumber(value: unknown, fallback: number): number {
304  if (typeof value === 'number' && Number.isFinite(value)) return value
305  if (typeof value === 'string' && value.trim() !== '') {
306    const parsed = Number(value)
307    if (Number.isFinite(parsed)) return parsed
308  }
309  return fallback
310}
311
312/** Joins path pieces with the separator the first one already uses. */
313function joinPath(...pieces: string[]): string {
314  const usable = pieces.filter((piece) => piece !== '')
315  if (usable.length === 0) return ''
316  const separator = usable.some((piece) => piece.includes('\\')) ? '\\' : '/'
317  return usable
318    .map((piece, index) =>
319      index === 0
320        ? piece.replace(/[\\/]+$/, '')
321        : piece.replace(/^[\\/]+/, '').replace(/[\\/]+$/, ''),
322    )
323    .join(separator)
324}
325
326async function resolveRegistryDir($: EngineInterface): Promise<string> {
327  const home = (await $.env.get('HOME')) ?? (await $.env.get('USERPROFILE'))
328  if (!home) return joinPath('.claude', 'session-bridge')
329  return joinPath(home, '.claude', 'session-bridge')
330}
331
332/** The node the bridge runs on: on the PATH, or where the PATH says it is. */
333async function findNode($: EngineInterface): Promise<string | undefined> {
334  for (const candidate of ['node', 'nodejs']) {
335    try {
336      const { exitCode } = await $.process.run([candidate, '--version'])
337      if (exitCode === 0) return candidate
338    } catch {
339      // not this one
340    }
341  }
342
343  const path = await $.env.get('PATH')
344  if (!path) return undefined
345  const separator = path.includes(';') ? ';' : ':'
346  for (const folder of path.split(separator)) {
347    if (!folder) continue
348    for (const name of ['node.exe', 'node']) {
349      const candidate = joinPath(folder, name)
350      try {
351        if (!(await $.fs.exists(candidate))) continue
352        const { exitCode } = await $.process.run([candidate, '--version'])
353        if (exitCode === 0) return candidate
354      } catch {
355        // not this one
356      }
357    }
358  }
359  return undefined
360}
361
362/** What the registry holds of the sessions that are still alive. */
363async function readRegistry($: EngineInterface): Promise<RegistryEntry[]> {
364  const folder = joinPath(registryDir, 'sessions')
365  let names: { name: string; kind: string }[]
366  try {
367    names = await $.fs.list(folder)
368  } catch {
369    return []
370  }
371  const entries: RegistryEntry[] = []
372  for (const entry of names) {
373    if (entry.kind !== 'file' || !entry.name.endsWith('.json')) continue
374    try {
375      const text = await $.fs.read(joinPath(folder, entry.name))
376      const parsed = JSON.parse(text) as RegistryEntry
377      if (parsed && typeof parsed.cwd === 'string') entries.push(parsed)
378    } catch {
379      // a file the bridge is writing, or one that is gone
380    }
381  }
382  return entries
383}
384