@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 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
 
@@ -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 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
  },
@@ -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.5.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
@@ -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 to disk so sessions survive gateway restarts. */
40
- const GC_HOME = join(process.env.HOME ?? tmpdir(), ".glueclaw");
41
- const SESSION_FILE = join(GC_HOME, "sessions.json");
42
- const sessionMap = new Map<string, string>();
43
-
44
- // Load persisted sessions on startup
45
- try {
46
- const saved = JSON.parse(readFileSync(SESSION_FILE, "utf8"));
47
- for (const [k, v] of Object.entries(saved)) {
48
- 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);
49
67
  }
50
- } catch {
51
- // Expected on first run when session file doesn't exist
68
+ return store;
52
69
  }
53
70
 
54
- export function persistSessions(): void {
71
+ function persistStore(store: SessionStore): void {
55
72
  try {
56
- const tmp = SESSION_FILE + ".tmp";
57
- writeFileSync(tmp, JSON.stringify(Object.fromEntries(sessionMap)));
58
- 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
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
- /** Evict oldest sessions when map exceeds MAX_SESSIONS */
251
- function evictSessions(): void {
252
- while (sessionMap.size > MAX_SESSIONS) {
253
- const oldest = sessionMap.keys().next().value;
254
- 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);
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 existingSessionId = sessionMap.get(sessionKey);
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
- // Debug: log args for resume troubleshooting
310
- // Extract user message and scrub it too
311
- const lastUser = [...(context.messages ?? [])]
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 ?? "main";
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
- // Use persistent dir so claude sessions survive restarts
344
- const gcHome = join(process.env.HOME ?? "/tmp", ".glueclaw");
345
- 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 });
346
410
  const proc = spawn(claudeBin, args, {
347
411
  stdio: ["pipe", "pipe", "pipe"],
348
- cwd: gcHome,
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
- sessionMap.set(sessionKey, sid);
436
- evictSessions();
437
- persistSessions();
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
- sessionMap.set(sessionKey, sid);
522
- evictSessions();
523
- 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);
524
623
  }
525
624
  // Only use result text if nothing came through streaming or assistant
526
625
  if (!text) {