@zeulewan/glueclaw-provider 1.3.0 → 1.5.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
@@ -50,11 +50,23 @@ The only way this breaks is if Anthropic changes how `--system-prompt` or `--out
50
50
 
51
51
  Switch in TUI: `/model glueclaw/glueclaw-opus`
52
52
 
53
+ ## Configuration
54
+
55
+ | Env var | Default | Description |
56
+ | ----------------------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
57
+ | `GLUECLAW_REQUEST_TIMEOUT_MS` | `120000` | Maximum time (in milliseconds) to wait for the Claude CLI subprocess to complete a single request before it is terminated. Increase if long-running tool calls or extensive reasoning trip the default 120s limit. Invalid or non-positive values fall back to the default. |
58
+
59
+ Example (10 minute timeout):
60
+
61
+ ```bash
62
+ export GLUECLAW_REQUEST_TIMEOUT_MS=600000
63
+ ```
64
+
53
65
  ## Notes
54
66
 
55
67
  - Tested with Telegram and OpenClaw TUI
56
68
  - Switching between GlueClaw and other backends (e.g. Codex) works seamlessly via `/model`
57
- - 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.
58
70
 
59
71
  ## Disclaimer
60
72
 
package/index.ts CHANGED
@@ -1,9 +1,11 @@
1
+ import { basename } from "node:path";
1
2
  import {
2
3
  definePluginEntry,
3
4
  type OpenClawPluginApi,
4
5
  } from "openclaw/plugin-sdk/plugin-entry";
5
6
  import { createClaudeCliStreamFn } from "./src/stream.js";
6
7
  import { MODEL_CATALOG } from "./src/catalog.js";
8
+ import { resolveSessionKey } from "./src/session-key.js";
7
9
 
8
10
  const PROVIDER_ID = "glueclaw";
9
11
  const PROVIDER_LABEL = "GlueClaw";
@@ -18,6 +20,17 @@ const MODEL_MAP: Readonly<Record<string, string>> = {
18
20
  "glueclaw-haiku": "claude-haiku-4-5",
19
21
  };
20
22
 
23
+ const DEFAULT_REQUEST_TIMEOUT_MS = 120_000;
24
+
25
+ function resolveRequestTimeoutMs(): number {
26
+ const raw = process.env.GLUECLAW_REQUEST_TIMEOUT_MS;
27
+ if (raw === undefined || raw === "") return DEFAULT_REQUEST_TIMEOUT_MS;
28
+ const parsed = Number(raw);
29
+ if (!Number.isFinite(parsed) || parsed <= 0)
30
+ return DEFAULT_REQUEST_TIMEOUT_MS;
31
+ return parsed;
32
+ }
33
+
21
34
  export default definePluginEntry({
22
35
  register(api: OpenClawPluginApi): void {
23
36
  const authProfile = () =>
@@ -69,11 +82,19 @@ export default definePluginEntry({
69
82
  },
70
83
  }),
71
84
  },
72
- createStreamFn: (ctx: { modelId: string; agentDir?: string }) => {
85
+ createStreamFn: (ctx: {
86
+ modelId: string;
87
+ agentDir?: string;
88
+ sessionId?: string;
89
+ sessionKey?: string;
90
+ }) => {
73
91
  const realModel = MODEL_MAP[ctx.modelId] ?? ctx.modelId;
92
+ const agentId = ctx.agentDir ? basename(ctx.agentDir) : undefined;
74
93
  return createClaudeCliStreamFn({
75
- sessionKey: ctx.agentDir ?? "default",
94
+ sessionKey: resolveSessionKey(ctx),
95
+ agentId,
76
96
  modelOverride: realModel,
97
+ requestTimeoutMs: resolveRequestTimeoutMs(),
77
98
  });
78
99
  },
79
100
  resolveSyntheticAuth: () => ({
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."
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zeulewan/glueclaw-provider",
3
- "version": "1.3.0",
3
+ "version": "1.5.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;
@@ -91,12 +93,99 @@ export function buildMsg(
91
93
  };
92
94
  }
93
95
 
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;
96
+ interface McpLoopbackRuntime {
97
+ port: number;
98
+ ownerToken: string;
99
+ nonOwnerToken?: string;
100
+ }
101
+
102
+ let _mcpLoopback: { port: number; token: string } | undefined;
103
+ let _mcpBootstrapAttempted = false;
104
+
105
+ function getEnvMcpLoopback(): { port: number; token: string } | undefined {
106
+ const portRaw = process.env.__GLUECLAW_MCP_PORT;
98
107
  const token = process.env.__GLUECLAW_MCP_TOKEN;
99
- if (port && token) return { port: parseInt(port, 10), token };
108
+ if (!portRaw || !token) return undefined;
109
+
110
+ const port = Number.parseInt(portRaw, 10);
111
+ if (!Number.isFinite(port) || port <= 0) return undefined;
112
+ return { port, token };
113
+ }
114
+
115
+ function openClawDistFromNodePath(nodePath: string): string | undefined {
116
+ const normalized = normalize(nodePath);
117
+ if (!normalized.includes("openclaw")) return undefined;
118
+ if (basename(normalized) !== "node_modules") return undefined;
119
+ return join(dirname(normalized), "dist");
120
+ }
121
+
122
+ export function resetMcpLoopbackForTests(): void {
123
+ _mcpLoopback = undefined;
124
+ _mcpBootstrapAttempted = false;
125
+ }
126
+
127
+ /** Bootstrap OpenClaw's MCP loopback server in-process and return the
128
+ * port + owner token. GlueClaw runs inside the gateway process, so we
129
+ * share OpenClaw's module cache: importing the same `mcp-http-*.js`
130
+ * the gateway loaded gives us the singleton, and a no-op when another
131
+ * caller already started it.
132
+ *
133
+ * Returns undefined if the OpenClaw dist cannot be located or its API
134
+ * has changed — in that case the claude subprocess simply runs without
135
+ * session tools, matching pre-RFC-001 behavior. */
136
+ export async function getMcpLoopback(): Promise<
137
+ { port: number; token: string } | undefined
138
+ > {
139
+ const envLoopback = getEnvMcpLoopback();
140
+ if (envLoopback) return envLoopback;
141
+
142
+ if (_mcpLoopback) return _mcpLoopback;
143
+ if (_mcpBootstrapAttempted) return undefined;
144
+ _mcpBootstrapAttempted = true;
145
+
146
+ try {
147
+ const { readdir } = await import("node:fs/promises");
148
+ const nodePaths = (process.env.NODE_PATH ?? "").split(delimiter);
149
+ const distDirs = nodePaths
150
+ .map(openClawDistFromNodePath)
151
+ .filter((p): p is string => Boolean(p));
152
+
153
+ for (const distDir of distDirs) {
154
+ try {
155
+ const files = await readdir(distDir);
156
+ const mcpFile = files.find(
157
+ (f) => f.startsWith("mcp-http-") && f.endsWith(".js"),
158
+ );
159
+ if (!mcpFile) continue;
160
+ const mod = (await import(
161
+ pathToFileURL(join(distDir, mcpFile)).href
162
+ )) as Record<string, unknown>;
163
+ // Minified aliases: n=ensureMcpLoopbackServer, i=getActiveMcpLoopbackRuntime
164
+ const ensureFn = (mod["n"] ?? mod["ensureMcpLoopbackServer"]) as
165
+ | (() => Promise<unknown>)
166
+ | undefined;
167
+ const getRuntime = (mod["i"] ?? mod["getActiveMcpLoopbackRuntime"]) as
168
+ | (() => McpLoopbackRuntime | undefined)
169
+ | undefined;
170
+ if (
171
+ typeof ensureFn !== "function" ||
172
+ typeof getRuntime !== "function"
173
+ ) {
174
+ continue;
175
+ }
176
+ await ensureFn();
177
+ const runtime = getRuntime();
178
+ if (runtime?.port && runtime.ownerToken) {
179
+ _mcpLoopback = { port: runtime.port, token: runtime.ownerToken };
180
+ return _mcpLoopback;
181
+ }
182
+ } catch {
183
+ continue;
184
+ }
185
+ }
186
+ } catch {
187
+ // Non-fatal: session tools simply won't be available
188
+ }
100
189
  return undefined;
101
190
  }
102
191
 
@@ -170,6 +259,7 @@ function evictSessions(): void {
170
259
  export function createClaudeCliStreamFn(opts: {
171
260
  claudeBin?: string;
172
261
  sessionKey?: string;
262
+ agentId?: string;
173
263
  modelOverride?: string;
174
264
  requestTimeoutMs?: number;
175
265
  }): StreamFn {
@@ -183,6 +273,15 @@ export function createClaudeCliStreamFn(opts: {
183
273
  let mcpCleanup: (() => void) | undefined;
184
274
  let stderrBuf = "";
185
275
  try {
276
+ const turnSessionKey = deriveTurnSessionKey({
277
+ agentId: opts.agentId,
278
+ systemPrompt: context.systemPrompt,
279
+ messages: context.messages as
280
+ | Array<{ role: string; content: unknown }>
281
+ | undefined,
282
+ });
283
+ const effectiveSessionKey =
284
+ turnSessionKey ?? opts.sessionKey ?? "default";
186
285
  // Scrub Anthropic detection triggers (see docs/detection-patterns.md)
187
286
  const cleanPrompt = scrubPrompt(context.systemPrompt ?? "");
188
287
  const resolvedModel = opts.modelOverride ?? model.id;
@@ -194,14 +293,17 @@ export function createClaudeCliStreamFn(opts: {
194
293
  "--verbose",
195
294
  "--include-partial-messages",
196
295
  ];
197
- // Resume session for multi-turn conversation memory
198
- const sessionKey = `glueclaw:${opts.sessionKey ?? "default"}`;
296
+ // Resume session for multi-turn conversation memory.
297
+ // Always re-inject the system prompt — on resumptions the CLI would
298
+ // otherwise stick to whatever identity was used on the first turn,
299
+ // leaving no way for callers to reinforce or correct an agent's
300
+ // identity across turns.
301
+ const sessionKey = `glueclaw:${effectiveSessionKey}`;
199
302
  const existingSessionId = sessionMap.get(sessionKey);
200
303
  if (existingSessionId) {
201
304
  args.push("--resume", existingSessionId);
202
- } else {
203
- if (cleanPrompt) args.push("--system-prompt", cleanPrompt);
204
305
  }
306
+ if (cleanPrompt) args.push("--system-prompt", cleanPrompt);
205
307
  if (resolvedModel) args.push("--model", resolvedModel);
206
308
 
207
309
  // Debug: log args for resume troubleshooting
@@ -226,14 +328,14 @@ export function createClaudeCliStreamFn(opts: {
226
328
  delete env.ANTHROPIC_API_KEY_OLD;
227
329
 
228
330
  // Wire up MCP bridge for OpenClaw gateway tools
229
- const loopback = getMcpLoopback();
331
+ const loopback = await getMcpLoopback();
230
332
  if (loopback) {
231
333
  const mcp = writeMcpConfig(loopback.port);
232
334
  mcpCleanup = mcp.cleanup;
233
335
  args.push("--strict-mcp-config", "--mcp-config", mcp.path);
234
336
  env.OPENCLAW_MCP_TOKEN = loopback.token;
235
- env.OPENCLAW_MCP_SESSION_KEY = opts.sessionKey ?? "";
236
- env.OPENCLAW_MCP_AGENT_ID = "main";
337
+ env.OPENCLAW_MCP_SESSION_KEY = effectiveSessionKey;
338
+ env.OPENCLAW_MCP_AGENT_ID = opts.agentId ?? "main";
237
339
  env.OPENCLAW_MCP_ACCOUNT_ID = "";
238
340
  env.OPENCLAW_MCP_MESSAGE_CHANNEL = "";
239
341
  }