@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 +3 -2
- package/index.ts +31 -13
- package/install.sh +13 -66
- package/openclaw.plugin.json +8 -0
- package/package.json +1 -1
- package/src/stream.ts +255 -58
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.
|
|
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
|
|
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
|
|
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
|
-
|
|
64
|
+
id: "local",
|
|
51
65
|
label: "Local Claude CLI",
|
|
52
66
|
hint: "Uses your locally installed claude binary",
|
|
53
|
-
|
|
54
|
-
|
|
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/
|
|
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/
|
|
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/
|
|
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/
|
|
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/
|
|
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.
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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 "[
|
|
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
|
|
262
|
+
echo " Re-run after OpenClaw updates to refresh plugin registration."
|
package/openclaw.plugin.json
CHANGED
package/package.json
CHANGED
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
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
49
|
-
// Expected on first run when session file doesn't exist
|
|
68
|
+
return store;
|
|
50
69
|
}
|
|
51
70
|
|
|
52
|
-
|
|
71
|
+
function persistStore(store: SessionStore): void {
|
|
53
72
|
try {
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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 (
|
|
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
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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:${
|
|
204
|
-
const
|
|
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
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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 =
|
|
240
|
-
env.OPENCLAW_MCP_AGENT_ID = opts.agentId
|
|
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
|
-
//
|
|
246
|
-
|
|
247
|
-
mkdirSync(
|
|
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:
|
|
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
|
-
|
|
338
|
-
|
|
339
|
-
|
|
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
|
-
|
|
424
|
-
|
|
425
|
-
|
|
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) {
|