@elvatis_com/openclaw-cli-bridge-elvatis 0.2.26 → 0.2.27
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 +8 -1
- package/SKILL.md +1 -1
- package/index.ts +125 -28
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/src/grok-client.ts +168 -347
- package/src/grok-session.ts +31 -31
- package/src/proxy-server.ts +7 -1
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
> OpenClaw plugin that bridges locally installed AI CLIs (Codex, Gemini, Claude Code) as model providers — with slash commands for instant model switching, restore, health testing, and model listing.
|
|
4
4
|
|
|
5
|
-
**Current version:** `0.2.
|
|
5
|
+
**Current version:** `0.2.27`
|
|
6
6
|
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -287,6 +287,13 @@ npm test # vitest run (45 tests)
|
|
|
287
287
|
|
|
288
288
|
## Changelog
|
|
289
289
|
|
|
290
|
+
### v0.2.27
|
|
291
|
+
- **feat:** Grok persistent Chromium profile (`~/.openclaw/grok-profile/`) — cookies survive gateway restarts
|
|
292
|
+
- **feat:** `/grok-login` imports cookies from OpenClaw browser into persistent profile automatically
|
|
293
|
+
- **fix:** `verifySession` reuses existing grok.com page instead of opening a new one (avoids Cloudflare 403)
|
|
294
|
+
- **fix:** DOM-polling strategy instead of direct fetch API — bypasses `x-statsig-id` anti-bot check completely
|
|
295
|
+
- **fix:** Lazy-connect: `connectGrokContext` callback auto-reconnects on first request after restart
|
|
296
|
+
|
|
290
297
|
### v0.2.26
|
|
291
298
|
- **feat:** Grok web-session bridge integrated into cli-bridge proxy — routes `web-grok/*` models through grok.com browser session (SuperGrok subscription, no API credits needed)
|
|
292
299
|
- **feat:** `/grok-login` — opens Chromium for X.com OAuth login, saves session to `~/.openclaw/grok-session.json`
|
package/SKILL.md
CHANGED
package/index.ts
CHANGED
|
@@ -86,27 +86,76 @@ interface CliPluginConfig {
|
|
|
86
86
|
let grokBrowser: Browser | null = null;
|
|
87
87
|
let grokContext: BrowserContext | null = null;
|
|
88
88
|
|
|
89
|
-
|
|
89
|
+
// Persistent profile dir — survives gateway restarts, keeps cookies intact
|
|
90
|
+
const GROK_PROFILE_DIR = join(homedir(), ".openclaw", "grok-profile");
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Launch (or reuse) a persistent headless Chromium context for grok.com.
|
|
94
|
+
* Uses launchPersistentContext so cookies survive gateway restarts.
|
|
95
|
+
* The profile lives at ~/.openclaw/grok-profile/
|
|
96
|
+
*/
|
|
97
|
+
async function getOrLaunchGrokContext(
|
|
98
|
+
log: (msg: string) => void
|
|
99
|
+
): Promise<BrowserContext | null> {
|
|
100
|
+
// Already have a live context?
|
|
101
|
+
if (grokContext) {
|
|
102
|
+
try {
|
|
103
|
+
// Quick check: can we still enumerate pages?
|
|
104
|
+
grokContext.pages();
|
|
105
|
+
return grokContext;
|
|
106
|
+
} catch {
|
|
107
|
+
grokContext = null;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
90
111
|
const { chromium } = await import("playwright");
|
|
91
|
-
|
|
112
|
+
|
|
113
|
+
// 1. Try connecting to the OpenClaw managed browser first (user may have grok.com open)
|
|
114
|
+
try {
|
|
115
|
+
const browser = await chromium.connectOverCDP("http://127.0.0.1:18800", { timeout: 2000 });
|
|
116
|
+
grokBrowser = browser;
|
|
117
|
+
const ctx = browser.contexts()[0];
|
|
118
|
+
if (ctx) {
|
|
119
|
+
log("[cli-bridge:grok] connected to OpenClaw browser");
|
|
120
|
+
return ctx;
|
|
121
|
+
}
|
|
122
|
+
} catch {
|
|
123
|
+
// OpenClaw browser not available — fall through to persistent context
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// 2. Launch our own persistent headless Chromium with saved profile
|
|
127
|
+
log("[cli-bridge:grok] launching persistent Chromium…");
|
|
128
|
+
try {
|
|
129
|
+
const ctx = await chromium.launchPersistentContext(GROK_PROFILE_DIR, {
|
|
130
|
+
headless: true,
|
|
131
|
+
args: ["--no-sandbox", "--disable-setuid-sandbox"],
|
|
132
|
+
});
|
|
133
|
+
grokContext = ctx;
|
|
134
|
+
log("[cli-bridge:grok] persistent context ready");
|
|
135
|
+
return ctx;
|
|
136
|
+
} catch (err) {
|
|
137
|
+
log(`[cli-bridge:grok] failed to launch browser: ${(err as Error).message}`);
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async function connectToOpenClawBrowser(
|
|
143
|
+
log: (msg: string) => void
|
|
144
|
+
): Promise<BrowserContext | null> {
|
|
145
|
+
return getOrLaunchGrokContext(log);
|
|
92
146
|
}
|
|
93
147
|
|
|
94
148
|
async function tryRestoreGrokSession(
|
|
95
|
-
|
|
149
|
+
_sessionPath: string,
|
|
96
150
|
log: (msg: string) => void
|
|
97
151
|
): Promise<boolean> {
|
|
98
|
-
const saved = loadSession(sessionPath);
|
|
99
|
-
if (!saved || isSessionExpiredByAge(saved)) {
|
|
100
|
-
log("[cli-bridge:grok] no valid saved session");
|
|
101
|
-
return false;
|
|
102
|
-
}
|
|
103
152
|
try {
|
|
104
|
-
|
|
105
|
-
|
|
153
|
+
const ctx = await getOrLaunchGrokContext(log);
|
|
154
|
+
if (!ctx) return false;
|
|
155
|
+
|
|
106
156
|
const check = await verifySession(ctx, log);
|
|
107
157
|
if (!check.valid) {
|
|
108
|
-
log(`[cli-bridge:grok]
|
|
109
|
-
await ctx.close().catch(() => {});
|
|
158
|
+
log(`[cli-bridge:grok] session invalid: ${check.reason}`);
|
|
110
159
|
return false;
|
|
111
160
|
}
|
|
112
161
|
grokContext = ctx;
|
|
@@ -443,7 +492,7 @@ function proxyTestRequest(
|
|
|
443
492
|
const plugin = {
|
|
444
493
|
id: "openclaw-cli-bridge-elvatis",
|
|
445
494
|
name: "OpenClaw CLI Bridge",
|
|
446
|
-
version: "0.2.
|
|
495
|
+
version: "0.2.27",
|
|
447
496
|
description:
|
|
448
497
|
"Phase 1: openai-codex auth bridge. " +
|
|
449
498
|
"Phase 2: HTTP proxy for gemini/claude CLIs. " +
|
|
@@ -558,6 +607,14 @@ const plugin = {
|
|
|
558
607
|
log: (msg) => api.logger.info(msg),
|
|
559
608
|
warn: (msg) => api.logger.warn(msg),
|
|
560
609
|
getGrokContext: () => grokContext,
|
|
610
|
+
connectGrokContext: async () => {
|
|
611
|
+
const ctx = await connectToOpenClawBrowser((msg) => api.logger.info(msg));
|
|
612
|
+
if (ctx) {
|
|
613
|
+
const check = await verifySession(ctx, (msg) => api.logger.info(msg));
|
|
614
|
+
if (check.valid) { grokContext = ctx; return ctx; }
|
|
615
|
+
}
|
|
616
|
+
return null;
|
|
617
|
+
},
|
|
561
618
|
});
|
|
562
619
|
proxyServer = server;
|
|
563
620
|
api.logger.info(
|
|
@@ -582,6 +639,14 @@ const plugin = {
|
|
|
582
639
|
log: (msg) => api.logger.info(msg),
|
|
583
640
|
warn: (msg) => api.logger.warn(msg),
|
|
584
641
|
getGrokContext: () => grokContext,
|
|
642
|
+
connectGrokContext: async () => {
|
|
643
|
+
const ctx = await connectToOpenClawBrowser((msg) => api.logger.info(msg));
|
|
644
|
+
if (ctx) {
|
|
645
|
+
const check = await verifySession(ctx, (msg) => api.logger.info(msg));
|
|
646
|
+
if (check.valid) { grokContext = ctx; return ctx; }
|
|
647
|
+
}
|
|
648
|
+
return null;
|
|
649
|
+
},
|
|
585
650
|
});
|
|
586
651
|
proxyServer = server;
|
|
587
652
|
api.logger.info(`[cli-bridge] proxy ready on :${port} (retry)`);
|
|
@@ -831,20 +896,54 @@ const plugin = {
|
|
|
831
896
|
|
|
832
897
|
api.registerCommand({
|
|
833
898
|
name: "grok-login",
|
|
834
|
-
description: "
|
|
899
|
+
description: "Authenticate grok.com: imports cookies from OpenClaw browser into persistent profile",
|
|
835
900
|
handler: async (): Promise<PluginCommandResult> => {
|
|
836
901
|
if (grokContext) {
|
|
837
|
-
|
|
902
|
+
const check = await verifySession(grokContext, (msg) => api.logger.info(msg));
|
|
903
|
+
if (check.valid) {
|
|
904
|
+
return { text: "✅ Already connected to grok.com. Use `/grok-logout` first to reset." };
|
|
905
|
+
}
|
|
906
|
+
grokContext = null;
|
|
838
907
|
}
|
|
839
|
-
api.logger.info("[cli-bridge:grok]
|
|
908
|
+
api.logger.info("[cli-bridge:grok] /grok-login: importing session from OpenClaw browser…");
|
|
909
|
+
const { chromium } = await import("playwright");
|
|
910
|
+
|
|
911
|
+
// Step 1: try to grab cookies from the OpenClaw browser (user must have grok.com open)
|
|
912
|
+
let importedCookies: unknown[] = [];
|
|
840
913
|
try {
|
|
841
|
-
|
|
842
|
-
const
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
914
|
+
const ocBrowser = await chromium.connectOverCDP("http://127.0.0.1:18800", { timeout: 3000 });
|
|
915
|
+
const ocCtx = ocBrowser.contexts()[0];
|
|
916
|
+
if (ocCtx) {
|
|
917
|
+
importedCookies = await ocCtx.cookies(["https://grok.com", "https://x.ai", "https://accounts.x.ai"]);
|
|
918
|
+
api.logger.info(`[cli-bridge:grok] imported ${importedCookies.length} cookies from OpenClaw browser`);
|
|
919
|
+
}
|
|
920
|
+
await ocBrowser.close().catch(() => {});
|
|
921
|
+
} catch {
|
|
922
|
+
api.logger.info("[cli-bridge:grok] OpenClaw browser not available — using saved profile");
|
|
847
923
|
}
|
|
924
|
+
|
|
925
|
+
// Step 2: launch/connect persistent context and inject cookies
|
|
926
|
+
const ctx = await getOrLaunchGrokContext((msg) => api.logger.info(msg));
|
|
927
|
+
if (!ctx) return { text: "❌ Could not launch browser. Check server logs." };
|
|
928
|
+
|
|
929
|
+
if (importedCookies.length > 0) {
|
|
930
|
+
await ctx.addCookies(importedCookies as Parameters<typeof ctx.addCookies>[0]);
|
|
931
|
+
api.logger.info(`[cli-bridge:grok] cookies injected into persistent profile`);
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
// Step 3: navigate to grok.com and verify
|
|
935
|
+
const pages = ctx.pages();
|
|
936
|
+
const page = pages.find(p => p.url().includes("grok.com")) ?? await ctx.newPage();
|
|
937
|
+
if (!page.url().includes("grok.com")) {
|
|
938
|
+
await page.goto("https://grok.com", { waitUntil: "domcontentloaded", timeout: 15_000 });
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
const check = await verifySession(ctx, (msg) => api.logger.info(msg));
|
|
942
|
+
if (!check.valid) {
|
|
943
|
+
return { text: `❌ Session not valid: ${check.reason}\n\nMake sure grok.com is open in your browser and you're logged in, then run /grok-login again.` };
|
|
944
|
+
}
|
|
945
|
+
grokContext = ctx;
|
|
946
|
+
return { text: `✅ Grok session ready!\n\nModels available:\n• \`vllm/web-grok/grok-3\`\n• \`vllm/web-grok/grok-3-fast\`\n• \`vllm/web-grok/grok-3-mini\`\n• \`vllm/web-grok/grok-3-mini-fast\`\n\nSession persists across gateway restarts (profile: ~/.openclaw/grok-profile/)` };
|
|
848
947
|
},
|
|
849
948
|
} satisfies OpenClawPluginCommandDefinition);
|
|
850
949
|
|
|
@@ -866,14 +965,12 @@ const plugin = {
|
|
|
866
965
|
|
|
867
966
|
api.registerCommand({
|
|
868
967
|
name: "grok-logout",
|
|
869
|
-
description: "
|
|
968
|
+
description: "Disconnect from grok.com session (does not close the browser)",
|
|
870
969
|
handler: async (): Promise<PluginCommandResult> => {
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
grokContext = null;
|
|
874
|
-
}
|
|
970
|
+
// Don't close the context — it belongs to the OpenClaw browser, not us
|
|
971
|
+
grokContext = null;
|
|
875
972
|
deleteSession(grokSessionPath);
|
|
876
|
-
return { text: "✅
|
|
973
|
+
return { text: "✅ Disconnected from grok.com. Run `/grok-login` to reconnect." };
|
|
877
974
|
},
|
|
878
975
|
} satisfies OpenClawPluginCommandDefinition);
|
|
879
976
|
|
package/openclaw.plugin.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"id": "openclaw-cli-bridge-elvatis",
|
|
3
3
|
"name": "OpenClaw CLI Bridge",
|
|
4
|
-
"version": "0.2.
|
|
4
|
+
"version": "0.2.27",
|
|
5
5
|
"description": "Phase 1: openai-codex auth bridge. Phase 2: local HTTP proxy routing model calls through gemini/claude CLIs (vllm provider).",
|
|
6
6
|
"providers": [
|
|
7
7
|
"openai-codex"
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@elvatis_com/openclaw-cli-bridge-elvatis",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.27",
|
|
4
4
|
"description": "Bridges gemini, claude, and codex CLI tools as OpenClaw model providers. Reads existing CLI auth without re-login.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"openclaw": {
|
package/src/grok-client.ts
CHANGED
|
@@ -1,16 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* grok-client.ts
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* using an authenticated browser session (cookies).
|
|
4
|
+
* Grok.com integration via Playwright DOM automation.
|
|
6
5
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
6
|
+
* Strategy: inject messages via the ProseMirror editor, poll `.message-bubble`
|
|
7
|
+
* DOM elements for the response. This bypasses Cloudflare anti-bot checks
|
|
8
|
+
* on direct API calls (which require signed x-statsig-id headers generated
|
|
9
|
+
* inside the page's own bundle — not accessible externally).
|
|
9
10
|
*
|
|
10
|
-
*
|
|
11
|
+
* Works by connecting to the running OpenClaw browser (CDP port 18800) which
|
|
12
|
+
* already has an authenticated grok.com session open.
|
|
11
13
|
*/
|
|
12
14
|
|
|
13
|
-
import type { BrowserContext } from "playwright";
|
|
15
|
+
import type { BrowserContext, Page } from "playwright";
|
|
14
16
|
|
|
15
17
|
// ──────────────────────────────────────────────────────────────────────────────
|
|
16
18
|
// Types
|
|
@@ -23,10 +25,7 @@ export interface ChatMessage {
|
|
|
23
25
|
|
|
24
26
|
export interface GrokCompleteOptions {
|
|
25
27
|
messages: ChatMessage[];
|
|
26
|
-
model?: string;
|
|
27
|
-
stream?: boolean;
|
|
28
|
-
maxTokens?: number;
|
|
29
|
-
temperature?: number;
|
|
28
|
+
model?: string;
|
|
30
29
|
timeoutMs?: number;
|
|
31
30
|
}
|
|
32
31
|
|
|
@@ -34,395 +33,217 @@ export interface GrokCompleteResult {
|
|
|
34
33
|
content: string;
|
|
35
34
|
model: string;
|
|
36
35
|
finishReason: string;
|
|
37
|
-
/** estimated — grok.com doesn't expose exact token counts */
|
|
38
36
|
promptTokens?: number;
|
|
39
37
|
completionTokens?: number;
|
|
40
38
|
}
|
|
41
39
|
|
|
42
|
-
/** SSE token event from grok.com */
|
|
43
|
-
interface GrokTokenEvent {
|
|
44
|
-
result?: {
|
|
45
|
-
response?: {
|
|
46
|
-
token?: string;
|
|
47
|
-
finalMetadata?: {
|
|
48
|
-
inputTokenCount?: number;
|
|
49
|
-
outputTokenCount?: number;
|
|
50
|
-
};
|
|
51
|
-
modelResponse?: {
|
|
52
|
-
responseId?: string;
|
|
53
|
-
message?: string;
|
|
54
|
-
};
|
|
55
|
-
};
|
|
56
|
-
isSoftStop?: boolean;
|
|
57
|
-
};
|
|
58
|
-
error?: string;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
40
|
// ──────────────────────────────────────────────────────────────────────────────
|
|
62
|
-
//
|
|
41
|
+
// Constants
|
|
63
42
|
// ──────────────────────────────────────────────────────────────────────────────
|
|
64
43
|
|
|
65
|
-
const
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
"grok-3-mini": "grok-3-mini",
|
|
69
|
-
"grok-3-mini-fast": "grok-3-mini-fast",
|
|
70
|
-
// aliases
|
|
71
|
-
"grok": "grok-3",
|
|
72
|
-
"grok-fast": "grok-3-fast",
|
|
73
|
-
"grok-mini": "grok-3-mini",
|
|
74
|
-
};
|
|
75
|
-
|
|
76
|
-
function resolveModel(model?: string): string {
|
|
77
|
-
if (!model) return "grok-3";
|
|
78
|
-
return MODEL_MAP[model] ?? model;
|
|
79
|
-
}
|
|
44
|
+
const DEFAULT_TIMEOUT_MS = 120_000;
|
|
45
|
+
const STABLE_CHECKS = 3; // consecutive identical reads to consider "done"
|
|
46
|
+
const STABLE_INTERVAL_MS = 500; // ms between stability checks
|
|
80
47
|
|
|
81
48
|
// ──────────────────────────────────────────────────────────────────────────────
|
|
82
|
-
//
|
|
49
|
+
// Helpers
|
|
83
50
|
// ──────────────────────────────────────────────────────────────────────────────
|
|
84
51
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
// System prompt → prepended to first user message
|
|
91
|
-
const systemMsgs = opts.messages.filter((m) => m.role === "system");
|
|
92
|
-
const convMsgs = opts.messages.filter((m) => m.role !== "system");
|
|
52
|
+
function resolveModel(m?: string): string {
|
|
53
|
+
const clean = (m ?? "grok-3").replace("web-grok/", "");
|
|
54
|
+
const allowed = ["grok-3", "grok-3-fast", "grok-3-mini", "grok-3-mini-fast"];
|
|
55
|
+
return allowed.includes(clean) ? clean : "grok-3";
|
|
56
|
+
}
|
|
93
57
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
58
|
+
/**
|
|
59
|
+
* Flatten a multi-turn message array into a single string for the Grok UI.
|
|
60
|
+
*/
|
|
61
|
+
function flattenMessages(messages: ChatMessage[]): string {
|
|
62
|
+
if (messages.length === 1) return messages[0].content;
|
|
63
|
+
return messages
|
|
64
|
+
.map((m) => {
|
|
65
|
+
if (m.role === "system") return `[System]: ${m.content}`;
|
|
66
|
+
if (m.role === "assistant") return `[Assistant]: ${m.content}`;
|
|
67
|
+
return m.content;
|
|
68
|
+
})
|
|
69
|
+
.join("\n\n");
|
|
70
|
+
}
|
|
98
71
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
message: userPrompt,
|
|
111
|
-
fileAttachments: [],
|
|
112
|
-
imageAttachments: [],
|
|
113
|
-
disableSearch: false,
|
|
114
|
-
enableImageGeneration: false,
|
|
115
|
-
returnImageBytes: false,
|
|
116
|
-
returnRawGrokInXaiRequest: false,
|
|
117
|
-
enableSideBySide: false,
|
|
118
|
-
isReasoning: model.includes("mini"), // mini models support reasoning
|
|
119
|
-
conversationHistory: history,
|
|
120
|
-
toolOverrides: {},
|
|
121
|
-
enableCustomization: false,
|
|
122
|
-
deepsearchPreset: "",
|
|
123
|
-
isPreset: false,
|
|
124
|
-
};
|
|
72
|
+
/**
|
|
73
|
+
* Get an existing grok.com page from the context, or navigate to grok.com.
|
|
74
|
+
*/
|
|
75
|
+
export async function getOrCreateGrokPage(
|
|
76
|
+
context: BrowserContext
|
|
77
|
+
): Promise<{ page: Page; owned: boolean }> {
|
|
78
|
+
const existing = context.pages().filter((p) => p.url().startsWith("https://grok.com"));
|
|
79
|
+
if (existing.length > 0) return { page: existing[0], owned: false };
|
|
80
|
+
const page = await context.newPage();
|
|
81
|
+
await page.goto("https://grok.com", { waitUntil: "domcontentloaded", timeout: 15_000 });
|
|
82
|
+
return { page, owned: true };
|
|
125
83
|
}
|
|
126
84
|
|
|
127
85
|
// ──────────────────────────────────────────────────────────────────────────────
|
|
128
|
-
//
|
|
86
|
+
// Core DOM automation
|
|
129
87
|
// ──────────────────────────────────────────────────────────────────────────────
|
|
130
88
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
89
|
+
/**
|
|
90
|
+
* Send a message via the grok.com UI and wait for a stable response.
|
|
91
|
+
* Returns the final text content of the last `.message-bubble` element.
|
|
92
|
+
*/
|
|
93
|
+
async function sendAndWait(
|
|
94
|
+
page: Page,
|
|
95
|
+
message: string,
|
|
96
|
+
timeoutMs: number,
|
|
97
|
+
log: (msg: string) => void
|
|
98
|
+
): Promise<string> {
|
|
99
|
+
// Count current message bubbles
|
|
100
|
+
const countBefore = await page.evaluate(
|
|
101
|
+
() => document.querySelectorAll(".message-bubble").length
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
// Type the message into the ProseMirror editor
|
|
105
|
+
await page.evaluate((msg: string) => {
|
|
106
|
+
const ed =
|
|
107
|
+
document.querySelector(".ProseMirror") ||
|
|
108
|
+
document.querySelector('[contenteditable="true"]');
|
|
109
|
+
if (!ed) throw new Error("Grok editor not found");
|
|
110
|
+
(ed as HTMLElement).focus();
|
|
111
|
+
document.execCommand("insertText", false, msg);
|
|
112
|
+
}, message);
|
|
113
|
+
|
|
114
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
115
|
+
await page.keyboard.press("Enter");
|
|
116
|
+
|
|
117
|
+
log(`grok-client: message sent (${message.length} chars), waiting for response…`);
|
|
118
|
+
|
|
119
|
+
// Poll for a stable response
|
|
120
|
+
const deadline = Date.now() + timeoutMs;
|
|
121
|
+
let lastText = "";
|
|
122
|
+
let stableCount = 0;
|
|
123
|
+
|
|
124
|
+
while (Date.now() < deadline) {
|
|
125
|
+
await new Promise((r) => setTimeout(r, STABLE_INTERVAL_MS));
|
|
126
|
+
|
|
127
|
+
const text = await page.evaluate(
|
|
128
|
+
(before: number) => {
|
|
129
|
+
const bubbles = [...document.querySelectorAll(".message-bubble")];
|
|
130
|
+
if (bubbles.length <= before) return "";
|
|
131
|
+
return bubbles[bubbles.length - 1].textContent?.trim() ?? "";
|
|
132
|
+
},
|
|
133
|
+
countBefore
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
if (text && text === lastText) {
|
|
137
|
+
stableCount++;
|
|
138
|
+
if (stableCount >= STABLE_CHECKS) {
|
|
139
|
+
log(`grok-client: response stable (${text.length} chars)`);
|
|
140
|
+
return text;
|
|
141
|
+
}
|
|
142
|
+
} else {
|
|
143
|
+
stableCount = 0;
|
|
144
|
+
lastText = text;
|
|
145
|
+
}
|
|
139
146
|
}
|
|
147
|
+
|
|
148
|
+
throw new Error(`grok.com response timeout after ${timeoutMs}ms`);
|
|
140
149
|
}
|
|
141
150
|
|
|
142
151
|
// ──────────────────────────────────────────────────────────────────────────────
|
|
143
|
-
//
|
|
152
|
+
// Public API
|
|
144
153
|
// ──────────────────────────────────────────────────────────────────────────────
|
|
145
154
|
|
|
146
|
-
const GROK_API_URL = "https://grok.com/rest/app-chat/conversations/new";
|
|
147
|
-
const DEFAULT_TIMEOUT_MS = 120_000;
|
|
148
|
-
|
|
149
155
|
/**
|
|
150
|
-
*
|
|
151
|
-
* Uses page.evaluate to make the fetch from inside the authenticated browser context.
|
|
156
|
+
* Non-streaming completion.
|
|
152
157
|
*/
|
|
153
158
|
export async function grokComplete(
|
|
154
159
|
context: BrowserContext,
|
|
155
160
|
opts: GrokCompleteOptions,
|
|
156
161
|
log: (msg: string) => void
|
|
157
162
|
): Promise<GrokCompleteResult> {
|
|
158
|
-
const
|
|
163
|
+
const { page, owned } = await getOrCreateGrokPage(context);
|
|
159
164
|
const model = resolveModel(opts.model);
|
|
160
|
-
const
|
|
161
|
-
|
|
162
|
-
log(`grok-client: POST ${GROK_API_URL} model=${model}`);
|
|
165
|
+
const prompt = flattenMessages(opts.messages);
|
|
166
|
+
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
163
167
|
|
|
164
|
-
|
|
165
|
-
const page = await context.newPage();
|
|
168
|
+
log(`grok-client: complete model=${model}`);
|
|
166
169
|
|
|
167
170
|
try {
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
waitUntil: "domcontentloaded",
|
|
171
|
-
timeout: 15_000,
|
|
172
|
-
});
|
|
173
|
-
|
|
174
|
-
// Make the API call from within the page (inherits cookies automatically)
|
|
175
|
-
const result = await page.evaluate(
|
|
176
|
-
async ({ url, requestBody, timeout }: { url: string; requestBody: unknown; timeout: number }) => {
|
|
177
|
-
const controller = new AbortController();
|
|
178
|
-
const timer = setTimeout(() => controller.abort(), timeout);
|
|
179
|
-
|
|
180
|
-
try {
|
|
181
|
-
const resp = await fetch(url, {
|
|
182
|
-
method: "POST",
|
|
183
|
-
headers: {
|
|
184
|
-
"Content-Type": "application/json",
|
|
185
|
-
Accept: "text/event-stream",
|
|
186
|
-
},
|
|
187
|
-
body: JSON.stringify(requestBody),
|
|
188
|
-
credentials: "include",
|
|
189
|
-
signal: controller.signal,
|
|
190
|
-
});
|
|
191
|
-
|
|
192
|
-
if (!resp.ok) {
|
|
193
|
-
const errText = await resp.text().catch(() => "");
|
|
194
|
-
return {
|
|
195
|
-
error: `HTTP ${resp.status}: ${errText.substring(0, 300)}`,
|
|
196
|
-
content: "",
|
|
197
|
-
};
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
const reader = resp.body!.getReader();
|
|
201
|
-
const decoder = new TextDecoder();
|
|
202
|
-
let fullText = "";
|
|
203
|
-
let buffer = "";
|
|
204
|
-
let inputTokens = 0;
|
|
205
|
-
let outputTokens = 0;
|
|
206
|
-
let finishReason = "stop";
|
|
207
|
-
|
|
208
|
-
while (true) {
|
|
209
|
-
const { done, value } = await reader.read();
|
|
210
|
-
if (done) break;
|
|
211
|
-
|
|
212
|
-
buffer += decoder.decode(value, { stream: true });
|
|
213
|
-
const lines = buffer.split("\n");
|
|
214
|
-
buffer = lines.pop() ?? "";
|
|
215
|
-
|
|
216
|
-
for (const line of lines) {
|
|
217
|
-
if (!line.startsWith("data: ")) continue;
|
|
218
|
-
const data = line.slice(6).trim();
|
|
219
|
-
if (data === "[DONE]") {
|
|
220
|
-
finishReason = "stop";
|
|
221
|
-
continue;
|
|
222
|
-
}
|
|
223
|
-
try {
|
|
224
|
-
const evt = JSON.parse(data);
|
|
225
|
-
const response = evt?.result?.response;
|
|
226
|
-
if (response?.token) {
|
|
227
|
-
fullText += response.token;
|
|
228
|
-
}
|
|
229
|
-
if (response?.finalMetadata) {
|
|
230
|
-
inputTokens = response.finalMetadata.inputTokenCount ?? 0;
|
|
231
|
-
outputTokens = response.finalMetadata.outputTokenCount ?? 0;
|
|
232
|
-
}
|
|
233
|
-
if (evt?.result?.isSoftStop) {
|
|
234
|
-
finishReason = "stop";
|
|
235
|
-
}
|
|
236
|
-
if (evt?.error) {
|
|
237
|
-
return { error: String(evt.error), content: fullText };
|
|
238
|
-
}
|
|
239
|
-
} catch {
|
|
240
|
-
// ignore parse errors on individual SSE lines
|
|
241
|
-
}
|
|
242
|
-
}
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
return {
|
|
246
|
-
content: fullText,
|
|
247
|
-
inputTokens,
|
|
248
|
-
outputTokens,
|
|
249
|
-
finishReason,
|
|
250
|
-
};
|
|
251
|
-
} finally {
|
|
252
|
-
clearTimeout(timer);
|
|
253
|
-
}
|
|
254
|
-
},
|
|
255
|
-
{ url: GROK_API_URL, requestBody: body, timeout: timeoutMs }
|
|
256
|
-
);
|
|
257
|
-
|
|
258
|
-
if ("error" in result && result.error) {
|
|
259
|
-
throw new Error(`grok.com API error: ${result.error}`);
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
log(
|
|
263
|
-
`grok-client: done — ${result.outputTokens ?? "?"} output tokens`
|
|
264
|
-
);
|
|
265
|
-
|
|
266
|
-
return {
|
|
267
|
-
content: result.content ?? "",
|
|
268
|
-
model,
|
|
269
|
-
finishReason: result.finishReason ?? "stop",
|
|
270
|
-
promptTokens: result.inputTokens,
|
|
271
|
-
completionTokens: result.outputTokens,
|
|
272
|
-
};
|
|
171
|
+
const content = await sendAndWait(page, prompt, timeoutMs, log);
|
|
172
|
+
return { content, model, finishReason: "stop" };
|
|
273
173
|
} finally {
|
|
274
|
-
await page.close();
|
|
174
|
+
if (owned) await page.close().catch(() => {});
|
|
275
175
|
}
|
|
276
176
|
}
|
|
277
177
|
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
178
|
+
/**
|
|
179
|
+
* Streaming completion — polls the DOM and calls onToken when new text arrives.
|
|
180
|
+
*/
|
|
282
181
|
export async function grokCompleteStream(
|
|
283
182
|
context: BrowserContext,
|
|
284
183
|
opts: GrokCompleteOptions,
|
|
285
184
|
onToken: (token: string) => void,
|
|
286
185
|
log: (msg: string) => void
|
|
287
186
|
): Promise<GrokCompleteResult> {
|
|
288
|
-
|
|
289
|
-
// Because page.evaluate can't stream back to Node, we use a polling approach:
|
|
290
|
-
// write tokens to window.__grokTokenBuf, poll from Node side.
|
|
291
|
-
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
187
|
+
const { page, owned } = await getOrCreateGrokPage(context);
|
|
292
188
|
const model = resolveModel(opts.model);
|
|
293
|
-
const
|
|
294
|
-
|
|
295
|
-
log(`grok-client: streaming POST ${GROK_API_URL} model=${model}`);
|
|
296
|
-
|
|
297
|
-
const page = await context.newPage();
|
|
189
|
+
const prompt = flattenMessages(opts.messages);
|
|
190
|
+
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
298
191
|
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
(
|
|
309
|
-
(
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
if (!resp.ok) {
|
|
334
|
-
const errText = await resp.text().catch(() => "");
|
|
335
|
-
w.__grokError = `HTTP ${resp.status}: ${errText.substring(0, 300)}`;
|
|
336
|
-
w.__grokDone = true;
|
|
337
|
-
return;
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
const reader = resp.body!.getReader();
|
|
341
|
-
const decoder = new TextDecoder();
|
|
342
|
-
let buffer = "";
|
|
343
|
-
|
|
344
|
-
while (true) {
|
|
345
|
-
const { done, value } = await reader.read();
|
|
346
|
-
if (done) break;
|
|
347
|
-
buffer += decoder.decode(value, { stream: true });
|
|
348
|
-
const lines = buffer.split("\n");
|
|
349
|
-
buffer = lines.pop() ?? "";
|
|
350
|
-
|
|
351
|
-
for (const line of lines) {
|
|
352
|
-
if (!line.startsWith("data: ")) continue;
|
|
353
|
-
const data = line.slice(6).trim();
|
|
354
|
-
if (data === "[DONE]") continue;
|
|
355
|
-
try {
|
|
356
|
-
const evt = JSON.parse(data);
|
|
357
|
-
const response = evt?.result?.response;
|
|
358
|
-
if (response?.token) {
|
|
359
|
-
(w.__grokTokenBuf as string[]).push(response.token);
|
|
360
|
-
}
|
|
361
|
-
if (response?.finalMetadata) {
|
|
362
|
-
w.__grokMeta = response.finalMetadata;
|
|
363
|
-
}
|
|
364
|
-
if (evt?.error) {
|
|
365
|
-
w.__grokError = String(evt.error);
|
|
366
|
-
}
|
|
367
|
-
} catch {
|
|
368
|
-
// ignore
|
|
369
|
-
}
|
|
370
|
-
}
|
|
371
|
-
}
|
|
372
|
-
} catch (e: unknown) {
|
|
373
|
-
w.__grokError = String(e);
|
|
374
|
-
} finally {
|
|
375
|
-
w.__grokDone = true;
|
|
376
|
-
}
|
|
377
|
-
})();
|
|
192
|
+
log(`grok-client: stream model=${model}`);
|
|
193
|
+
|
|
194
|
+
const countBefore = await page.evaluate(
|
|
195
|
+
() => document.querySelectorAll(".message-bubble").length
|
|
196
|
+
);
|
|
197
|
+
|
|
198
|
+
// Send message
|
|
199
|
+
await page.evaluate((msg: string) => {
|
|
200
|
+
const ed =
|
|
201
|
+
document.querySelector(".ProseMirror") ||
|
|
202
|
+
document.querySelector('[contenteditable="true"]');
|
|
203
|
+
if (!ed) throw new Error("Grok editor not found");
|
|
204
|
+
(ed as HTMLElement).focus();
|
|
205
|
+
document.execCommand("insertText", false, msg);
|
|
206
|
+
}, prompt);
|
|
207
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
208
|
+
await page.keyboard.press("Enter");
|
|
209
|
+
|
|
210
|
+
log(`grok-client: message sent, streaming…`);
|
|
211
|
+
|
|
212
|
+
// Stream: poll DOM, emit new chars as tokens
|
|
213
|
+
const deadline = Date.now() + timeoutMs;
|
|
214
|
+
let emittedLength = 0;
|
|
215
|
+
let lastText = "";
|
|
216
|
+
let stableCount = 0;
|
|
217
|
+
|
|
218
|
+
while (Date.now() < deadline) {
|
|
219
|
+
await new Promise((r) => setTimeout(r, STABLE_INTERVAL_MS));
|
|
220
|
+
|
|
221
|
+
const text = await page.evaluate(
|
|
222
|
+
(before: number) => {
|
|
223
|
+
const bubbles = [...document.querySelectorAll(".message-bubble")];
|
|
224
|
+
if (bubbles.length <= before) return "";
|
|
225
|
+
return bubbles[bubbles.length - 1].textContent?.trim() ?? "";
|
|
378
226
|
},
|
|
379
|
-
|
|
227
|
+
countBefore
|
|
380
228
|
);
|
|
381
229
|
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
while (Date.now() < deadline) {
|
|
388
|
-
const state = await page.evaluate(() => {
|
|
389
|
-
const w = window as unknown as Record<string, unknown>;
|
|
390
|
-
const tokens = (w.__grokTokenBuf as string[]).splice(0);
|
|
391
|
-
return {
|
|
392
|
-
tokens,
|
|
393
|
-
done: w.__grokDone as boolean,
|
|
394
|
-
error: w.__grokError as string | null,
|
|
395
|
-
meta: w.__grokMeta as { inputTokenCount?: number; outputTokenCount?: number } | null,
|
|
396
|
-
};
|
|
397
|
-
});
|
|
398
|
-
|
|
399
|
-
for (const token of state.tokens) {
|
|
400
|
-
onToken(token);
|
|
401
|
-
fullContent += token;
|
|
402
|
-
}
|
|
403
|
-
|
|
404
|
-
if (state.error) {
|
|
405
|
-
throw new Error(`grok.com stream error: ${state.error}`);
|
|
406
|
-
}
|
|
230
|
+
if (text && text.length > emittedLength) {
|
|
231
|
+
const newChars = text.slice(emittedLength);
|
|
232
|
+
onToken(newChars);
|
|
233
|
+
emittedLength = text.length;
|
|
234
|
+
}
|
|
407
235
|
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
);
|
|
412
|
-
return {
|
|
413
|
-
content: fullContent,
|
|
414
|
-
model,
|
|
415
|
-
finishReason: "stop",
|
|
416
|
-
promptTokens: state.meta?.inputTokenCount,
|
|
417
|
-
completionTokens: state.meta?.outputTokenCount,
|
|
418
|
-
};
|
|
236
|
+
if (text && text === lastText) {
|
|
237
|
+
stableCount++;
|
|
238
|
+
if (stableCount >= STABLE_CHECKS) {
|
|
239
|
+
log(`grok-client: stream done (${text.length} chars)`);
|
|
240
|
+
return { content: text, model, finishReason: "stop" };
|
|
419
241
|
}
|
|
420
|
-
|
|
421
|
-
|
|
242
|
+
} else {
|
|
243
|
+
stableCount = 0;
|
|
244
|
+
lastText = text;
|
|
422
245
|
}
|
|
423
|
-
|
|
424
|
-
throw new Error(`grok.com stream timeout after ${timeoutMs}ms`);
|
|
425
|
-
} finally {
|
|
426
|
-
await page.close();
|
|
427
246
|
}
|
|
247
|
+
|
|
248
|
+
throw new Error(`grok.com stream timeout after ${timeoutMs}ms`);
|
|
428
249
|
}
|
package/src/grok-session.ts
CHANGED
|
@@ -87,51 +87,51 @@ export async function verifySession(
|
|
|
87
87
|
context: BrowserContext,
|
|
88
88
|
log: (msg: string) => void
|
|
89
89
|
): Promise<SessionCheckResult> {
|
|
90
|
+
log("verifying grok session...");
|
|
91
|
+
|
|
92
|
+
// Prefer an existing grok.com page — don't open a new one (new pages can
|
|
93
|
+
// trigger Cloudflare checks and displace the authenticated session page).
|
|
94
|
+
const existingPages = context.pages().filter((p) => p.url().startsWith("https://grok.com"));
|
|
95
|
+
if (existingPages.length > 0) {
|
|
96
|
+
const page = existingPages[0];
|
|
97
|
+
// Check for sign-in link on existing page
|
|
98
|
+
const signIn = page.locator('a[href*="sign-in"], a[href*="/login"]');
|
|
99
|
+
const signInVisible = await signIn.isVisible().catch(() => false);
|
|
100
|
+
if (signInVisible) return { valid: false, reason: "sign-in link visible — session expired" };
|
|
101
|
+
// Check for editor (logged in indicator)
|
|
102
|
+
const editor = page.locator('.ProseMirror, [contenteditable="true"]');
|
|
103
|
+
const editorVisible = await editor.isVisible().catch(() => false);
|
|
104
|
+
if (editorVisible) {
|
|
105
|
+
log("session valid ✅");
|
|
106
|
+
return { valid: true };
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// No existing page — open one to check, then leave it open for reuse
|
|
90
111
|
const page = await context.newPage();
|
|
91
112
|
try {
|
|
92
113
|
log("verifying grok session...");
|
|
93
114
|
await page.goto(GROK_HOME, { waitUntil: "domcontentloaded", timeout: 15_000 });
|
|
94
115
|
|
|
95
|
-
// If we see Sign In link → not logged in
|
|
96
116
|
const signIn = page.locator('a[href*="sign-in"], a[href*="/login"]');
|
|
97
|
-
|
|
98
|
-
|
|
117
|
+
if (await signIn.isVisible().catch(() => false)) {
|
|
118
|
+
await page.close();
|
|
99
119
|
return { valid: false, reason: "sign-in link visible — session expired" };
|
|
100
120
|
}
|
|
101
121
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
if (chatVisible) {
|
|
122
|
+
const editor = page.locator('.ProseMirror, [contenteditable="true"]');
|
|
123
|
+
if (await editor.isVisible().catch(() => false)) {
|
|
124
|
+
// Keep page open — grokComplete will reuse it
|
|
106
125
|
log("session valid ✅");
|
|
107
126
|
return { valid: true };
|
|
108
127
|
}
|
|
109
128
|
|
|
110
|
-
// Ambiguous —
|
|
111
|
-
|
|
112
|
-
try {
|
|
113
|
-
const r = await fetch("https://grok.com/rest/app-chat/conversations", {
|
|
114
|
-
method: "GET",
|
|
115
|
-
credentials: "include",
|
|
116
|
-
});
|
|
117
|
-
return { status: r.status };
|
|
118
|
-
} catch (e: unknown) {
|
|
119
|
-
return { status: 0, error: String(e) };
|
|
120
|
-
}
|
|
121
|
-
});
|
|
122
|
-
|
|
123
|
-
if (resp.status === 200 || resp.status === 204) {
|
|
124
|
-
log("session valid via API check ✅");
|
|
125
|
-
return { valid: true };
|
|
126
|
-
}
|
|
127
|
-
if (resp.status === 401 || resp.status === 403) {
|
|
128
|
-
return { valid: false, reason: `API returned ${resp.status}` };
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
log(`session check ambiguous (status ${resp.status}) — assuming valid`);
|
|
129
|
+
// Ambiguous — assume valid, keep page open
|
|
130
|
+
log("session check ambiguous — assuming valid");
|
|
132
131
|
return { valid: true };
|
|
133
|
-
}
|
|
134
|
-
await page.close();
|
|
132
|
+
} catch (err) {
|
|
133
|
+
await page.close().catch(() => {});
|
|
134
|
+
return { valid: false, reason: (err as Error).message };
|
|
135
135
|
}
|
|
136
136
|
}
|
|
137
137
|
|
package/src/proxy-server.ts
CHANGED
|
@@ -27,6 +27,8 @@ export interface ProxyServerOptions {
|
|
|
27
27
|
warn: (msg: string) => void;
|
|
28
28
|
/** Returns the current authenticated Grok BrowserContext (null if not logged in) */
|
|
29
29
|
getGrokContext?: () => BrowserContext | null;
|
|
30
|
+
/** Async lazy connect — called when getGrokContext returns null */
|
|
31
|
+
connectGrokContext?: () => Promise<BrowserContext | null>;
|
|
30
32
|
/** Override for testing — replaces grokComplete */
|
|
31
33
|
_grokComplete?: typeof grokComplete;
|
|
32
34
|
/** Override for testing — replaces grokCompleteStream */
|
|
@@ -198,7 +200,11 @@ async function handleRequest(
|
|
|
198
200
|
|
|
199
201
|
// ── Grok web-session routing ──────────────────────────────────────────────
|
|
200
202
|
if (model.startsWith("web-grok/")) {
|
|
201
|
-
|
|
203
|
+
let grokCtx = opts.getGrokContext?.() ?? null;
|
|
204
|
+
// Lazy connect: if context is null but a connector is provided, try now
|
|
205
|
+
if (!grokCtx && opts.connectGrokContext) {
|
|
206
|
+
grokCtx = await opts.connectGrokContext();
|
|
207
|
+
}
|
|
202
208
|
if (!grokCtx) {
|
|
203
209
|
res.writeHead(503, { "Content-Type": "application/json" });
|
|
204
210
|
res.end(JSON.stringify({ error: { message: "No active grok.com session. Use /grok-login to authenticate.", code: "no_grok_session" } }));
|