diff --git a/core/process-manager.ts b/core/process-manager.ts index c4ae9bf..05ade00 100644 --- a/core/process-manager.ts +++ b/core/process-manager.ts @@ -1,4 +1,7 @@ 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 { SessionMapping } from './session-mapping.js' @@ -78,14 +81,10 @@ export class ProcessManager { return handle } - /** Bridge server calls this when it sees a `hello` frame matching this pid. */ - markReady(pid: number): void { - for (const h of this.byKey.values()) { - if (h.pid === pid) { - h.markReady() - return - } - } + /** Bridge server calls this when it sees a `hello` frame for this session. */ + markReady(openclawSessionKey: string): void { + const h = this.byKey.get(openclawSessionKey) + if (h) h.markReady() } touch(openclawSessionKey: string): void { @@ -97,36 +96,59 @@ export class ProcessManager { const { config } = this.deps const wsUrl = `ws://127.0.0.1:${config.channelWsPort}/bridge` 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//.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 = [ - '--channels', channelTag, '--dangerously-load-development-channels', channelTag, - '--resume', claudeSessionUuid, + ...sessionFlag, '--permission-mode', config.permissionMode, + '--debug-file', debugFile, ] 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, env: { ...process.env, SYNTHESIS_WS_URL: wsUrl, SYNTHESIS_OPENCLAW_SESSION: openclawSessionKey, SYNTHESIS_CLAUDE_SESSION: claudeSessionUuid, + TERM: 'xterm-256color', }, stdio: ['pipe', 'pipe', 'pipe'], detached: false, }) - // After ~500ms, send "1\n" to confirm the dev-channels dialog. Idempotent - // if the dialog isn't there (claude eats the keystroke harmlessly). - setTimeout(() => { - try { - proc.stdin?.write('1\n') - } catch { - /* ignore */ - } - }, 500) + // Pre-feed Enter keystrokes through the PTY to dismiss any startup + // prompts (dev-channels confirm; trusted-workspace if anything slipped + // through). Cheap to over-send — Claude eats unexpected keystrokes. + for (const delay of [800, 1500, 2500, 4000, 6000]) { + setTimeout(() => { + try { proc.stdin?.write('\r') } catch { /* ignore */ } + }, delay) + } let resolveReady!: () => void const ready = new Promise(r => { resolveReady = r }) @@ -181,6 +203,11 @@ export class ProcessManager { 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 { this.shuttingDown = true if (this.idleSweeper) clearInterval(this.idleSweeper) @@ -190,3 +217,18 @@ export class ProcessManager { this.byKey.clear() } } + +/** + * Claude Code persists session transcripts at: + * ~/.claude/projects//.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 + } +} diff --git a/index.ts b/index.ts index 883772b..e65e1e3 100644 --- a/index.ts +++ b/index.ts @@ -1,5 +1,12 @@ -import { definePluginEntry } from 'openclaw/plugin-sdk/plugin-entry' -import type { OpenClawPluginApi } from 'openclaw/plugin-sdk/core' +// Local `definePluginEntry` shim. The OpenClaw plugin runtime evaluates this +// 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 void | Promise }>(opts: T): T { + return opts +} +type OpenClawPluginApi = unknown + import fs from 'node:fs' import path from 'node:path' import { normalizeConfig, type SynthesisConfig } from './core/config.js' diff --git a/web/bridge-server.ts b/web/bridge-server.ts index d1c1ae2..00e6968 100644 --- a/web/bridge-server.ts +++ b/web/bridge-server.ts @@ -70,7 +70,7 @@ export function startBridgeServer(deps: BridgeDeps): { httpServer: http.Server; const prev = byKey.get(conn.openclawSessionKey) if (prev && prev.ws !== ws) try { prev.ws.close(4002, 'replaced by reconnect') } catch {} byKey.set(conn.openclawSessionKey, conn) - processManager.markReady(conn.pid) + processManager.markReady(conn.openclawSessionKey) log.info(`hello session=${conn.openclawSessionKey} pid=${conn.pid}`) ws.send(JSON.stringify({ type: 'hello_ack' })) return @@ -123,6 +123,7 @@ export function startBridgeServer(deps: BridgeDeps): { httpServer: http.Server; // ── HTTP server (OpenAI-compatible /v1/chat/completions) ──────────────────── 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') { res.writeHead(200, { 'Content-Type': 'application/json' }) 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 } - let bodyRaw = '' - req.on('data', c => { bodyRaw += c }) - req.on('end', async () => { - let body: any - try { body = JSON.parse(bodyRaw) } catch { - res.writeHead(400, { 'Content-Type': 'application/json' }) - res.end(JSON.stringify({ error: 'invalid_json' })); return - } + // Read the body synchronously (Promise-wrapped) so the outer `async` + // handler holds the response open until we explicitly end it. Returning + // from the outer fn before res.end() causes Bun's http server to close + // the connection. + const bodyRaw = await new Promise(resolve => { + let buf = '' + 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 // 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)}` - res.writeHead(200, { - 'Content-Type': 'text/event-stream', - 'Cache-Control': 'no-cache', - 'Connection': 'keep-alive', - 'Transfer-Encoding': 'chunked', - }) + log.info(`[trace] before writeHead session=${sessionKey} user=${latestUser.substring(0,50)}`) + try { + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + '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 // 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) releaseSlot() } - }) + } }) httpServer.listen(config.bridgePort, '127.0.0.1', () => {