@zeulewan/glueclaw-provider 1.5.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 +2 -1
- package/index.ts +31 -13
- package/openclaw.plugin.json +8 -0
- package/package.json +1 -1
- package/src/stream.ts +148 -49
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
|
|
|
@@ -67,6 +67,7 @@ export GLUECLAW_REQUEST_TIMEOUT_MS=600000
|
|
|
67
67
|
- Tested with Telegram and OpenClaw TUI
|
|
68
68
|
- Switching between GlueClaw and other backends (e.g. Codex) works seamlessly via `/model`
|
|
69
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/openclaw.plugin.json
CHANGED
package/package.json
CHANGED
package/src/stream.ts
CHANGED
|
@@ -26,6 +26,8 @@ interface StreamEventData {
|
|
|
26
26
|
subtype?: string;
|
|
27
27
|
session_id?: string;
|
|
28
28
|
result?: string;
|
|
29
|
+
is_error?: boolean;
|
|
30
|
+
errors?: string[];
|
|
29
31
|
usage?: Record<string, number>;
|
|
30
32
|
event?: {
|
|
31
33
|
delta?: { type?: string; text?: string };
|
|
@@ -36,31 +38,53 @@ interface StreamEventData {
|
|
|
36
38
|
}
|
|
37
39
|
|
|
38
40
|
/** Track claude session IDs per session key for multi-turn resume.
|
|
39
|
-
* Persisted
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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);
|
|
49
67
|
}
|
|
50
|
-
|
|
51
|
-
// Expected on first run when session file doesn't exist
|
|
68
|
+
return store;
|
|
52
69
|
}
|
|
53
70
|
|
|
54
|
-
|
|
71
|
+
function persistStore(store: SessionStore): void {
|
|
55
72
|
try {
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
|
59
77
|
} catch {
|
|
60
78
|
// Best-effort persistence — non-fatal if disk write fails
|
|
61
79
|
}
|
|
62
80
|
}
|
|
63
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
|
+
|
|
64
88
|
export function buildUsage(raw?: Record<string, number>): Usage {
|
|
65
89
|
return {
|
|
66
90
|
input: raw?.input_tokens ?? 0,
|
|
@@ -247,11 +271,50 @@ export function unscrubResponse(text: string): string {
|
|
|
247
271
|
.replace(/\[\[reply:/g, "[[reply_to:");
|
|
248
272
|
}
|
|
249
273
|
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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);
|
|
255
318
|
else break;
|
|
256
319
|
}
|
|
257
320
|
}
|
|
@@ -260,6 +323,7 @@ export function createClaudeCliStreamFn(opts: {
|
|
|
260
323
|
claudeBin?: string;
|
|
261
324
|
sessionKey?: string;
|
|
262
325
|
agentId?: string;
|
|
326
|
+
workspaceDir: string;
|
|
263
327
|
modelOverride?: string;
|
|
264
328
|
requestTimeoutMs?: number;
|
|
265
329
|
}): StreamFn {
|
|
@@ -299,28 +363,17 @@ export function createClaudeCliStreamFn(opts: {
|
|
|
299
363
|
// leaving no way for callers to reinforce or correct an agent's
|
|
300
364
|
// identity across turns.
|
|
301
365
|
const sessionKey = `glueclaw:${effectiveSessionKey}`;
|
|
302
|
-
const
|
|
366
|
+
const sessionStore = getSessionStore(opts.workspaceDir);
|
|
367
|
+
const existingSessionId = sessionStore.map.get(sessionKey);
|
|
303
368
|
if (existingSessionId) {
|
|
304
369
|
args.push("--resume", existingSessionId);
|
|
305
370
|
}
|
|
306
371
|
if (cleanPrompt) args.push("--system-prompt", cleanPrompt);
|
|
307
372
|
if (resolvedModel) args.push("--model", resolvedModel);
|
|
308
373
|
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
.reverse()
|
|
313
|
-
.find((m) => m.role === "user");
|
|
314
|
-
let prompt = "";
|
|
315
|
-
if (lastUser) {
|
|
316
|
-
const c = lastUser.content;
|
|
317
|
-
if (typeof c === "string") prompt = c;
|
|
318
|
-
else if (Array.isArray(c))
|
|
319
|
-
prompt = c
|
|
320
|
-
.filter((b): b is TextContent => b.type === "text")
|
|
321
|
-
.map((b) => b.text)
|
|
322
|
-
.join("\n");
|
|
323
|
-
}
|
|
374
|
+
const prompt = extractPromptText(
|
|
375
|
+
context.messages as MessageLike[] | undefined,
|
|
376
|
+
);
|
|
324
377
|
if (prompt) args.push(prompt);
|
|
325
378
|
|
|
326
379
|
const env = { ...process.env };
|
|
@@ -330,22 +383,33 @@ export function createClaudeCliStreamFn(opts: {
|
|
|
330
383
|
// Wire up MCP bridge for OpenClaw gateway tools
|
|
331
384
|
const loopback = await getMcpLoopback();
|
|
332
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
|
+
}
|
|
333
397
|
const mcp = writeMcpConfig(loopback.port);
|
|
334
398
|
mcpCleanup = mcp.cleanup;
|
|
335
399
|
args.push("--strict-mcp-config", "--mcp-config", mcp.path);
|
|
336
400
|
env.OPENCLAW_MCP_TOKEN = loopback.token;
|
|
337
401
|
env.OPENCLAW_MCP_SESSION_KEY = effectiveSessionKey;
|
|
338
|
-
env.OPENCLAW_MCP_AGENT_ID = opts.agentId
|
|
402
|
+
env.OPENCLAW_MCP_AGENT_ID = opts.agentId;
|
|
339
403
|
env.OPENCLAW_MCP_ACCOUNT_ID = "";
|
|
340
404
|
env.OPENCLAW_MCP_MESSAGE_CHANNEL = "";
|
|
341
405
|
}
|
|
342
406
|
|
|
343
|
-
//
|
|
344
|
-
|
|
345
|
-
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 });
|
|
346
410
|
const proc = spawn(claudeBin, args, {
|
|
347
411
|
stdio: ["pipe", "pipe", "pipe"],
|
|
348
|
-
cwd:
|
|
412
|
+
cwd: opts.workspaceDir,
|
|
349
413
|
env,
|
|
350
414
|
});
|
|
351
415
|
if (options?.signal)
|
|
@@ -432,9 +496,9 @@ export function createClaudeCliStreamFn(opts: {
|
|
|
432
496
|
if (type === "system" && data.subtype === "init") {
|
|
433
497
|
const sid = data.session_id;
|
|
434
498
|
if (sid) {
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
499
|
+
sessionStore.map.set(sessionKey, sid);
|
|
500
|
+
evictStore(sessionStore);
|
|
501
|
+
persistStore(sessionStore);
|
|
438
502
|
}
|
|
439
503
|
continue;
|
|
440
504
|
}
|
|
@@ -516,11 +580,46 @@ export function createClaudeCliStreamFn(opts: {
|
|
|
516
580
|
|
|
517
581
|
// Result event (final) - authoritative response
|
|
518
582
|
if (type === "result") {
|
|
583
|
+
const isError =
|
|
584
|
+
data.is_error === true ||
|
|
585
|
+
data.subtype === "error_during_execution";
|
|
519
586
|
const sid = data.session_id;
|
|
520
|
-
if (sid) {
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
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);
|
|
524
623
|
}
|
|
525
624
|
// Only use result text if nothing came through streaming or assistant
|
|
526
625
|
if (!text) {
|