@elvatis_com/openclaw-cli-bridge-elvatis 1.9.1 → 2.1.1
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/.ai/handoff/NEXT_ACTIONS.md +4 -1
- package/.ai/handoff/STATUS.md +55 -50
- package/.github/ISSUE_TEMPLATE/bug_report.md +19 -0
- package/.github/ISSUE_TEMPLATE/feature_request.md +14 -0
- package/.github/PULL_REQUEST_TEMPLATE.md +11 -0
- package/.github/dependabot.yml +11 -0
- package/.github/workflows/auto-publish.yml +68 -0
- package/.github/workflows/codeql.yml +40 -0
- package/CODE_OF_CONDUCT.md +38 -0
- package/CONTRIBUTING.md +15 -108
- package/LICENSE +216 -0
- package/README.md +37 -7
- package/SECURITY.md +17 -0
- package/index.ts +28 -0
- package/openclaw.plugin.json +2 -2
- package/package.json +4 -3
- package/src/cli-runner.ts +178 -19
- package/src/codex-auth-import.ts +127 -0
- package/src/grok-client.ts +1 -1
- package/src/proxy-server.ts +145 -22
- package/src/session-manager.ts +346 -0
- package/src/workdir.ts +108 -0
- package/test/chatgpt-proxy.test.ts +2 -2
- package/test/claude-proxy.test.ts +2 -2
- package/test/cli-runner-extended.test.ts +267 -0
- package/test/codex-auth-import.test.ts +244 -0
- package/test/grok-proxy.test.ts +2 -2
- package/test/proxy-e2e.test.ts +274 -2
- package/test/session-manager.test.ts +446 -0
- package/test/workdir.test.ts +152 -0
package/src/proxy-server.ts
CHANGED
|
@@ -18,6 +18,7 @@ import { claudeComplete, claudeCompleteStream, type ChatMessage as ClaudeBrowser
|
|
|
18
18
|
import { chatgptComplete, chatgptCompleteStream, type ChatMessage as ChatGPTBrowserChatMessage } from "./chatgpt-browser.js";
|
|
19
19
|
import type { BrowserContext } from "playwright";
|
|
20
20
|
import { renderStatusPage, type StatusProvider } from "./status-template.js";
|
|
21
|
+
import { sessionManager } from "./session-manager.js";
|
|
21
22
|
|
|
22
23
|
export type GrokCompleteOptions = Parameters<typeof grokComplete>[1];
|
|
23
24
|
export type GrokCompleteStreamOptions = Parameters<typeof grokCompleteStream>[1];
|
|
@@ -85,32 +86,38 @@ export interface ProxyServerOptions {
|
|
|
85
86
|
/** Available CLI bridge models for GET /v1/models */
|
|
86
87
|
export const CLI_MODELS = [
|
|
87
88
|
// ── Claude Code CLI ───────────────────────────────────────────────────────
|
|
88
|
-
{ id: "cli-claude/claude-sonnet-4-6", name: "Claude Sonnet 4.6 (CLI)", contextWindow:
|
|
89
|
-
{ id: "cli-claude/claude-opus-4-6", name: "Claude Opus 4.6 (CLI)", contextWindow:
|
|
90
|
-
{ id: "cli-claude/claude-haiku-4-5", name: "Claude Haiku 4.5 (CLI)", contextWindow: 200_000, maxTokens:
|
|
89
|
+
{ id: "cli-claude/claude-sonnet-4-6", name: "Claude Sonnet 4.6 (CLI)", contextWindow: 1_000_000, maxTokens: 64_000 },
|
|
90
|
+
{ id: "cli-claude/claude-opus-4-6", name: "Claude Opus 4.6 (CLI)", contextWindow: 1_000_000, maxTokens: 128_000 },
|
|
91
|
+
{ id: "cli-claude/claude-haiku-4-5", name: "Claude Haiku 4.5 (CLI)", contextWindow: 200_000, maxTokens: 64_000 },
|
|
91
92
|
// ── Gemini CLI ────────────────────────────────────────────────────────────
|
|
92
|
-
{ id: "cli-gemini/gemini-2.5-pro", name: "Gemini 2.5 Pro (CLI)", contextWindow:
|
|
93
|
-
{ id: "cli-gemini/gemini-2.5-flash", name: "Gemini 2.5 Flash (CLI)", contextWindow:
|
|
94
|
-
{ id: "cli-gemini/gemini-3-pro-preview", name: "Gemini 3 Pro Preview (CLI)", contextWindow:
|
|
95
|
-
{ id: "cli-gemini/gemini-3-flash-preview", name: "Gemini 3 Flash Preview (CLI)", contextWindow:
|
|
93
|
+
{ id: "cli-gemini/gemini-2.5-pro", name: "Gemini 2.5 Pro (CLI)", contextWindow: 1_048_576, maxTokens: 65_535 },
|
|
94
|
+
{ id: "cli-gemini/gemini-2.5-flash", name: "Gemini 2.5 Flash (CLI)", contextWindow: 1_048_576, maxTokens: 65_535 },
|
|
95
|
+
{ id: "cli-gemini/gemini-3-pro-preview", name: "Gemini 3 Pro Preview (CLI)", contextWindow: 1_048_576, maxTokens: 65_536 },
|
|
96
|
+
{ id: "cli-gemini/gemini-3-flash-preview", name: "Gemini 3 Flash Preview (CLI)", contextWindow: 1_048_576, maxTokens: 65_536 },
|
|
96
97
|
// Codex CLI models (via openai-codex provider, OAuth auth)
|
|
97
|
-
|
|
98
|
-
{ id: "openai-codex/gpt-5.
|
|
99
|
-
{ id: "openai-codex/gpt-5.
|
|
100
|
-
{ id: "openai-codex/gpt-5.
|
|
101
|
-
{ id: "openai-codex/gpt-5.
|
|
98
|
+
// GPT-5.4: 1M ctx, 128K out | GPT-5.3: 400K ctx, 128K out | GPT-5.2: 200K, 32K | Mini: 128K, 16K
|
|
99
|
+
{ id: "openai-codex/gpt-5.4", name: "GPT-5.4", contextWindow: 1_050_000, maxTokens: 128_000 },
|
|
100
|
+
{ id: "openai-codex/gpt-5.3-codex", name: "GPT-5.3 Codex", contextWindow: 400_000, maxTokens: 128_000 },
|
|
101
|
+
{ id: "openai-codex/gpt-5.3-codex-spark", name: "GPT-5.3 Codex Spark", contextWindow: 400_000, maxTokens: 64_000 },
|
|
102
|
+
{ id: "openai-codex/gpt-5.2-codex", name: "GPT-5.2 Codex", contextWindow: 200_000, maxTokens: 32_768 },
|
|
103
|
+
{ id: "openai-codex/gpt-5.1-codex-mini", name: "GPT-5.1 Codex Mini", contextWindow: 128_000, maxTokens: 16_384 },
|
|
102
104
|
// Grok web-session models (requires /grok-login)
|
|
105
|
+
{ id: "web-grok/grok-4", name: "Grok 4 (web session)", contextWindow: 131_072, maxTokens: 131_072 },
|
|
103
106
|
{ id: "web-grok/grok-3", name: "Grok 3 (web session)", contextWindow: 131_072, maxTokens: 131_072 },
|
|
104
107
|
{ id: "web-grok/grok-3-fast", name: "Grok 3 Fast (web session)", contextWindow: 131_072, maxTokens: 131_072 },
|
|
105
108
|
{ id: "web-grok/grok-3-mini", name: "Grok 3 Mini (web session)", contextWindow: 131_072, maxTokens: 131_072 },
|
|
106
109
|
{ id: "web-grok/grok-3-mini-fast", name: "Grok 3 Mini Fast (web session)", contextWindow: 131_072, maxTokens: 131_072 },
|
|
107
110
|
// Gemini web-session models (requires /gemini-login)
|
|
108
|
-
{ id: "web-gemini/gemini-2-5-pro", name: "Gemini 2.5 Pro (web session)", contextWindow:
|
|
109
|
-
{ id: "web-gemini/gemini-2-5-flash", name: "Gemini 2.5 Flash (web session)", contextWindow:
|
|
110
|
-
{ id: "web-gemini/gemini-3-pro", name: "Gemini 3 Pro (web session)", contextWindow:
|
|
111
|
-
{ id: "web-gemini/gemini-3-flash", name: "Gemini 3 Flash (web session)", contextWindow:
|
|
111
|
+
{ id: "web-gemini/gemini-2-5-pro", name: "Gemini 2.5 Pro (web session)", contextWindow: 1_048_576, maxTokens: 65_535 },
|
|
112
|
+
{ id: "web-gemini/gemini-2-5-flash", name: "Gemini 2.5 Flash (web session)", contextWindow: 1_048_576, maxTokens: 65_535 },
|
|
113
|
+
{ id: "web-gemini/gemini-3-pro", name: "Gemini 3 Pro (web session)", contextWindow: 1_048_576, maxTokens: 65_536 },
|
|
114
|
+
{ id: "web-gemini/gemini-3-flash", name: "Gemini 3 Flash (web session)", contextWindow: 1_048_576, maxTokens: 65_536 },
|
|
112
115
|
// Claude → use cli-claude/* instead (web-claude removed in v1.6.x)
|
|
113
116
|
// ChatGPT → use openai-codex/* or copilot-proxy instead (web-chatgpt removed in v1.6.x)
|
|
117
|
+
// ── OpenCode CLI ──────────────────────────────────────────────────────────
|
|
118
|
+
{ id: "opencode/default", name: "OpenCode (CLI)", contextWindow: 128_000, maxTokens: 16_384 },
|
|
119
|
+
// ── Pi CLI ──────────────────────────────────────────────────────────────
|
|
120
|
+
{ id: "pi/default", name: "Pi (CLI)", contextWindow: 128_000, maxTokens: 16_384 },
|
|
114
121
|
// ── Local BitNet inference ──────────────────────────────────────────────────
|
|
115
122
|
{ id: "local-bitnet/bitnet-2b", name: "BitNet b1.58 2B (local CPU inference)", contextWindow: 4_096, maxTokens: 2_048 },
|
|
116
123
|
];
|
|
@@ -131,9 +138,10 @@ export function startProxyServer(opts: ProxyServerOptions): Promise<http.Server>
|
|
|
131
138
|
});
|
|
132
139
|
});
|
|
133
140
|
|
|
134
|
-
// Stop the token refresh interval when the server closes (timer-leak prevention)
|
|
141
|
+
// Stop the token refresh interval and session manager when the server closes (timer-leak prevention)
|
|
135
142
|
server.on("close", () => {
|
|
136
143
|
stopTokenRefresh();
|
|
144
|
+
sessionManager.stop();
|
|
137
145
|
});
|
|
138
146
|
|
|
139
147
|
server.on("error", (err) => reject(err));
|
|
@@ -141,6 +149,10 @@ export function startProxyServer(opts: ProxyServerOptions): Promise<http.Server>
|
|
|
141
149
|
opts.log(
|
|
142
150
|
`[cli-bridge] proxy server listening on http://127.0.0.1:${opts.port}`
|
|
143
151
|
);
|
|
152
|
+
// unref() so the proxy server does not keep the Node.js event loop alive
|
|
153
|
+
// when openclaw doctor or other short-lived CLI commands load plugins.
|
|
154
|
+
// The gateway's own main loop keeps the process alive during normal operation.
|
|
155
|
+
server.unref();
|
|
144
156
|
// Start proactive OAuth token refresh scheduler for Claude Code CLI.
|
|
145
157
|
setAuthLogger(opts.log);
|
|
146
158
|
void scheduleTokenRefresh();
|
|
@@ -236,7 +248,7 @@ async function handleRequest(
|
|
|
236
248
|
owned_by: "openclaw-cli-bridge",
|
|
237
249
|
// CLI-proxy models stream plain text — no tool/function call support
|
|
238
250
|
capabilities: {
|
|
239
|
-
tools: !(m.id.startsWith("cli-gemini/") || m.id.startsWith("cli-claude/") || m.id.startsWith("local-bitnet/")),
|
|
251
|
+
tools: !(m.id.startsWith("cli-gemini/") || m.id.startsWith("cli-claude/") || m.id.startsWith("openai-codex/") || m.id.startsWith("opencode/") || m.id.startsWith("pi/") || m.id.startsWith("local-bitnet/")),
|
|
240
252
|
},
|
|
241
253
|
})),
|
|
242
254
|
})
|
|
@@ -272,7 +284,8 @@ async function handleRequest(
|
|
|
272
284
|
return;
|
|
273
285
|
}
|
|
274
286
|
|
|
275
|
-
const { model, messages, stream = false } = parsed as { model: string; messages: ChatMessage[]; stream?: boolean; tools?: unknown };
|
|
287
|
+
const { model, messages, stream = false } = parsed as { model: string; messages: ChatMessage[]; stream?: boolean; tools?: unknown; workdir?: string };
|
|
288
|
+
const workdir = (parsed as { workdir?: string }).workdir;
|
|
276
289
|
const hasTools = Array.isArray((parsed as { tools?: unknown }).tools) && (parsed as { tools?: unknown[] }).tools!.length > 0;
|
|
277
290
|
|
|
278
291
|
if (!model || !messages?.length) {
|
|
@@ -284,7 +297,7 @@ async function handleRequest(
|
|
|
284
297
|
// CLI-proxy models (cli-gemini/*, cli-claude/*) are plain text completions —
|
|
285
298
|
// they cannot process tool/function call schemas. Return a clear 400 so
|
|
286
299
|
// OpenClaw can surface a meaningful error instead of getting a garbled response.
|
|
287
|
-
const isCliModel = model.startsWith("cli-gemini/") || model.startsWith("cli-claude/"); // local-bitnet/* exempt: llama-server silently ignores tools
|
|
300
|
+
const isCliModel = model.startsWith("cli-gemini/") || model.startsWith("cli-claude/") || model.startsWith("openai-codex/") || model.startsWith("opencode/") || model.startsWith("pi/"); // local-bitnet/* exempt: llama-server silently ignores tools
|
|
288
301
|
if (hasTools && isCliModel) {
|
|
289
302
|
res.writeHead(400, { "Content-Type": "application/json" });
|
|
290
303
|
res.end(JSON.stringify({
|
|
@@ -591,7 +604,7 @@ async function handleRequest(
|
|
|
591
604
|
let content: string;
|
|
592
605
|
let usedModel = model;
|
|
593
606
|
try {
|
|
594
|
-
content = await routeToCliRunner(model, messages, opts.timeoutMs ?? 120_000);
|
|
607
|
+
content = await routeToCliRunner(model, messages, opts.timeoutMs ?? 120_000, { workdir });
|
|
595
608
|
} catch (err) {
|
|
596
609
|
const msg = (err as Error).message;
|
|
597
610
|
// ── Model fallback: retry once with a lighter model if configured ────
|
|
@@ -599,7 +612,7 @@ async function handleRequest(
|
|
|
599
612
|
if (fallbackModel) {
|
|
600
613
|
opts.warn(`[cli-bridge] ${model} failed (${msg}), falling back to ${fallbackModel}`);
|
|
601
614
|
try {
|
|
602
|
-
content = await routeToCliRunner(fallbackModel, messages, opts.timeoutMs ?? 120_000);
|
|
615
|
+
content = await routeToCliRunner(fallbackModel, messages, opts.timeoutMs ?? 120_000, { workdir });
|
|
603
616
|
usedModel = fallbackModel;
|
|
604
617
|
opts.log(`[cli-bridge] fallback to ${fallbackModel} succeeded`);
|
|
605
618
|
} catch (fallbackErr) {
|
|
@@ -667,6 +680,116 @@ async function handleRequest(
|
|
|
667
680
|
return;
|
|
668
681
|
}
|
|
669
682
|
|
|
683
|
+
// ── Session Manager endpoints ──────────────────────────────────────────────
|
|
684
|
+
|
|
685
|
+
// POST /v1/sessions/spawn
|
|
686
|
+
if (url === "/v1/sessions/spawn" && req.method === "POST") {
|
|
687
|
+
const body = await readBody(req);
|
|
688
|
+
let parsed: { model: string; messages: ChatMessage[]; workdir?: string; timeout?: number };
|
|
689
|
+
try {
|
|
690
|
+
parsed = JSON.parse(body) as typeof parsed;
|
|
691
|
+
} catch {
|
|
692
|
+
res.writeHead(400, { "Content-Type": "application/json", ...corsHeaders() });
|
|
693
|
+
res.end(JSON.stringify({ error: { message: "Invalid JSON body", type: "invalid_request_error" } }));
|
|
694
|
+
return;
|
|
695
|
+
}
|
|
696
|
+
if (!parsed.model || !parsed.messages?.length) {
|
|
697
|
+
res.writeHead(400, { "Content-Type": "application/json", ...corsHeaders() });
|
|
698
|
+
res.end(JSON.stringify({ error: { message: "model and messages are required", type: "invalid_request_error" } }));
|
|
699
|
+
return;
|
|
700
|
+
}
|
|
701
|
+
const sessionId = sessionManager.spawn(parsed.model, parsed.messages, {
|
|
702
|
+
workdir: parsed.workdir,
|
|
703
|
+
timeout: parsed.timeout,
|
|
704
|
+
});
|
|
705
|
+
opts.log(`[cli-bridge] session spawned: ${sessionId} (${parsed.model})`);
|
|
706
|
+
res.writeHead(200, { "Content-Type": "application/json", ...corsHeaders() });
|
|
707
|
+
res.end(JSON.stringify({ sessionId }));
|
|
708
|
+
return;
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
// GET /v1/sessions — list all sessions
|
|
712
|
+
if (url === "/v1/sessions" && req.method === "GET") {
|
|
713
|
+
const sessions = sessionManager.list();
|
|
714
|
+
res.writeHead(200, { "Content-Type": "application/json", ...corsHeaders() });
|
|
715
|
+
res.end(JSON.stringify({ sessions }));
|
|
716
|
+
return;
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
// Session-specific endpoints: /v1/sessions/:id/*
|
|
720
|
+
const sessionMatch = url.match(/^\/v1\/sessions\/([a-f0-9]+)\/(poll|log|write|kill)$/);
|
|
721
|
+
if (sessionMatch) {
|
|
722
|
+
const sessionId = sessionMatch[1];
|
|
723
|
+
const action = sessionMatch[2];
|
|
724
|
+
|
|
725
|
+
if (action === "poll" && req.method === "GET") {
|
|
726
|
+
const result = sessionManager.poll(sessionId);
|
|
727
|
+
if (!result) {
|
|
728
|
+
res.writeHead(404, { "Content-Type": "application/json", ...corsHeaders() });
|
|
729
|
+
res.end(JSON.stringify({ error: { message: "Session not found", type: "not_found" } }));
|
|
730
|
+
return;
|
|
731
|
+
}
|
|
732
|
+
res.writeHead(200, { "Content-Type": "application/json", ...corsHeaders() });
|
|
733
|
+
res.end(JSON.stringify(result));
|
|
734
|
+
return;
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
if (action === "log" && req.method === "GET") {
|
|
738
|
+
// Parse ?offset=N from URL
|
|
739
|
+
const urlObj = new URL(url, `http://127.0.0.1:${opts.port}`);
|
|
740
|
+
const offset = parseInt(urlObj.searchParams.get("offset") ?? "0", 10) || 0;
|
|
741
|
+
const result = sessionManager.log(sessionId, offset);
|
|
742
|
+
if (!result) {
|
|
743
|
+
res.writeHead(404, { "Content-Type": "application/json", ...corsHeaders() });
|
|
744
|
+
res.end(JSON.stringify({ error: { message: "Session not found", type: "not_found" } }));
|
|
745
|
+
return;
|
|
746
|
+
}
|
|
747
|
+
res.writeHead(200, { "Content-Type": "application/json", ...corsHeaders() });
|
|
748
|
+
res.end(JSON.stringify(result));
|
|
749
|
+
return;
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
if (action === "write" && req.method === "POST") {
|
|
753
|
+
const body = await readBody(req);
|
|
754
|
+
let parsed: { data: string };
|
|
755
|
+
try {
|
|
756
|
+
parsed = JSON.parse(body) as typeof parsed;
|
|
757
|
+
} catch {
|
|
758
|
+
res.writeHead(400, { "Content-Type": "application/json", ...corsHeaders() });
|
|
759
|
+
res.end(JSON.stringify({ error: { message: "Invalid JSON body", type: "invalid_request_error" } }));
|
|
760
|
+
return;
|
|
761
|
+
}
|
|
762
|
+
const ok = sessionManager.write(sessionId, parsed.data ?? "");
|
|
763
|
+
res.writeHead(ok ? 200 : 404, { "Content-Type": "application/json", ...corsHeaders() });
|
|
764
|
+
res.end(JSON.stringify({ ok }));
|
|
765
|
+
return;
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
if (action === "kill" && req.method === "POST") {
|
|
769
|
+
const ok = sessionManager.kill(sessionId);
|
|
770
|
+
res.writeHead(ok ? 200 : 404, { "Content-Type": "application/json", ...corsHeaders() });
|
|
771
|
+
res.end(JSON.stringify({ ok }));
|
|
772
|
+
return;
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
// Also handle /v1/sessions/:id/log with query params (URL match above doesn't capture query strings)
|
|
777
|
+
const logMatch = url.match(/^\/v1\/sessions\/([a-f0-9]+)\/log\?/);
|
|
778
|
+
if (logMatch && req.method === "GET") {
|
|
779
|
+
const sessionId = logMatch[1];
|
|
780
|
+
const urlObj = new URL(url, `http://127.0.0.1:${opts.port}`);
|
|
781
|
+
const offset = parseInt(urlObj.searchParams.get("offset") ?? "0", 10) || 0;
|
|
782
|
+
const result = sessionManager.log(sessionId, offset);
|
|
783
|
+
if (!result) {
|
|
784
|
+
res.writeHead(404, { "Content-Type": "application/json", ...corsHeaders() });
|
|
785
|
+
res.end(JSON.stringify({ error: { message: "Session not found", type: "not_found" } }));
|
|
786
|
+
return;
|
|
787
|
+
}
|
|
788
|
+
res.writeHead(200, { "Content-Type": "application/json", ...corsHeaders() });
|
|
789
|
+
res.end(JSON.stringify(result));
|
|
790
|
+
return;
|
|
791
|
+
}
|
|
792
|
+
|
|
670
793
|
// 404
|
|
671
794
|
res.writeHead(404, { "Content-Type": "application/json" });
|
|
672
795
|
res.end(JSON.stringify({ error: { message: `Not found: ${url}`, type: "not_found" } }));
|
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* session-manager.ts
|
|
3
|
+
*
|
|
4
|
+
* Manages long-running CLI sessions as background processes.
|
|
5
|
+
* Each session spawns a CLI subprocess, buffers stdout/stderr, and allows
|
|
6
|
+
* polling, log streaming, stdin writes, and graceful termination.
|
|
7
|
+
*
|
|
8
|
+
* Singleton pattern — import and use the shared `sessionManager` instance.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { spawn, type ChildProcess } from "node:child_process";
|
|
12
|
+
import { randomBytes } from "node:crypto";
|
|
13
|
+
import { tmpdir, homedir } from "node:os";
|
|
14
|
+
import { existsSync } from "node:fs";
|
|
15
|
+
import { join } from "node:path";
|
|
16
|
+
import { execSync } from "node:child_process";
|
|
17
|
+
import { formatPrompt, type ChatMessage } from "./cli-runner.js";
|
|
18
|
+
import { createIsolatedWorkdir, cleanupWorkdir, sweepOrphanedWorkdirs } from "./workdir.js";
|
|
19
|
+
|
|
20
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
21
|
+
// Types
|
|
22
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
export type SessionStatus = "running" | "exited" | "killed";
|
|
25
|
+
|
|
26
|
+
export interface SessionEntry {
|
|
27
|
+
proc: ChildProcess;
|
|
28
|
+
stdout: string;
|
|
29
|
+
stderr: string;
|
|
30
|
+
startTime: number;
|
|
31
|
+
exitCode: number | null;
|
|
32
|
+
model: string;
|
|
33
|
+
status: SessionStatus;
|
|
34
|
+
/** Isolated workdir created for this session (null if caller provided explicit workdir). */
|
|
35
|
+
isolatedWorkdir: string | null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface SessionInfo {
|
|
39
|
+
sessionId: string;
|
|
40
|
+
model: string;
|
|
41
|
+
status: SessionStatus;
|
|
42
|
+
startTime: number;
|
|
43
|
+
exitCode: number | null;
|
|
44
|
+
/** Isolated workdir path (null if not using workdir isolation). */
|
|
45
|
+
isolatedWorkdir: string | null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface SpawnOptions {
|
|
49
|
+
workdir?: string;
|
|
50
|
+
timeout?: number;
|
|
51
|
+
/**
|
|
52
|
+
* If true, create an isolated temp directory for this session.
|
|
53
|
+
* The directory is automatically cleaned up when the session exits or is killed.
|
|
54
|
+
* Ignored if `workdir` is explicitly set.
|
|
55
|
+
* Default: false (uses per-runner defaults: tmpdir for gemini, homedir for others).
|
|
56
|
+
*/
|
|
57
|
+
isolateWorkdir?: boolean;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
61
|
+
// Minimal env (mirrors cli-runner.ts buildMinimalEnv)
|
|
62
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
function buildMinimalEnv(): Record<string, string> {
|
|
65
|
+
const pick = (key: string) => process.env[key];
|
|
66
|
+
const env: Record<string, string> = { NO_COLOR: "1", TERM: "dumb" };
|
|
67
|
+
|
|
68
|
+
for (const key of ["HOME", "PATH", "USER", "LOGNAME", "SHELL", "TMPDIR", "TMP", "TEMP"]) {
|
|
69
|
+
const v = pick(key);
|
|
70
|
+
if (v) env[key] = v;
|
|
71
|
+
}
|
|
72
|
+
for (const key of [
|
|
73
|
+
"GOOGLE_APPLICATION_CREDENTIALS",
|
|
74
|
+
"ANTHROPIC_API_KEY",
|
|
75
|
+
"CLAUDE_API_KEY",
|
|
76
|
+
"CODEX_API_KEY",
|
|
77
|
+
"OPENAI_API_KEY",
|
|
78
|
+
"XDG_CONFIG_HOME",
|
|
79
|
+
"XDG_DATA_HOME",
|
|
80
|
+
"XDG_CACHE_HOME",
|
|
81
|
+
"XDG_RUNTIME_DIR",
|
|
82
|
+
"DBUS_SESSION_BUS_ADDRESS",
|
|
83
|
+
]) {
|
|
84
|
+
const v = pick(key);
|
|
85
|
+
if (v) env[key] = v;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return env;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
92
|
+
// Session Manager
|
|
93
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
94
|
+
|
|
95
|
+
/** Auto-cleanup interval: 30 minutes. */
|
|
96
|
+
const SESSION_TTL_MS = 30 * 60 * 1000;
|
|
97
|
+
const CLEANUP_INTERVAL_MS = 5 * 60 * 1000;
|
|
98
|
+
|
|
99
|
+
export class SessionManager {
|
|
100
|
+
private sessions = new Map<string, SessionEntry>();
|
|
101
|
+
private cleanupTimer: ReturnType<typeof setInterval> | null = null;
|
|
102
|
+
|
|
103
|
+
constructor() {
|
|
104
|
+
this.cleanupTimer = setInterval(() => this.cleanup(), CLEANUP_INTERVAL_MS);
|
|
105
|
+
// Don't keep the process alive just for cleanup
|
|
106
|
+
if (this.cleanupTimer.unref) this.cleanupTimer.unref();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Spawn a new CLI session for the given model + messages.
|
|
111
|
+
* Returns a unique sessionId (random hex).
|
|
112
|
+
*/
|
|
113
|
+
spawn(model: string, messages: ChatMessage[], opts: SpawnOptions = {}): string {
|
|
114
|
+
const sessionId = randomBytes(8).toString("hex");
|
|
115
|
+
const prompt = formatPrompt(messages);
|
|
116
|
+
|
|
117
|
+
// Workdir isolation: create a temp dir if requested and no explicit workdir given
|
|
118
|
+
let isolatedDir: string | null = null;
|
|
119
|
+
const effectiveOpts = { ...opts };
|
|
120
|
+
if (opts.isolateWorkdir && !opts.workdir) {
|
|
121
|
+
isolatedDir = createIsolatedWorkdir();
|
|
122
|
+
effectiveOpts.workdir = isolatedDir;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const { cmd, args, cwd, useStdin } = this.resolveCliCommand(model, prompt, effectiveOpts);
|
|
126
|
+
|
|
127
|
+
const proc = spawn(cmd, args, {
|
|
128
|
+
env: buildMinimalEnv(),
|
|
129
|
+
cwd,
|
|
130
|
+
timeout: opts.timeout,
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
const entry: SessionEntry = {
|
|
134
|
+
proc,
|
|
135
|
+
stdout: "",
|
|
136
|
+
stderr: "",
|
|
137
|
+
startTime: Date.now(),
|
|
138
|
+
exitCode: null,
|
|
139
|
+
model,
|
|
140
|
+
status: "running",
|
|
141
|
+
isolatedWorkdir: isolatedDir,
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
if (useStdin) {
|
|
145
|
+
proc.stdin.write(prompt, "utf8", () => {
|
|
146
|
+
proc.stdin.end();
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
proc.stdout?.on("data", (d: Buffer) => { entry.stdout += d.toString(); });
|
|
151
|
+
proc.stderr?.on("data", (d: Buffer) => { entry.stderr += d.toString(); });
|
|
152
|
+
|
|
153
|
+
proc.on("close", (code) => {
|
|
154
|
+
entry.exitCode = code ?? 0;
|
|
155
|
+
if (entry.status === "running") entry.status = "exited";
|
|
156
|
+
// Auto-cleanup isolated workdir on process exit
|
|
157
|
+
if (entry.isolatedWorkdir) {
|
|
158
|
+
cleanupWorkdir(entry.isolatedWorkdir);
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
proc.on("error", () => {
|
|
163
|
+
if (entry.status === "running") entry.status = "exited";
|
|
164
|
+
entry.exitCode = entry.exitCode ?? 1;
|
|
165
|
+
// Auto-cleanup isolated workdir on error too
|
|
166
|
+
if (entry.isolatedWorkdir) {
|
|
167
|
+
cleanupWorkdir(entry.isolatedWorkdir);
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
this.sessions.set(sessionId, entry);
|
|
172
|
+
return sessionId;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Check if a session is still running. */
|
|
176
|
+
poll(sessionId: string): { running: boolean; exitCode: number | null; status: SessionStatus } | null {
|
|
177
|
+
const entry = this.sessions.get(sessionId);
|
|
178
|
+
if (!entry) return null;
|
|
179
|
+
return {
|
|
180
|
+
running: entry.status === "running",
|
|
181
|
+
exitCode: entry.exitCode,
|
|
182
|
+
status: entry.status,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Get buffered stdout/stderr from offset. */
|
|
187
|
+
log(sessionId: string, offset = 0): { stdout: string; stderr: string; offset: number } | null {
|
|
188
|
+
const entry = this.sessions.get(sessionId);
|
|
189
|
+
if (!entry) return null;
|
|
190
|
+
return {
|
|
191
|
+
stdout: entry.stdout.slice(offset),
|
|
192
|
+
stderr: entry.stderr.slice(offset),
|
|
193
|
+
offset: entry.stdout.length,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Write data to the session's stdin. */
|
|
198
|
+
write(sessionId: string, data: string): boolean {
|
|
199
|
+
const entry = this.sessions.get(sessionId);
|
|
200
|
+
if (!entry || entry.status !== "running") return false;
|
|
201
|
+
try {
|
|
202
|
+
entry.proc.stdin?.write(data, "utf8");
|
|
203
|
+
return true;
|
|
204
|
+
} catch {
|
|
205
|
+
return false;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Send SIGTERM to the session process. */
|
|
210
|
+
kill(sessionId: string): boolean {
|
|
211
|
+
const entry = this.sessions.get(sessionId);
|
|
212
|
+
if (!entry || entry.status !== "running") return false;
|
|
213
|
+
entry.status = "killed";
|
|
214
|
+
entry.proc.kill("SIGTERM");
|
|
215
|
+
return true;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** List all sessions with their status. */
|
|
219
|
+
list(): SessionInfo[] {
|
|
220
|
+
const result: SessionInfo[] = [];
|
|
221
|
+
for (const [sessionId, entry] of this.sessions) {
|
|
222
|
+
result.push({
|
|
223
|
+
sessionId,
|
|
224
|
+
model: entry.model,
|
|
225
|
+
status: entry.status,
|
|
226
|
+
startTime: entry.startTime,
|
|
227
|
+
exitCode: entry.exitCode,
|
|
228
|
+
isolatedWorkdir: entry.isolatedWorkdir,
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
return result;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Remove sessions older than SESSION_TTL_MS. Kill running ones first. Clean up isolated workdirs. */
|
|
235
|
+
cleanup(): void {
|
|
236
|
+
const now = Date.now();
|
|
237
|
+
for (const [sessionId, entry] of this.sessions) {
|
|
238
|
+
if (now - entry.startTime > SESSION_TTL_MS) {
|
|
239
|
+
if (entry.status === "running") {
|
|
240
|
+
entry.proc.kill("SIGTERM");
|
|
241
|
+
entry.status = "killed";
|
|
242
|
+
}
|
|
243
|
+
// Clean up isolated workdir if it wasn't cleaned on exit
|
|
244
|
+
if (entry.isolatedWorkdir) {
|
|
245
|
+
cleanupWorkdir(entry.isolatedWorkdir);
|
|
246
|
+
}
|
|
247
|
+
this.sessions.delete(sessionId);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
// Sweep orphaned workdirs from crashed sessions
|
|
251
|
+
sweepOrphanedWorkdirs();
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** Stop the cleanup timer (for graceful shutdown). */
|
|
255
|
+
stop(): void {
|
|
256
|
+
if (this.cleanupTimer) {
|
|
257
|
+
clearInterval(this.cleanupTimer);
|
|
258
|
+
this.cleanupTimer = null;
|
|
259
|
+
}
|
|
260
|
+
// Kill all running sessions and clean up their workdirs
|
|
261
|
+
for (const [, entry] of this.sessions) {
|
|
262
|
+
if (entry.status === "running") {
|
|
263
|
+
entry.proc.kill("SIGTERM");
|
|
264
|
+
entry.status = "killed";
|
|
265
|
+
}
|
|
266
|
+
if (entry.isolatedWorkdir) {
|
|
267
|
+
cleanupWorkdir(entry.isolatedWorkdir);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
273
|
+
// Internal: resolve CLI command + args for a model
|
|
274
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
275
|
+
|
|
276
|
+
private resolveCliCommand(
|
|
277
|
+
model: string,
|
|
278
|
+
prompt: string,
|
|
279
|
+
opts: SpawnOptions
|
|
280
|
+
): { cmd: string; args: string[]; cwd: string; useStdin: boolean } {
|
|
281
|
+
const normalized = model.startsWith("vllm/") ? model.slice(5) : model;
|
|
282
|
+
const stripPfx = (id: string) => { const s = id.indexOf("/"); return s === -1 ? id : id.slice(s + 1); };
|
|
283
|
+
const modelName = stripPfx(normalized);
|
|
284
|
+
|
|
285
|
+
if (normalized.startsWith("cli-gemini/")) {
|
|
286
|
+
return {
|
|
287
|
+
cmd: "gemini",
|
|
288
|
+
args: ["-m", modelName, "-p", ""],
|
|
289
|
+
cwd: opts.workdir ?? tmpdir(),
|
|
290
|
+
useStdin: true,
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
if (normalized.startsWith("cli-claude/")) {
|
|
295
|
+
return {
|
|
296
|
+
cmd: "claude",
|
|
297
|
+
args: ["-p", "--output-format", "text", "--permission-mode", "plan", "--tools", "", "--model", modelName],
|
|
298
|
+
cwd: opts.workdir ?? homedir(),
|
|
299
|
+
useStdin: true,
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
if (normalized.startsWith("openai-codex/")) {
|
|
304
|
+
const cwd = opts.workdir ?? homedir();
|
|
305
|
+
// Ensure git repo for Codex
|
|
306
|
+
if (!existsSync(join(cwd, ".git"))) {
|
|
307
|
+
try { execSync("git init", { cwd, stdio: "ignore" }); } catch { /* best effort */ }
|
|
308
|
+
}
|
|
309
|
+
return {
|
|
310
|
+
cmd: "codex",
|
|
311
|
+
args: ["--model", modelName, "--quiet", "--full-auto"],
|
|
312
|
+
cwd,
|
|
313
|
+
useStdin: true,
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
if (normalized.startsWith("opencode/")) {
|
|
318
|
+
return {
|
|
319
|
+
cmd: "opencode",
|
|
320
|
+
args: ["run", prompt],
|
|
321
|
+
cwd: opts.workdir ?? homedir(),
|
|
322
|
+
useStdin: false,
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
if (normalized.startsWith("pi/")) {
|
|
327
|
+
return {
|
|
328
|
+
cmd: "pi",
|
|
329
|
+
args: ["-p", prompt],
|
|
330
|
+
cwd: opts.workdir ?? homedir(),
|
|
331
|
+
useStdin: false,
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// Fallback: try as a generic CLI (stdin-based)
|
|
336
|
+
return {
|
|
337
|
+
cmd: modelName,
|
|
338
|
+
args: [],
|
|
339
|
+
cwd: opts.workdir ?? homedir(),
|
|
340
|
+
useStdin: true,
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** Shared singleton instance. */
|
|
346
|
+
export const sessionManager = new SessionManager();
|