Compare commits
1 Commits
0324a47d13
...
3229fbd024
| Author | SHA1 | Date | |
|---|---|---|---|
| 3229fbd024 |
@@ -1,4 +1,7 @@
|
|||||||
import { spawn, type ChildProcess } from 'node:child_process'
|
import { spawn, type ChildProcess } from 'node:child_process'
|
||||||
|
import { existsSync } from 'node:fs'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
import { homedir } from 'node:os'
|
||||||
import type { SynthesisConfig } from './config.js'
|
import type { SynthesisConfig } from './config.js'
|
||||||
import type { SessionMapping } from './session-mapping.js'
|
import type { SessionMapping } from './session-mapping.js'
|
||||||
|
|
||||||
@@ -78,14 +81,10 @@ export class ProcessManager {
|
|||||||
return handle
|
return handle
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Bridge server calls this when it sees a `hello` frame matching this pid. */
|
/** Bridge server calls this when it sees a `hello` frame for this session. */
|
||||||
markReady(pid: number): void {
|
markReady(openclawSessionKey: string): void {
|
||||||
for (const h of this.byKey.values()) {
|
const h = this.byKey.get(openclawSessionKey)
|
||||||
if (h.pid === pid) {
|
if (h) h.markReady()
|
||||||
h.markReady()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
touch(openclawSessionKey: string): void {
|
touch(openclawSessionKey: string): void {
|
||||||
@@ -97,36 +96,59 @@ export class ProcessManager {
|
|||||||
const { config } = this.deps
|
const { config } = this.deps
|
||||||
const wsUrl = `ws://127.0.0.1:${config.channelWsPort}/bridge`
|
const wsUrl = `ws://127.0.0.1:${config.channelWsPort}/bridge`
|
||||||
const channelTag = `server:${config.channelName}`
|
const channelTag = `server:${config.channelName}`
|
||||||
|
// First spawn for this session-id uses --session-id (fresh); subsequent
|
||||||
|
// spawns (after idle reap / OOM / crash) use --resume to pick up the
|
||||||
|
// saved transcript at ~/.claude/projects/<workspace-slug>/<uuid>.jsonl.
|
||||||
|
const hasExistingSession = claudeSessionFileExists(workspace, claudeSessionUuid)
|
||||||
|
const sessionFlag = hasExistingSession
|
||||||
|
? ['--resume', claudeSessionUuid]
|
||||||
|
: ['--session-id', claudeSessionUuid]
|
||||||
|
// Pass channel ONLY via --dangerously-load-development-channels.
|
||||||
|
// Passing the same channel through both --channels and
|
||||||
|
// --dangerously-load-development-channels makes Claude Code list it
|
||||||
|
// twice ("Listening for channel messages from: X, X") and the second
|
||||||
|
// entry never inherits the dev-mode flag — the status panel ends up
|
||||||
|
// saying "server: entries need --dangerously-load-development-channels"
|
||||||
|
// even after the dev-mode confirmation. The dev flag implies the
|
||||||
|
// channel is loaded, so --channels is redundant here.
|
||||||
|
const debugFile = `/tmp/synthesis-claude-${openclawSessionKey.replace(/[^a-z0-9]/gi, '_')}.debug.log`
|
||||||
const args = [
|
const args = [
|
||||||
'--channels', channelTag,
|
|
||||||
'--dangerously-load-development-channels', channelTag,
|
'--dangerously-load-development-channels', channelTag,
|
||||||
'--resume', claudeSessionUuid,
|
...sessionFlag,
|
||||||
'--permission-mode', config.permissionMode,
|
'--permission-mode', config.permissionMode,
|
||||||
|
'--debug-file', debugFile,
|
||||||
]
|
]
|
||||||
|
|
||||||
this.log.info(`spawning claude session=${openclawSessionKey} claude_uuid=${claudeSessionUuid} workspace=${workspace}`)
|
this.log.info(`spawning claude session=${openclawSessionKey} claude_uuid=${claudeSessionUuid} workspace=${workspace}`)
|
||||||
|
|
||||||
const proc = spawn('claude', args, {
|
// Wrap claude in `script(1)` to give it a PTY. Without a TTY, Claude
|
||||||
|
// Code's interactive prompts (workspace-trust, dev-channels confirm)
|
||||||
|
// block forever waiting for keystrokes that never arrive. `script -q -c`
|
||||||
|
// forks a PTY, runs the command inside it, and discards the transcript
|
||||||
|
// to /dev/null. Stdin we write to is the PTY master.
|
||||||
|
const claudeCmd = ['claude', ...args].map(a => /[\s"]/.test(a) ? `"${a.replace(/"/g, '\\"')}"` : a).join(' ')
|
||||||
|
const ptyLog = `/tmp/synthesis-claude-${openclawSessionKey.replace(/[^a-z0-9]/gi, '_')}.pty`
|
||||||
|
const proc = spawn('script', ['-q', '-c', claudeCmd, ptyLog], {
|
||||||
cwd: workspace,
|
cwd: workspace,
|
||||||
env: {
|
env: {
|
||||||
...process.env,
|
...process.env,
|
||||||
SYNTHESIS_WS_URL: wsUrl,
|
SYNTHESIS_WS_URL: wsUrl,
|
||||||
SYNTHESIS_OPENCLAW_SESSION: openclawSessionKey,
|
SYNTHESIS_OPENCLAW_SESSION: openclawSessionKey,
|
||||||
SYNTHESIS_CLAUDE_SESSION: claudeSessionUuid,
|
SYNTHESIS_CLAUDE_SESSION: claudeSessionUuid,
|
||||||
|
TERM: 'xterm-256color',
|
||||||
},
|
},
|
||||||
stdio: ['pipe', 'pipe', 'pipe'],
|
stdio: ['pipe', 'pipe', 'pipe'],
|
||||||
detached: false,
|
detached: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
// After ~500ms, send "1\n" to confirm the dev-channels dialog. Idempotent
|
// Pre-feed Enter keystrokes through the PTY to dismiss any startup
|
||||||
// if the dialog isn't there (claude eats the keystroke harmlessly).
|
// prompts (dev-channels confirm; trusted-workspace if anything slipped
|
||||||
setTimeout(() => {
|
// through). Cheap to over-send — Claude eats unexpected keystrokes.
|
||||||
try {
|
for (const delay of [800, 1500, 2500, 4000, 6000]) {
|
||||||
proc.stdin?.write('1\n')
|
setTimeout(() => {
|
||||||
} catch {
|
try { proc.stdin?.write('\r') } catch { /* ignore */ }
|
||||||
/* ignore */
|
}, delay)
|
||||||
}
|
}
|
||||||
}, 500)
|
|
||||||
|
|
||||||
let resolveReady!: () => void
|
let resolveReady!: () => void
|
||||||
const ready = new Promise<void>(r => { resolveReady = r })
|
const ready = new Promise<void>(r => { resolveReady = r })
|
||||||
@@ -181,6 +203,11 @@ export class ProcessManager {
|
|||||||
return [...this.byKey.values()]
|
return [...this.byKey.values()]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Async helper exposed for tests / admin tools. */
|
||||||
|
static sessionFileExists(workspace: string, uuid: string): boolean {
|
||||||
|
return claudeSessionFileExists(workspace, uuid)
|
||||||
|
}
|
||||||
|
|
||||||
async shutdown(): Promise<void> {
|
async shutdown(): Promise<void> {
|
||||||
this.shuttingDown = true
|
this.shuttingDown = true
|
||||||
if (this.idleSweeper) clearInterval(this.idleSweeper)
|
if (this.idleSweeper) clearInterval(this.idleSweeper)
|
||||||
@@ -190,3 +217,18 @@ export class ProcessManager {
|
|||||||
this.byKey.clear()
|
this.byKey.clear()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Claude Code persists session transcripts at:
|
||||||
|
* ~/.claude/projects/<workspace-slug>/<session-uuid>.jsonl
|
||||||
|
* where workspace-slug is the absolute workspace path with `/` → `-`.
|
||||||
|
*/
|
||||||
|
function claudeSessionFileExists(workspace: string, uuid: string): boolean {
|
||||||
|
try {
|
||||||
|
const slug = workspace.replace(/\//g, '-')
|
||||||
|
const path = join(homedir(), '.claude', 'projects', slug, `${uuid}.jsonl`)
|
||||||
|
return existsSync(path)
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
11
index.ts
11
index.ts
@@ -1,5 +1,12 @@
|
|||||||
import { definePluginEntry } from 'openclaw/plugin-sdk/plugin-entry'
|
// Local `definePluginEntry` shim. The OpenClaw plugin runtime evaluates this
|
||||||
import type { OpenClawPluginApi } from 'openclaw/plugin-sdk/core'
|
// module and calls .register() on the default export — it doesn't care where
|
||||||
|
// definePluginEntry came from. Defining it locally lets us run standalone
|
||||||
|
// (`bun index.ts`) without the `openclaw` package installed.
|
||||||
|
function definePluginEntry<T extends { id: string; name: string; description?: string; register: (api: any) => void | Promise<void> }>(opts: T): T {
|
||||||
|
return opts
|
||||||
|
}
|
||||||
|
type OpenClawPluginApi = unknown
|
||||||
|
|
||||||
import fs from 'node:fs'
|
import fs from 'node:fs'
|
||||||
import path from 'node:path'
|
import path from 'node:path'
|
||||||
import { normalizeConfig, type SynthesisConfig } from './core/config.js'
|
import { normalizeConfig, type SynthesisConfig } from './core/config.js'
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ export function startBridgeServer(deps: BridgeDeps): { httpServer: http.Server;
|
|||||||
const prev = byKey.get(conn.openclawSessionKey)
|
const prev = byKey.get(conn.openclawSessionKey)
|
||||||
if (prev && prev.ws !== ws) try { prev.ws.close(4002, 'replaced by reconnect') } catch {}
|
if (prev && prev.ws !== ws) try { prev.ws.close(4002, 'replaced by reconnect') } catch {}
|
||||||
byKey.set(conn.openclawSessionKey, conn)
|
byKey.set(conn.openclawSessionKey, conn)
|
||||||
processManager.markReady(conn.pid)
|
processManager.markReady(conn.openclawSessionKey)
|
||||||
log.info(`hello session=${conn.openclawSessionKey} pid=${conn.pid}`)
|
log.info(`hello session=${conn.openclawSessionKey} pid=${conn.pid}`)
|
||||||
ws.send(JSON.stringify({ type: 'hello_ack' }))
|
ws.send(JSON.stringify({ type: 'hello_ack' }))
|
||||||
return
|
return
|
||||||
@@ -123,6 +123,7 @@ export function startBridgeServer(deps: BridgeDeps): { httpServer: http.Server;
|
|||||||
|
|
||||||
// ── HTTP server (OpenAI-compatible /v1/chat/completions) ────────────────────
|
// ── HTTP server (OpenAI-compatible /v1/chat/completions) ────────────────────
|
||||||
const httpServer = http.createServer(async (req, res) => {
|
const httpServer = http.createServer(async (req, res) => {
|
||||||
|
log.info(`[trace] http req method=${req.method} url=${req.url}`)
|
||||||
if (req.method === 'GET' && req.url === '/health') {
|
if (req.method === 'GET' && req.url === '/health') {
|
||||||
res.writeHead(200, { 'Content-Type': 'application/json' })
|
res.writeHead(200, { 'Content-Type': 'application/json' })
|
||||||
res.end(JSON.stringify({ ok: true, processes: processManager.list().length, conns: byKey.size }))
|
res.end(JSON.stringify({ ok: true, processes: processManager.list().length, conns: byKey.size }))
|
||||||
@@ -132,14 +133,24 @@ export function startBridgeServer(deps: BridgeDeps): { httpServer: http.Server;
|
|||||||
res.writeHead(404); res.end(); return
|
res.writeHead(404); res.end(); return
|
||||||
}
|
}
|
||||||
|
|
||||||
let bodyRaw = ''
|
// Read the body synchronously (Promise-wrapped) so the outer `async`
|
||||||
req.on('data', c => { bodyRaw += c })
|
// handler holds the response open until we explicitly end it. Returning
|
||||||
req.on('end', async () => {
|
// from the outer fn before res.end() causes Bun's http server to close
|
||||||
let body: any
|
// the connection.
|
||||||
try { body = JSON.parse(bodyRaw) } catch {
|
const bodyRaw = await new Promise<string>(resolve => {
|
||||||
res.writeHead(400, { 'Content-Type': 'application/json' })
|
let buf = ''
|
||||||
res.end(JSON.stringify({ error: 'invalid_json' })); return
|
req.on('data', c => { buf += c })
|
||||||
}
|
req.on('end', () => resolve(buf))
|
||||||
|
})
|
||||||
|
|
||||||
|
log.info(`[trace] body received bytes=${bodyRaw.length}`)
|
||||||
|
let body: any
|
||||||
|
try { body = JSON.parse(bodyRaw); log.info(`[trace] body parsed`) } catch (e) {
|
||||||
|
log.warn(`[trace] body parse FAILED: ${e}`)
|
||||||
|
res.writeHead(400, { 'Content-Type': 'application/json' })
|
||||||
|
res.end(JSON.stringify({ error: 'invalid_json' })); return
|
||||||
|
}
|
||||||
|
{
|
||||||
|
|
||||||
// Minimal session-key extraction for v1 — use headers where OpenClaw
|
// Minimal session-key extraction for v1 — use headers where OpenClaw
|
||||||
// doesn't natively send them. Production OpenClaw runtime injects
|
// doesn't natively send them. Production OpenClaw runtime injects
|
||||||
@@ -159,12 +170,19 @@ export function startBridgeServer(deps: BridgeDeps): { httpServer: http.Server;
|
|||||||
}
|
}
|
||||||
|
|
||||||
const completionId = `chatcmpl-synthesis-${randomUUID().slice(0, 8)}`
|
const completionId = `chatcmpl-synthesis-${randomUUID().slice(0, 8)}`
|
||||||
res.writeHead(200, {
|
log.info(`[trace] before writeHead session=${sessionKey} user=${latestUser.substring(0,50)}`)
|
||||||
'Content-Type': 'text/event-stream',
|
try {
|
||||||
'Cache-Control': 'no-cache',
|
res.writeHead(200, {
|
||||||
'Connection': 'keep-alive',
|
'Content-Type': 'text/event-stream',
|
||||||
'Transfer-Encoding': 'chunked',
|
'Cache-Control': 'no-cache',
|
||||||
})
|
'Connection': 'keep-alive',
|
||||||
|
'Transfer-Encoding': 'chunked',
|
||||||
|
})
|
||||||
|
log.info(`[trace] after writeHead`)
|
||||||
|
} catch (e) {
|
||||||
|
log.warn(`[trace] writeHead FAILED: ${e}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// SSE heartbeat: empty content delta every HEARTBEAT_MS to keep
|
// SSE heartbeat: empty content delta every HEARTBEAT_MS to keep
|
||||||
// OpenClaw's LLM idle watchdog from firing during long quiet turns.
|
// OpenClaw's LLM idle watchdog from firing during long quiet turns.
|
||||||
@@ -215,7 +233,7 @@ export function startBridgeServer(deps: BridgeDeps): { httpServer: http.Server;
|
|||||||
clearInterval(sseHeartbeat)
|
clearInterval(sseHeartbeat)
|
||||||
releaseSlot()
|
releaseSlot()
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
httpServer.listen(config.bridgePort, '127.0.0.1', () => {
|
httpServer.listen(config.bridgePort, '127.0.0.1', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user