fix(bridge): scope CLI sessions per OpenClaw session and reset on /new
The bridge was keying claudeSessionId by agentId alone, so every Discord
channel, DM, and cron run for a single agent shared one Claude CLI
session. Two consequences in the wild:
- Cross-channel context bleed: 8.7MB session for `developer` mixed
references from channels 1474327736242798612 and 1498579994044010566
plus the operator DM all in one --resume thread.
- `/new` had no effect on the CLI side. OpenClaw rotated its session
file but the bridge kept --resume-ing the same long-lived
claudeSessionId, eventually crossing the 1M model context (debug log
showed `prompt is too long: 1179616 tokens > 1000000 maximum`).
Changes:
* input-filter: extract `chat_id` from the Conversation-info
untrusted-metadata block (scanning all messages, since runtimeOnly
turns put it in the system prompt) and detect bare `/new`/`/reset`
via the BARE_SESSION_RESET_PROMPT_BASE marker. Add buildSessionKey
`${agentId}::${chatId}` and resolveDispatchPrompt fallback for the
empty user message that OpenClaw sends on bare resets.
* server: use the composite session key for getSession/putSession;
on bareSessionReset, removeSession before dispatching so the CLI
starts a fresh session; on a CLI result_error (typically
prompt_too_long) drop the entry too so the next turn doesn't
re-resume into the poisoned context.
* claude/sdk-adapter: surface CLI terminal errors via a new
`result_error` event (carries reason + sessionId) so the bridge
can react instead of just streaming the synthetic
"Prompt is too long" assistant text and silently re-using the
same session.
* index: convert register() to synchronous (OpenClaw rejects async
register with "plugin register must be synchronous"); replace the
pre-bind port probe with a server-level EADDRINUSE handler.
* .gitignore: ignore node_modules/ and dist/.
This commit is contained in:
@@ -1,7 +1,12 @@
|
||||
import http from "node:http";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { BridgeInboundRequest } from "../core/types/model.js";
|
||||
import { extractLatestUserMessage, extractRequestContext } from "./input-filter.js";
|
||||
import {
|
||||
buildSessionKey,
|
||||
extractLatestUserMessage,
|
||||
extractRequestContext,
|
||||
resolveDispatchPrompt,
|
||||
} from "./input-filter.js";
|
||||
import { buildBootstrap } from "./bootstrap.js";
|
||||
import { dispatchToClaude } from "../core/claude/sdk-adapter.js";
|
||||
import { dispatchToGemini } from "../core/gemini/sdk-adapter.js";
|
||||
@@ -10,6 +15,7 @@ import {
|
||||
getSession,
|
||||
putSession,
|
||||
markOrphaned,
|
||||
removeSession,
|
||||
} from "../core/contractor/session-map-store.js";
|
||||
|
||||
export type BridgeServerConfig = {
|
||||
@@ -100,22 +106,39 @@ export function createBridgeServer(config: BridgeServerConfig): http.Server {
|
||||
return;
|
||||
}
|
||||
|
||||
// Extract agent ID and workspace from the system prompt's Runtime line.
|
||||
// OpenClaw does NOT send agent/session info as HTTP headers — it's in the system prompt.
|
||||
const { agentId: parsedAgentId, workspace: parsedWorkspace, skillsBlock, workspaceContextFiles } = extractRequestContext(body);
|
||||
// Extract agent ID, workspace, chat id, and bare-reset signal from the
|
||||
// request. OpenClaw does NOT send agent/session info as HTTP headers — it
|
||||
// lives in the system prompt's Runtime line and the user envelope's
|
||||
// "Conversation info" untrusted-metadata block.
|
||||
const {
|
||||
agentId: parsedAgentId,
|
||||
workspace: parsedWorkspace,
|
||||
skillsBlock,
|
||||
workspaceContextFiles,
|
||||
chatId,
|
||||
bareSessionReset,
|
||||
} = extractRequestContext(body);
|
||||
const latestMessage = extractLatestUserMessage(body);
|
||||
|
||||
if (!latestMessage) {
|
||||
// Pick the prompt to forward to the CLI. For bare /new turns OpenClaw
|
||||
// submits an empty user message — we synthesize a stub prompt instead so
|
||||
// the CLI has something to respond to.
|
||||
const dispatchPrompt = resolveDispatchPrompt(latestMessage, { bareSessionReset });
|
||||
|
||||
if (!dispatchPrompt) {
|
||||
sendJson(res, 400, { error: "no user message found" });
|
||||
return;
|
||||
}
|
||||
|
||||
// Use agentId as session key — one persistent Claude session per agent (v1).
|
||||
// Scope the CLI session by (agentId, chat_id) so different Discord
|
||||
// channels / DMs / etc. for the same agent don't pile into one Claude
|
||||
// session and bleed context across surfaces. Falls back to agentId-only
|
||||
// when chat_id can't be parsed (local TUI, etc.).
|
||||
const agentId = parsedAgentId;
|
||||
const sessionKey = agentId; // stable per-agent key
|
||||
const sessionKey = buildSessionKey(agentId, chatId);
|
||||
|
||||
logger.info(
|
||||
`[contractor-bridge] turn agentId=${agentId} workspace=${parsedWorkspace} msg=${latestMessage.substring(0, 80)}`,
|
||||
`[contractor-bridge] turn agentId=${agentId} sessionKey=${sessionKey} workspace=${parsedWorkspace} bareReset=${bareSessionReset} msg=${dispatchPrompt.substring(0, 80)}`,
|
||||
);
|
||||
|
||||
// Resolve workspace: prefer what we parsed from the system prompt (most accurate);
|
||||
@@ -133,8 +156,18 @@ export function createBridgeServer(config: BridgeServerConfig): http.Server {
|
||||
// Detect backend from body.model: "contractor-gemini-bridge" → Gemini, else → Claude
|
||||
const isGemini = typeof body.model === "string" && body.model.includes("gemini");
|
||||
|
||||
// Look up existing session (shared structure for both Claude and Gemini)
|
||||
// Look up existing session (shared structure for both Claude and Gemini).
|
||||
// On a bare /new or /reset turn we deliberately drop the existing entry so
|
||||
// the CLI starts a fresh session — otherwise --resume would bring back the
|
||||
// very history the user just asked to abandon.
|
||||
let existingEntry = sessionKey ? getSession(workspace, sessionKey) : null;
|
||||
if (bareSessionReset && existingEntry && sessionKey) {
|
||||
logger.info(
|
||||
`[contractor-bridge] bare /new detected — dropping prior CLI session sessionKey=${sessionKey} prevClaudeSessionId=${existingEntry.claudeSessionId}`,
|
||||
);
|
||||
removeSession(workspace, sessionKey);
|
||||
existingEntry = null;
|
||||
}
|
||||
let resumeSessionId = existingEntry?.state === "active" ? existingEntry.claudeSessionId : null;
|
||||
|
||||
// Bootstrap is passed as the system prompt on every turn (stateless — not persisted in session files).
|
||||
@@ -160,13 +193,14 @@ export function createBridgeServer(config: BridgeServerConfig): http.Server {
|
||||
const completionId = `chatcmpl-bridge-${randomUUID().slice(0, 8)}`;
|
||||
let newSessionId = "";
|
||||
let hasError = false;
|
||||
let resultErrorReason: string | null = null;
|
||||
|
||||
const openclawTools = body.tools ?? [];
|
||||
|
||||
try {
|
||||
const dispatchIter = isGemini
|
||||
? dispatchToGemini({
|
||||
prompt: latestMessage,
|
||||
prompt: dispatchPrompt,
|
||||
systemPrompt,
|
||||
workspace,
|
||||
agentId,
|
||||
@@ -176,7 +210,7 @@ export function createBridgeServer(config: BridgeServerConfig): http.Server {
|
||||
bridgeApiKey: apiKey,
|
||||
})
|
||||
: dispatchToClaude({
|
||||
prompt: latestMessage,
|
||||
prompt: dispatchPrompt,
|
||||
systemPrompt,
|
||||
workspace,
|
||||
agentId,
|
||||
@@ -192,6 +226,15 @@ export function createBridgeServer(config: BridgeServerConfig): http.Server {
|
||||
sseWrite(res, buildChunk(completionId, event.text));
|
||||
} else if (event.type === "done") {
|
||||
newSessionId = event.sessionId;
|
||||
} else if (event.type === "result_error") {
|
||||
// CLI returned a terminal error (typically context overflow). The
|
||||
// text was already streamed via prior `text` events; record the
|
||||
// session so we can drop it below and log the reason.
|
||||
logger.warn(
|
||||
`[contractor-bridge] ${isGemini ? "gemini" : "claude"} result_error reason=${event.reason} sessionId=${event.sessionId} message=${event.message.substring(0, 200)}`,
|
||||
);
|
||||
resultErrorReason = event.reason;
|
||||
newSessionId = event.sessionId;
|
||||
} else if (event.type === "error") {
|
||||
logger.warn(`[contractor-bridge] ${isGemini ? "gemini" : "claude"} error: ${event.message}`);
|
||||
hasError = true;
|
||||
@@ -208,8 +251,20 @@ export function createBridgeServer(config: BridgeServerConfig): http.Server {
|
||||
sseWrite(res, "[DONE]");
|
||||
res.end();
|
||||
|
||||
// Persist session mapping (shared for both Claude and Gemini)
|
||||
if (newSessionId && sessionKey && !hasError) {
|
||||
// Session-map persistence:
|
||||
// - Successful turn → upsert with the latest claudeSessionId so the next
|
||||
// turn can `--resume` into it.
|
||||
// - Terminal CLI error (context overflow etc., reported via result_error)
|
||||
// → drop the entry so the next turn starts fresh instead of resuming
|
||||
// into the same poisoned session and re-erroring.
|
||||
// - Stream/transport error before any sessionId was captured → mark the
|
||||
// prior entry orphaned (existing behavior).
|
||||
if (resultErrorReason && sessionKey) {
|
||||
logger.info(
|
||||
`[contractor-bridge] dropping CLI session after terminal error sessionKey=${sessionKey} reason=${resultErrorReason}`,
|
||||
);
|
||||
removeSession(workspace, sessionKey);
|
||||
} else if (newSessionId && sessionKey && !hasError) {
|
||||
const now = new Date().toISOString();
|
||||
putSession(workspace, {
|
||||
openclawSessionKey: sessionKey,
|
||||
|
||||
Reference in New Issue
Block a user