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

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.
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.
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.
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.
hooks/register.ts 384 lines1import 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