@zeulewan/glueclaw-provider 1.4.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -8,7 +8,7 @@ Uses the official Claude CLI and scrubs out [Anthropic's detection triggers](doc
8
8
 
9
9
  ## Install
10
10
 
11
- Requires [OpenClaw](https://docs.openclaw.ai) 2026.4.10+, [Claude Code](https://claude.ai/claude-code) logged in with Max, and Node.js 22+. Non-destructive, won't touch your existing config or sessions.
11
+ Requires [OpenClaw](https://docs.openclaw.ai) 2026.5.x+, [Claude Code](https://claude.ai/claude-code) logged in with Max, and Node.js 22+. Non-destructive, won't touch your existing config or sessions.
12
12
 
13
13
  ### npm (recommended)
14
14
 
@@ -66,7 +66,8 @@ export GLUECLAW_REQUEST_TIMEOUT_MS=600000
66
66
 
67
67
  - Tested with Telegram and OpenClaw TUI
68
68
  - Switching between GlueClaw and other backends (e.g. Codex) works seamlessly via `/model`
69
- - The installer patches one file in OpenClaw's dist to expose the MCP loopback token to plugins. A `.glueclaw-bak` backup is created.
69
+ - The installer does not patch OpenClaw's dist. GlueClaw starts the MCP loopback in-process when available.
70
+ - Multi-agent setups are isolated end-to-end: each agent gets its own Claude project storage and its own session-id cache, anchored at the agent's `workspaceDir`. See the [multi-agent guide](docs/multi-agent.md).
70
71
 
71
72
  ## Disclaimer
72
73
 
package/index.ts CHANGED
@@ -1,11 +1,10 @@
1
- import { basename } from "node:path";
2
1
  import {
3
2
  definePluginEntry,
4
3
  type OpenClawPluginApi,
5
4
  } from "openclaw/plugin-sdk/plugin-entry";
6
5
  import { createClaudeCliStreamFn } from "./src/stream.js";
7
6
  import { MODEL_CATALOG } from "./src/catalog.js";
8
- import { resolveSessionKey } from "./src/session-key.js";
7
+ import { resolveAgentId, resolveSessionKey } from "./src/session-key.js";
9
8
 
10
9
  const PROVIDER_ID = "glueclaw";
11
10
  const PROVIDER_LABEL = "GlueClaw";
@@ -33,13 +32,28 @@ function resolveRequestTimeoutMs(): number {
33
32
 
34
33
  export default definePluginEntry({
35
34
  register(api: OpenClawPluginApi): void {
36
- const authProfile = () =>
35
+ const syntheticAuth = () =>
37
36
  ({
38
37
  apiKey: AUTH_KEY,
39
38
  source: AUTH_SOURCE,
40
39
  mode: "api-key" as const,
41
40
  }) as const;
42
41
 
42
+ const authResult = () =>
43
+ ({
44
+ profiles: [
45
+ {
46
+ profileId: `${PROVIDER_ID}:default`,
47
+ credential: {
48
+ type: "api_key" as const,
49
+ provider: PROVIDER_ID,
50
+ key: AUTH_KEY,
51
+ },
52
+ },
53
+ ],
54
+ notes: ["Uses local Claude CLI OAuth (Max subscription)."],
55
+ }) as const;
56
+
43
57
  api.registerProvider({
44
58
  id: PROVIDER_ID,
45
59
  label: PROVIDER_LABEL,
@@ -47,11 +61,11 @@ export default definePluginEntry({
47
61
  envVars: ["GLUECLAW_KEY"],
48
62
  auth: [
49
63
  {
50
- method: "local",
64
+ id: "local",
51
65
  label: "Local Claude CLI",
52
66
  hint: "Uses your locally installed claude binary",
53
- authenticate: async () => authProfile(),
54
- authenticateNonInteractive: async () => authProfile(),
67
+ kind: "custom" as const,
68
+ run: async () => authResult(),
55
69
  },
56
70
  ],
57
71
  catalog: {
@@ -87,21 +101,25 @@ export default definePluginEntry({
87
101
  agentDir?: string;
88
102
  sessionId?: string;
89
103
  sessionKey?: string;
104
+ workspaceDir?: string;
90
105
  }) => {
106
+ if (!ctx.workspaceDir) {
107
+ throw new Error(
108
+ "GlueClaw requires ProviderCreateStreamFnContext.workspaceDir, " +
109
+ "available in OpenClaw 2026.5.x+. Upgrade OpenClaw to a release " +
110
+ "that surfaces workspaceDir to provider plugins.",
111
+ );
112
+ }
91
113
  const realModel = MODEL_MAP[ctx.modelId] ?? ctx.modelId;
92
- const agentId = ctx.agentDir ? basename(ctx.agentDir) : undefined;
93
114
  return createClaudeCliStreamFn({
94
115
  sessionKey: resolveSessionKey(ctx),
95
- agentId,
116
+ agentId: resolveAgentId(ctx),
117
+ workspaceDir: ctx.workspaceDir,
96
118
  modelOverride: realModel,
97
119
  requestTimeoutMs: resolveRequestTimeoutMs(),
98
120
  });
99
121
  },
100
- resolveSyntheticAuth: () => ({
101
- apiKey: AUTH_KEY,
102
- source: AUTH_SOURCE,
103
- mode: "api-key",
104
- }),
122
+ resolveSyntheticAuth: () => syntheticAuth(),
105
123
  augmentModelCatalog: () => [...MODEL_CATALOG],
106
124
  });
107
125
  },
package/install.sh CHANGED
@@ -29,14 +29,6 @@ oc_config() {
29
29
  }
30
30
  }
31
31
 
32
- sedi() {
33
- if [[ "$(uname)" == "Darwin" ]]; then
34
- sed -i '' "$@"
35
- else
36
- sed -i "$@"
37
- fi
38
- }
39
-
40
32
  require_cmd() {
41
33
  command -v "$1" >/dev/null 2>&1 || die "$1 not found. $2"
42
34
  }
@@ -121,14 +113,6 @@ else
121
113
  fi
122
114
  echo ""
123
115
 
124
- # Find OpenClaw dist
125
- OPENCLAW_BIN="$(command -v openclaw)"
126
- OPENCLAW_ROOT="$(dirname "$OPENCLAW_BIN")/../lib/node_modules/openclaw"
127
- # Suppress not-found: fallback path may not exist
128
- [ ! -d "$OPENCLAW_ROOT/dist" ] && OPENCLAW_ROOT="$(npm root -g 2>/dev/null)/openclaw"
129
- [ ! -d "$OPENCLAW_ROOT/dist" ] && die "Cannot find OpenClaw dist"
130
- OPENCLAW_DIST="$OPENCLAW_ROOT/dist"
131
-
132
116
  # Detect shell config
133
117
  if [ -f "${HOME}/.zshrc" ]; then
134
118
  SHELL_RC="${HOME}/.zshrc"
@@ -142,17 +126,7 @@ fi
142
126
 
143
127
  GW_PID=""
144
128
  GW_LOG=""
145
- BACKUP_FILE=""
146
129
  cleanup() {
147
- # Restore MCP patch backup if script failed mid-patch
148
- if [ -n "$BACKUP_FILE" ] && [ -f "$BACKUP_FILE" ]; then
149
- if [ -w "$(dirname "$BACKUP_FILE")" ]; then
150
- mv "$BACKUP_FILE" "${BACKUP_FILE%.glueclaw-bak}" 2>/dev/null || true
151
- else
152
- sudo mv "$BACKUP_FILE" "${BACKUP_FILE%.glueclaw-bak}" 2>/dev/null || true
153
- fi
154
- echo " Restored backup: $(basename "$BACKUP_FILE")" >&2
155
- fi
156
130
  if [ -n "$GW_PID" ] && kill -0 "$GW_PID" 2>/dev/null; then
157
131
  kill "$GW_PID" 2>/dev/null || true
158
132
  fi
@@ -162,19 +136,19 @@ trap cleanup INT TERM
162
136
 
163
137
  # --- 1. Dependencies ---
164
138
 
165
- echo "[1/7] Installing dependencies..."
139
+ echo "[1/6] Installing dependencies..."
166
140
  cd "$PLUGIN_DIR"
167
141
  npm install --silent || die "npm install failed"
168
142
 
169
143
  # --- 2. Environment ---
170
144
 
171
- echo "[2/7] Setting up environment..."
145
+ echo "[2/6] Setting up environment..."
172
146
  ensure_line "$SHELL_RC" "GLUECLAW_KEY" "export GLUECLAW_KEY=local"
173
147
  export GLUECLAW_KEY=local
174
148
 
175
149
  # --- 3. Plugin registration ---
176
150
 
177
- echo "[3/7] Registering plugin..."
151
+ echo "[3/6] Registering plugin..."
178
152
  # GlueClaw is on OpenClaw's official safe plugin list. Try standard install first,
179
153
  # fall back to --dangerously-force-unsafe-install for older OpenClaw versions,
180
154
  # then manual config as last resort.
@@ -188,7 +162,7 @@ fi
188
162
 
189
163
  # --- 4. Model config ---
190
164
 
191
- echo "[4/7] Configuring models..."
165
+ echo "[4/6] Configuring models..."
192
166
  # These two are fatal — without them, nothing works
193
167
  oc_config models.providers.glueclaw \
194
168
  '{"baseUrl":"local://glueclaw","models":[{"id":"glueclaw-opus","name":"GlueClaw Opus","contextWindow":1000000},{"id":"glueclaw-sonnet","name":"GlueClaw Sonnet","contextWindow":1000000},{"id":"glueclaw-haiku","name":"GlueClaw Haiku","contextWindow":200000}]}' \
@@ -211,47 +185,20 @@ oc_config gateway.tools.allow \
211
185
 
212
186
  # --- 5. Auth profile ---
213
187
 
214
- echo "[5/7] Setting up auth..."
188
+ echo "[5/6] Setting up auth..."
215
189
  AGENT_DIR="${HOME}/.openclaw/agents/main/agent"
216
190
  mkdir -p "$AGENT_DIR" || die "Cannot create $AGENT_DIR"
217
191
  AUTH_FILE="$AGENT_DIR/auth-profiles.json"
218
192
  write_auth_profile "$AUTH_FILE"
219
193
 
220
- # --- 6. Patch: MCP bridge ---
221
-
222
- echo "[6/7] Patching gateway for MCP bridge..."
223
- SERVER_FILE=$(grep -rl "mcp loopback listening" "$OPENCLAW_DIST"/*.js 2>/dev/null | head -n 1)
224
- [ -z "$SERVER_FILE" ] && die "Cannot find MCP loopback in OpenClaw dist — incompatible version?"
225
-
226
- # Use sudo for file operations if dist directory is not writable (global npm install)
227
- ELEVATE=""
228
- if [ ! -w "$OPENCLAW_DIST" ]; then
229
- echo " OpenClaw dist is root-owned, using sudo for patch..."
230
- ELEVATE="sudo"
231
- fi
232
-
233
- if ! grep -q "__GLUECLAW_MCP" "$SERVER_FILE"; then
234
- $ELEVATE cp "$SERVER_FILE" "${SERVER_FILE}.glueclaw-bak" || die "Cannot backup $SERVER_FILE"
235
- BACKUP_FILE="${SERVER_FILE}.glueclaw-bak"
236
- # shellcheck disable=SC2016
237
- if [ -n "$ELEVATE" ]; then
238
- $ELEVATE sed -i 's/logDebug(`mcp loopback listening/process.env.__GLUECLAW_MCP_PORT = String(address.port); process.env.__GLUECLAW_MCP_TOKEN = token; logDebug(`mcp loopback listening/' "$SERVER_FILE" ||
239
- die "Failed to patch $SERVER_FILE"
240
- else
241
- sedi 's/logDebug(`mcp loopback listening/process.env.__GLUECLAW_MCP_PORT = String(address.port); process.env.__GLUECLAW_MCP_TOKEN = token; logDebug(`mcp loopback listening/' "$SERVER_FILE" ||
242
- die "Failed to patch $SERVER_FILE"
243
- fi
244
- # Validate the patch actually applied
245
- grep -q "__GLUECLAW_MCP_PORT" "$SERVER_FILE" || die "MCP patch did not apply — sed replacement failed"
246
- BACKUP_FILE="" # Patch succeeded, don't restore on cleanup
247
- echo " Patched $(basename "$SERVER_FILE")"
248
- else
249
- echo " Already patched"
250
- fi
251
-
252
- # --- 7. Restart gateway ---
194
+ # --- 6. Restart gateway ---
195
+ # Note: GlueClaw bootstraps OpenClaw's MCP loopback in-process from
196
+ # src/stream.ts (see docs/RFC-001-sessions-send-native.md). No dist patching
197
+ # is needed GlueClaw shares OpenClaw's module cache as an in-process
198
+ # provider, so calling ensureMcpLoopbackServer() and getActiveMcpLoopbackRuntime()
199
+ # directly is sufficient.
253
200
 
254
- echo "[7/7] Starting gateway..."
201
+ echo "[6/6] Starting gateway..."
255
202
  # Stop any existing gateway first
256
203
  pkill -f "openclaw.*gateway" 2>/dev/null || true
257
204
  openclaw gateway stop 2>/dev/null || true
@@ -312,4 +259,4 @@ echo " Default: glueclaw/glueclaw-sonnet"
312
259
  echo ""
313
260
  echo " Run: openclaw tui"
314
261
  echo ""
315
- echo " Re-run after OpenClaw updates to re-apply patches."
262
+ echo " Re-run after OpenClaw updates to refresh plugin registration."
@@ -5,6 +5,14 @@
5
5
  "providerAuthEnvVars": {
6
6
  "glueclaw": ["GLUECLAW_KEY"]
7
7
  },
8
+ "setup": {
9
+ "providers": [
10
+ {
11
+ "id": "glueclaw",
12
+ "envVars": ["GLUECLAW_KEY"]
13
+ }
14
+ ]
15
+ },
8
16
  "providerAuthChoices": [
9
17
  {
10
18
  "provider": "glueclaw",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zeulewan/glueclaw-provider",
3
- "version": "1.4.0",
3
+ "version": "2.0.0",
4
4
  "description": "GlueClaw - Claude CLI subprocess provider for Max subscription",
5
5
  "type": "module",
6
6
  "engines": {
package/src/stream.ts CHANGED
@@ -7,12 +7,14 @@ import {
7
7
  rmSync,
8
8
  renameSync,
9
9
  } from "node:fs";
10
- import { join } from "node:path";
10
+ import { basename, delimiter, dirname, join, normalize } from "node:path";
11
11
  import { tmpdir } from "node:os";
12
12
  import { randomBytes } from "node:crypto";
13
+ import { pathToFileURL } from "node:url";
13
14
  import { createAssistantMessageEventStream } from "@mariozechner/pi-ai";
14
15
  import type { StreamFn } from "@mariozechner/pi-agent-core";
15
16
  import type { AssistantMessage, Usage, TextContent } from "@mariozechner/pi-ai";
17
+ import { deriveTurnSessionKey } from "./session-key.js";
16
18
 
17
19
  const PROCESS_TIMEOUT_MS = 5000;
18
20
  const REQUEST_TIMEOUT_MS = 120_000;
@@ -24,6 +26,8 @@ interface StreamEventData {
24
26
  subtype?: string;
25
27
  session_id?: string;
26
28
  result?: string;
29
+ is_error?: boolean;
30
+ errors?: string[];
27
31
  usage?: Record<string, number>;
28
32
  event?: {
29
33
  delta?: { type?: string; text?: string };
@@ -34,31 +38,53 @@ interface StreamEventData {
34
38
  }
35
39
 
36
40
  /** Track claude session IDs per session key for multi-turn resume.
37
- * Persisted to disk so sessions survive gateway restarts. */
38
- const GC_HOME = join(process.env.HOME ?? tmpdir(), ".glueclaw");
39
- const SESSION_FILE = join(GC_HOME, "sessions.json");
40
- const sessionMap = new Map<string, string>();
41
-
42
- // Load persisted sessions on startup
43
- try {
44
- const saved = JSON.parse(readFileSync(SESSION_FILE, "utf8"));
45
- for (const [k, v] of Object.entries(saved)) {
46
- if (typeof v === "string") sessionMap.set(k, v);
41
+ * Persisted at `<workspaceDir>/.glueclaw/sessions.json`, so each OpenClaw
42
+ * agent gets its own session cache. Requires OpenClaw 2026.5.x+ which
43
+ * surfaces `ProviderCreateStreamFnContext.workspaceDir` to the plugin. */
44
+
45
+ type SessionStore = { filePath: string; map: Map<string, string> };
46
+ const sessionStores = new Map<string, SessionStore>();
47
+
48
+ function sessionFilePath(workspaceDir: string): string {
49
+ return join(workspaceDir, ".glueclaw", "sessions.json");
50
+ }
51
+
52
+ function getSessionStore(workspaceDir: string): SessionStore {
53
+ const filePath = sessionFilePath(workspaceDir);
54
+ let store = sessionStores.get(filePath);
55
+ if (!store) {
56
+ const map = new Map<string, string>();
57
+ try {
58
+ const saved = JSON.parse(readFileSync(filePath, "utf8"));
59
+ for (const [k, v] of Object.entries(saved)) {
60
+ if (typeof v === "string") map.set(k, v);
61
+ }
62
+ } catch {
63
+ // Expected on first run when session file doesn't exist
64
+ }
65
+ store = { filePath, map };
66
+ sessionStores.set(filePath, store);
47
67
  }
48
- } catch {
49
- // Expected on first run when session file doesn't exist
68
+ return store;
50
69
  }
51
70
 
52
- export function persistSessions(): void {
71
+ function persistStore(store: SessionStore): void {
53
72
  try {
54
- const tmp = SESSION_FILE + ".tmp";
55
- writeFileSync(tmp, JSON.stringify(Object.fromEntries(sessionMap)));
56
- renameSync(tmp, SESSION_FILE); // Atomic on most filesystems
73
+ mkdirSync(dirname(store.filePath), { recursive: true });
74
+ const tmp = store.filePath + ".tmp";
75
+ writeFileSync(tmp, JSON.stringify(Object.fromEntries(store.map)));
76
+ renameSync(tmp, store.filePath); // Atomic on most filesystems
57
77
  } catch {
58
78
  // Best-effort persistence — non-fatal if disk write fails
59
79
  }
60
80
  }
61
81
 
82
+ /** Persist all known session stores to disk. Exported for tests and callers
83
+ * that want to flush state explicitly. */
84
+ export function persistSessions(): void {
85
+ for (const store of sessionStores.values()) persistStore(store);
86
+ }
87
+
62
88
  export function buildUsage(raw?: Record<string, number>): Usage {
63
89
  return {
64
90
  input: raw?.input_tokens ?? 0,
@@ -91,12 +117,99 @@ export function buildMsg(
91
117
  };
92
118
  }
93
119
 
94
- /** Get the MCP loopback port and token from process.env.
95
- * The gateway patches write these during MCP server startup. */
96
- export function getMcpLoopback(): { port: number; token: string } | undefined {
97
- const port = process.env.__GLUECLAW_MCP_PORT;
120
+ interface McpLoopbackRuntime {
121
+ port: number;
122
+ ownerToken: string;
123
+ nonOwnerToken?: string;
124
+ }
125
+
126
+ let _mcpLoopback: { port: number; token: string } | undefined;
127
+ let _mcpBootstrapAttempted = false;
128
+
129
+ function getEnvMcpLoopback(): { port: number; token: string } | undefined {
130
+ const portRaw = process.env.__GLUECLAW_MCP_PORT;
98
131
  const token = process.env.__GLUECLAW_MCP_TOKEN;
99
- if (port && token) return { port: parseInt(port, 10), token };
132
+ if (!portRaw || !token) return undefined;
133
+
134
+ const port = Number.parseInt(portRaw, 10);
135
+ if (!Number.isFinite(port) || port <= 0) return undefined;
136
+ return { port, token };
137
+ }
138
+
139
+ function openClawDistFromNodePath(nodePath: string): string | undefined {
140
+ const normalized = normalize(nodePath);
141
+ if (!normalized.includes("openclaw")) return undefined;
142
+ if (basename(normalized) !== "node_modules") return undefined;
143
+ return join(dirname(normalized), "dist");
144
+ }
145
+
146
+ export function resetMcpLoopbackForTests(): void {
147
+ _mcpLoopback = undefined;
148
+ _mcpBootstrapAttempted = false;
149
+ }
150
+
151
+ /** Bootstrap OpenClaw's MCP loopback server in-process and return the
152
+ * port + owner token. GlueClaw runs inside the gateway process, so we
153
+ * share OpenClaw's module cache: importing the same `mcp-http-*.js`
154
+ * the gateway loaded gives us the singleton, and a no-op when another
155
+ * caller already started it.
156
+ *
157
+ * Returns undefined if the OpenClaw dist cannot be located or its API
158
+ * has changed — in that case the claude subprocess simply runs without
159
+ * session tools, matching pre-RFC-001 behavior. */
160
+ export async function getMcpLoopback(): Promise<
161
+ { port: number; token: string } | undefined
162
+ > {
163
+ const envLoopback = getEnvMcpLoopback();
164
+ if (envLoopback) return envLoopback;
165
+
166
+ if (_mcpLoopback) return _mcpLoopback;
167
+ if (_mcpBootstrapAttempted) return undefined;
168
+ _mcpBootstrapAttempted = true;
169
+
170
+ try {
171
+ const { readdir } = await import("node:fs/promises");
172
+ const nodePaths = (process.env.NODE_PATH ?? "").split(delimiter);
173
+ const distDirs = nodePaths
174
+ .map(openClawDistFromNodePath)
175
+ .filter((p): p is string => Boolean(p));
176
+
177
+ for (const distDir of distDirs) {
178
+ try {
179
+ const files = await readdir(distDir);
180
+ const mcpFile = files.find(
181
+ (f) => f.startsWith("mcp-http-") && f.endsWith(".js"),
182
+ );
183
+ if (!mcpFile) continue;
184
+ const mod = (await import(
185
+ pathToFileURL(join(distDir, mcpFile)).href
186
+ )) as Record<string, unknown>;
187
+ // Minified aliases: n=ensureMcpLoopbackServer, i=getActiveMcpLoopbackRuntime
188
+ const ensureFn = (mod["n"] ?? mod["ensureMcpLoopbackServer"]) as
189
+ | (() => Promise<unknown>)
190
+ | undefined;
191
+ const getRuntime = (mod["i"] ?? mod["getActiveMcpLoopbackRuntime"]) as
192
+ | (() => McpLoopbackRuntime | undefined)
193
+ | undefined;
194
+ if (
195
+ typeof ensureFn !== "function" ||
196
+ typeof getRuntime !== "function"
197
+ ) {
198
+ continue;
199
+ }
200
+ await ensureFn();
201
+ const runtime = getRuntime();
202
+ if (runtime?.port && runtime.ownerToken) {
203
+ _mcpLoopback = { port: runtime.port, token: runtime.ownerToken };
204
+ return _mcpLoopback;
205
+ }
206
+ } catch {
207
+ continue;
208
+ }
209
+ }
210
+ } catch {
211
+ // Non-fatal: session tools simply won't be available
212
+ }
100
213
  return undefined;
101
214
  }
102
215
 
@@ -158,11 +271,50 @@ export function unscrubResponse(text: string): string {
158
271
  .replace(/\[\[reply:/g, "[[reply_to:");
159
272
  }
160
273
 
161
- /** Evict oldest sessions when map exceeds MAX_SESSIONS */
162
- function evictSessions(): void {
163
- while (sessionMap.size > MAX_SESSIONS) {
164
- const oldest = sessionMap.keys().next().value;
165
- if (oldest !== undefined) sessionMap.delete(oldest);
274
+ type MessageLike = { role: string; content: unknown };
275
+
276
+ function extractTextContent(content: unknown): string {
277
+ if (typeof content === "string") return content;
278
+ if (!Array.isArray(content)) return "";
279
+ return content
280
+ .filter(
281
+ (b): b is TextContent =>
282
+ typeof b === "object" &&
283
+ b !== null &&
284
+ (b as { type?: unknown }).type === "text" &&
285
+ typeof (b as { text?: unknown }).text === "string",
286
+ )
287
+ .map((b) => b.text)
288
+ .join("\n");
289
+ }
290
+
291
+ function isOpenClawRuntimeMetadata(text: string): boolean {
292
+ // OpenClaw injects per-turn context blocks as user-role messages on
293
+ // channel inbound. Each one's first line is a labelled
294
+ // "<Section> (untrusted metadata):" header, e.g.:
295
+ // - "Sender (untrusted metadata):"
296
+ // - "Conversation info (untrusted metadata):"
297
+ // Match the suffix on the first non-empty line so we recognize current
298
+ // and future labels without churning this list. See zeulewan/glueclaw#39.
299
+ const firstLine = text.split(/\r?\n/, 1)[0]?.trim();
300
+ return /\(untrusted metadata\):$/.test(firstLine ?? "");
301
+ }
302
+
303
+ export function extractPromptText(messages: MessageLike[] | undefined): string {
304
+ for (let i = (messages?.length ?? 0) - 1; i >= 0; i--) {
305
+ const message = messages?.[i];
306
+ if (!message || message.role !== "user") continue;
307
+ const text = extractTextContent(message.content);
308
+ if (text && !isOpenClawRuntimeMetadata(text)) return text;
309
+ }
310
+ return "";
311
+ }
312
+
313
+ /** Evict oldest sessions when a workspace's map exceeds MAX_SESSIONS */
314
+ function evictStore(store: SessionStore): void {
315
+ while (store.map.size > MAX_SESSIONS) {
316
+ const oldest = store.map.keys().next().value;
317
+ if (oldest !== undefined) store.map.delete(oldest);
166
318
  else break;
167
319
  }
168
320
  }
@@ -171,6 +323,7 @@ export function createClaudeCliStreamFn(opts: {
171
323
  claudeBin?: string;
172
324
  sessionKey?: string;
173
325
  agentId?: string;
326
+ workspaceDir: string;
174
327
  modelOverride?: string;
175
328
  requestTimeoutMs?: number;
176
329
  }): StreamFn {
@@ -184,6 +337,15 @@ export function createClaudeCliStreamFn(opts: {
184
337
  let mcpCleanup: (() => void) | undefined;
185
338
  let stderrBuf = "";
186
339
  try {
340
+ const turnSessionKey = deriveTurnSessionKey({
341
+ agentId: opts.agentId,
342
+ systemPrompt: context.systemPrompt,
343
+ messages: context.messages as
344
+ | Array<{ role: string; content: unknown }>
345
+ | undefined,
346
+ });
347
+ const effectiveSessionKey =
348
+ turnSessionKey ?? opts.sessionKey ?? "default";
187
349
  // Scrub Anthropic detection triggers (see docs/detection-patterns.md)
188
350
  const cleanPrompt = scrubPrompt(context.systemPrompt ?? "");
189
351
  const resolvedModel = opts.modelOverride ?? model.id;
@@ -200,29 +362,18 @@ export function createClaudeCliStreamFn(opts: {
200
362
  // otherwise stick to whatever identity was used on the first turn,
201
363
  // leaving no way for callers to reinforce or correct an agent's
202
364
  // identity across turns.
203
- const sessionKey = `glueclaw:${opts.sessionKey ?? "default"}`;
204
- const existingSessionId = sessionMap.get(sessionKey);
365
+ const sessionKey = `glueclaw:${effectiveSessionKey}`;
366
+ const sessionStore = getSessionStore(opts.workspaceDir);
367
+ const existingSessionId = sessionStore.map.get(sessionKey);
205
368
  if (existingSessionId) {
206
369
  args.push("--resume", existingSessionId);
207
370
  }
208
371
  if (cleanPrompt) args.push("--system-prompt", cleanPrompt);
209
372
  if (resolvedModel) args.push("--model", resolvedModel);
210
373
 
211
- // Debug: log args for resume troubleshooting
212
- // Extract user message and scrub it too
213
- const lastUser = [...(context.messages ?? [])]
214
- .reverse()
215
- .find((m) => m.role === "user");
216
- let prompt = "";
217
- if (lastUser) {
218
- const c = lastUser.content;
219
- if (typeof c === "string") prompt = c;
220
- else if (Array.isArray(c))
221
- prompt = c
222
- .filter((b): b is TextContent => b.type === "text")
223
- .map((b) => b.text)
224
- .join("\n");
225
- }
374
+ const prompt = extractPromptText(
375
+ context.messages as MessageLike[] | undefined,
376
+ );
226
377
  if (prompt) args.push(prompt);
227
378
 
228
379
  const env = { ...process.env };
@@ -230,24 +381,35 @@ export function createClaudeCliStreamFn(opts: {
230
381
  delete env.ANTHROPIC_API_KEY_OLD;
231
382
 
232
383
  // Wire up MCP bridge for OpenClaw gateway tools
233
- const loopback = getMcpLoopback();
384
+ const loopback = await getMcpLoopback();
234
385
  if (loopback) {
386
+ if (!opts.agentId) {
387
+ // Refuse to silently mis-stamp MCP loopback auth as a default
388
+ // agent — that's how zeulewan/glueclaw#36 hid behind a working
389
+ // setup whenever the active agent happened to be named "main".
390
+ throw new Error(
391
+ "GlueClaw cannot wire MCP loopback without a resolved agent id. " +
392
+ "OpenClaw did not propagate sessionKey or a parseable agentDir " +
393
+ "to the provider, so identity stamping would be ambiguous. " +
394
+ "See zeulewan/glueclaw#36.",
395
+ );
396
+ }
235
397
  const mcp = writeMcpConfig(loopback.port);
236
398
  mcpCleanup = mcp.cleanup;
237
399
  args.push("--strict-mcp-config", "--mcp-config", mcp.path);
238
400
  env.OPENCLAW_MCP_TOKEN = loopback.token;
239
- env.OPENCLAW_MCP_SESSION_KEY = opts.sessionKey ?? "";
240
- env.OPENCLAW_MCP_AGENT_ID = opts.agentId ?? "main";
401
+ env.OPENCLAW_MCP_SESSION_KEY = effectiveSessionKey;
402
+ env.OPENCLAW_MCP_AGENT_ID = opts.agentId;
241
403
  env.OPENCLAW_MCP_ACCOUNT_ID = "";
242
404
  env.OPENCLAW_MCP_MESSAGE_CHANNEL = "";
243
405
  }
244
406
 
245
- // Use persistent dir so claude sessions survive restarts
246
- const gcHome = join(process.env.HOME ?? "/tmp", ".glueclaw");
247
- mkdirSync(gcHome, { recursive: true });
407
+ // Anchor Claude's project storage at the active OpenClaw agent
408
+ // workspace so per-agent state stays isolated.
409
+ mkdirSync(opts.workspaceDir, { recursive: true });
248
410
  const proc = spawn(claudeBin, args, {
249
411
  stdio: ["pipe", "pipe", "pipe"],
250
- cwd: gcHome,
412
+ cwd: opts.workspaceDir,
251
413
  env,
252
414
  });
253
415
  if (options?.signal)
@@ -334,9 +496,9 @@ export function createClaudeCliStreamFn(opts: {
334
496
  if (type === "system" && data.subtype === "init") {
335
497
  const sid = data.session_id;
336
498
  if (sid) {
337
- sessionMap.set(sessionKey, sid);
338
- evictSessions();
339
- persistSessions();
499
+ sessionStore.map.set(sessionKey, sid);
500
+ evictStore(sessionStore);
501
+ persistStore(sessionStore);
340
502
  }
341
503
  continue;
342
504
  }
@@ -418,11 +580,46 @@ export function createClaudeCliStreamFn(opts: {
418
580
 
419
581
  // Result event (final) - authoritative response
420
582
  if (type === "result") {
583
+ const isError =
584
+ data.is_error === true ||
585
+ data.subtype === "error_during_execution";
421
586
  const sid = data.session_id;
422
- if (sid) {
423
- sessionMap.set(sessionKey, sid);
424
- evictSessions();
425
- persistSessions();
587
+ if (sid && !isError) {
588
+ // Only persist the session id from a successful turn —
589
+ // claude emits a fresh session_id even on hard failures
590
+ // (e.g. stale --resume), and persisting that id would
591
+ // perpetuate the failure on every subsequent turn.
592
+ // See zeulewan/glueclaw#37.
593
+ sessionStore.map.set(sessionKey, sid);
594
+ evictStore(sessionStore);
595
+ persistStore(sessionStore);
596
+ }
597
+ if (isError) {
598
+ // The cached resume id is the most likely culprit (claude
599
+ // reports a missing conversation when the id has gone
600
+ // stale). Drop it so the next turn starts a fresh session.
601
+ if (existingSessionId) {
602
+ sessionStore.map.delete(sessionKey);
603
+ persistStore(sessionStore);
604
+ }
605
+ // Pick the most informative error string claude emitted:
606
+ // - errors[] (e.g. "No conversation found with session ID: …")
607
+ // - result (e.g. "Failed to authenticate. API Error: 401 …")
608
+ // - api_error_status alone (e.g. 401, 429)
609
+ // data.subtype is intentionally not used: even on real errors
610
+ // it can be the literal string "success" (it tags the result
611
+ // schema, not the outcome).
612
+ const apiStatus = (data as { api_error_status?: unknown })
613
+ .api_error_status;
614
+ const errText =
615
+ Array.isArray(data.errors) && data.errors.length > 0
616
+ ? data.errors.join("; ")
617
+ : typeof data.result === "string" && data.result.trim()
618
+ ? data.result.trim()
619
+ : typeof apiStatus === "number"
620
+ ? `claude CLI failed with HTTP ${apiStatus}`
621
+ : "claude CLI returned an error";
622
+ throw new Error(errText);
426
623
  }
427
624
  // Only use result text if nothing came through streaming or assistant
428
625
  if (!text) {