@elvatis_com/openclaw-cli-bridge-elvatis 2.5.0 → 2.6.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 +11 -2
- package/SKILL.md +1 -1
- package/index.ts +35 -37
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/src/cli-runner.ts +11 -13
- package/src/config.ts +217 -0
- package/src/provider-sessions.ts +264 -0
- package/src/proxy-server.ts +57 -15
- package/src/session-manager.ts +9 -8
- package/test/config.test.ts +102 -0
- package/test/provider-sessions.test.ts +294 -0
- package/test/session-manager.test.ts +14 -0
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* provider-sessions.ts
|
|
3
|
+
*
|
|
4
|
+
* Persistent session registry for CLI bridge provider sessions.
|
|
5
|
+
*
|
|
6
|
+
* A "provider session" represents a long-lived context with a CLI provider
|
|
7
|
+
* (Claude, Gemini, Codex, etc.). Sessions survive across individual runs:
|
|
8
|
+
* when a run times out, the session persists so that follow-up runs can
|
|
9
|
+
* resume in the same context.
|
|
10
|
+
*
|
|
11
|
+
* Session vs Run:
|
|
12
|
+
* - Session: long-lived unit (provider context, profile, remote session ID)
|
|
13
|
+
* - Run: single request within a session (messages, tools, timeout)
|
|
14
|
+
*
|
|
15
|
+
* Storage: in-memory Map + periodic flush to ~/.openclaw/cli-bridge/sessions.json.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { randomBytes } from "node:crypto";
|
|
19
|
+
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
20
|
+
import { dirname } from "node:path";
|
|
21
|
+
import {
|
|
22
|
+
PROVIDER_SESSIONS_FILE,
|
|
23
|
+
PROVIDER_SESSION_TTL_MS,
|
|
24
|
+
PROVIDER_SESSION_SWEEP_MS,
|
|
25
|
+
} from "./config.js";
|
|
26
|
+
|
|
27
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
28
|
+
// Types
|
|
29
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
export type ProviderAlias = "claude" | "gemini" | "grok" | "codex" | "opencode" | "pi" | "bitnet" | string;
|
|
32
|
+
|
|
33
|
+
export type SessionState = "active" | "idle" | "expired";
|
|
34
|
+
|
|
35
|
+
export interface ProviderSession {
|
|
36
|
+
/** Unique session ID, e.g. "claude:session-a1b2c3d4". */
|
|
37
|
+
id: string;
|
|
38
|
+
/** Provider type. */
|
|
39
|
+
provider: ProviderAlias;
|
|
40
|
+
/** Full model alias, e.g. "cli-claude/claude-sonnet-4-6". */
|
|
41
|
+
modelAlias: string;
|
|
42
|
+
/** Unix timestamp when the session was created. */
|
|
43
|
+
createdAt: number;
|
|
44
|
+
/** Unix timestamp of the last activity (run start, touch). */
|
|
45
|
+
updatedAt: number;
|
|
46
|
+
/** Current session state. */
|
|
47
|
+
state: SessionState;
|
|
48
|
+
/** Total runs executed in this session. */
|
|
49
|
+
runCount: number;
|
|
50
|
+
/** Number of runs that timed out. */
|
|
51
|
+
timeoutCount: number;
|
|
52
|
+
/** Provider-specific state (profile path, remote session ID, etc.). */
|
|
53
|
+
meta: Record<string, unknown>;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface CreateSessionOptions {
|
|
57
|
+
/** Provider-specific metadata. */
|
|
58
|
+
meta?: Record<string, unknown>;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
62
|
+
// Registry
|
|
63
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
/** Serialized format of the sessions file. */
|
|
66
|
+
interface SessionStore {
|
|
67
|
+
version: 1;
|
|
68
|
+
sessions: ProviderSession[];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export class ProviderSessionRegistry {
|
|
72
|
+
private sessions = new Map<string, ProviderSession>();
|
|
73
|
+
private sweepTimer: ReturnType<typeof setInterval> | null = null;
|
|
74
|
+
private dirty = false;
|
|
75
|
+
|
|
76
|
+
constructor() {
|
|
77
|
+
this.load();
|
|
78
|
+
this.sweepTimer = setInterval(() => this.sweep(), PROVIDER_SESSION_SWEEP_MS);
|
|
79
|
+
if (this.sweepTimer.unref) this.sweepTimer.unref();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ── CRUD ─────────────────────────────────────────────────────────────────
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Create a new provider session.
|
|
86
|
+
* Returns the session with a unique ID.
|
|
87
|
+
*/
|
|
88
|
+
createSession(
|
|
89
|
+
provider: ProviderAlias,
|
|
90
|
+
modelAlias: string,
|
|
91
|
+
opts: CreateSessionOptions = {}
|
|
92
|
+
): ProviderSession {
|
|
93
|
+
const now = Date.now();
|
|
94
|
+
const id = `${provider}:session-${randomBytes(6).toString("hex")}`;
|
|
95
|
+
const session: ProviderSession = {
|
|
96
|
+
id,
|
|
97
|
+
provider,
|
|
98
|
+
modelAlias,
|
|
99
|
+
createdAt: now,
|
|
100
|
+
updatedAt: now,
|
|
101
|
+
state: "active",
|
|
102
|
+
runCount: 0,
|
|
103
|
+
timeoutCount: 0,
|
|
104
|
+
meta: opts.meta ?? {},
|
|
105
|
+
};
|
|
106
|
+
this.sessions.set(id, session);
|
|
107
|
+
this.dirty = true;
|
|
108
|
+
this.flush();
|
|
109
|
+
return session;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Get a session by ID. Returns undefined if not found. */
|
|
113
|
+
getSession(id: string): ProviderSession | undefined {
|
|
114
|
+
return this.sessions.get(id);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Find an existing active session for the given provider+model.
|
|
119
|
+
* Returns the most recently updated match, or undefined.
|
|
120
|
+
*/
|
|
121
|
+
findSession(provider: ProviderAlias, modelAlias: string): ProviderSession | undefined {
|
|
122
|
+
let best: ProviderSession | undefined;
|
|
123
|
+
for (const s of this.sessions.values()) {
|
|
124
|
+
if (s.provider !== provider || s.modelAlias !== modelAlias) continue;
|
|
125
|
+
if (s.state === "expired") continue;
|
|
126
|
+
if (!best || s.updatedAt > best.updatedAt) best = s;
|
|
127
|
+
}
|
|
128
|
+
return best;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Get or create a session for the given provider+model.
|
|
133
|
+
* Reuses existing active session if available.
|
|
134
|
+
*/
|
|
135
|
+
ensureSession(
|
|
136
|
+
provider: ProviderAlias,
|
|
137
|
+
modelAlias: string,
|
|
138
|
+
opts: CreateSessionOptions = {}
|
|
139
|
+
): ProviderSession {
|
|
140
|
+
const existing = this.findSession(provider, modelAlias);
|
|
141
|
+
if (existing) {
|
|
142
|
+
this.touchSession(existing.id);
|
|
143
|
+
return existing;
|
|
144
|
+
}
|
|
145
|
+
return this.createSession(provider, modelAlias, opts);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Update the session's last-activity timestamp and set state to active.
|
|
150
|
+
* Call this at the start of every run.
|
|
151
|
+
*/
|
|
152
|
+
touchSession(id: string): boolean {
|
|
153
|
+
const session = this.sessions.get(id);
|
|
154
|
+
if (!session) return false;
|
|
155
|
+
session.updatedAt = Date.now();
|
|
156
|
+
if (session.state === "idle") session.state = "active";
|
|
157
|
+
this.dirty = true;
|
|
158
|
+
return true;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Record that a run completed in this session. */
|
|
162
|
+
recordRun(id: string, timedOut: boolean): void {
|
|
163
|
+
const session = this.sessions.get(id);
|
|
164
|
+
if (!session) return;
|
|
165
|
+
session.runCount++;
|
|
166
|
+
if (timedOut) session.timeoutCount++;
|
|
167
|
+
session.updatedAt = Date.now();
|
|
168
|
+
session.state = "idle"; // run finished, session stays alive
|
|
169
|
+
this.dirty = true;
|
|
170
|
+
this.flush();
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Delete a session by ID. */
|
|
174
|
+
deleteSession(id: string): boolean {
|
|
175
|
+
const deleted = this.sessions.delete(id);
|
|
176
|
+
if (deleted) {
|
|
177
|
+
this.dirty = true;
|
|
178
|
+
this.flush();
|
|
179
|
+
}
|
|
180
|
+
return deleted;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** List all sessions. */
|
|
184
|
+
listSessions(): ProviderSession[] {
|
|
185
|
+
return [...this.sessions.values()];
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Get summary stats for logging/status. */
|
|
189
|
+
stats(): { total: number; active: number; idle: number; expired: number } {
|
|
190
|
+
let active = 0, idle = 0, expired = 0;
|
|
191
|
+
for (const s of this.sessions.values()) {
|
|
192
|
+
if (s.state === "active") active++;
|
|
193
|
+
else if (s.state === "idle") idle++;
|
|
194
|
+
else expired++;
|
|
195
|
+
}
|
|
196
|
+
return { total: this.sessions.size, active, idle, expired };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// ── Lifecycle ────────────────────────────────────────────────────────────
|
|
200
|
+
|
|
201
|
+
/** Sweep stale sessions (older than PROVIDER_SESSION_TTL_MS without activity). */
|
|
202
|
+
sweep(): void {
|
|
203
|
+
const now = Date.now();
|
|
204
|
+
let changed = false;
|
|
205
|
+
for (const [id, session] of this.sessions) {
|
|
206
|
+
if (now - session.updatedAt > PROVIDER_SESSION_TTL_MS) {
|
|
207
|
+
session.state = "expired";
|
|
208
|
+
this.sessions.delete(id);
|
|
209
|
+
changed = true;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
if (changed) {
|
|
213
|
+
this.dirty = true;
|
|
214
|
+
this.flush();
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Stop the sweep timer (for graceful shutdown). */
|
|
219
|
+
stop(): void {
|
|
220
|
+
if (this.sweepTimer) {
|
|
221
|
+
clearInterval(this.sweepTimer);
|
|
222
|
+
this.sweepTimer = null;
|
|
223
|
+
}
|
|
224
|
+
this.flush();
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// ── Persistence ──────────────────────────────────────────────────────────
|
|
228
|
+
|
|
229
|
+
/** Load sessions from disk. */
|
|
230
|
+
private load(): void {
|
|
231
|
+
try {
|
|
232
|
+
const raw = readFileSync(PROVIDER_SESSIONS_FILE, "utf-8");
|
|
233
|
+
const store = JSON.parse(raw) as SessionStore;
|
|
234
|
+
if (store.version === 1 && Array.isArray(store.sessions)) {
|
|
235
|
+
for (const s of store.sessions) {
|
|
236
|
+
// Skip expired sessions on load
|
|
237
|
+
if (Date.now() - s.updatedAt > PROVIDER_SESSION_TTL_MS) continue;
|
|
238
|
+
this.sessions.set(s.id, s);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
} catch {
|
|
242
|
+
// No file yet or corrupt — start fresh
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** Flush dirty sessions to disk. */
|
|
247
|
+
private flush(): void {
|
|
248
|
+
if (!this.dirty) return;
|
|
249
|
+
try {
|
|
250
|
+
mkdirSync(dirname(PROVIDER_SESSIONS_FILE), { recursive: true });
|
|
251
|
+
const store: SessionStore = {
|
|
252
|
+
version: 1,
|
|
253
|
+
sessions: [...this.sessions.values()],
|
|
254
|
+
};
|
|
255
|
+
writeFileSync(PROVIDER_SESSIONS_FILE, JSON.stringify(store, null, 2) + "\n", "utf-8");
|
|
256
|
+
this.dirty = false;
|
|
257
|
+
} catch {
|
|
258
|
+
// Non-fatal — sessions are still in memory
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** Shared singleton instance. */
|
|
264
|
+
export const providerSessions = new ProviderSessionRegistry();
|
package/src/proxy-server.ts
CHANGED
|
@@ -20,6 +20,17 @@ import type { BrowserContext } from "playwright";
|
|
|
20
20
|
import { renderStatusPage, type StatusProvider } from "./status-template.js";
|
|
21
21
|
import { sessionManager } from "./session-manager.js";
|
|
22
22
|
import { metrics } from "./metrics.js";
|
|
23
|
+
import { providerSessions } from "./provider-sessions.js";
|
|
24
|
+
import {
|
|
25
|
+
DEFAULT_PROXY_TIMEOUT_MS,
|
|
26
|
+
MAX_EFFECTIVE_TIMEOUT_MS,
|
|
27
|
+
TIMEOUT_PER_EXTRA_MSG_MS,
|
|
28
|
+
TIMEOUT_PER_TOOL_MS,
|
|
29
|
+
SSE_KEEPALIVE_INTERVAL_MS,
|
|
30
|
+
DEFAULT_BITNET_SERVER_URL,
|
|
31
|
+
BITNET_MAX_MESSAGES,
|
|
32
|
+
BITNET_SYSTEM_PROMPT,
|
|
33
|
+
} from "./config.js";
|
|
23
34
|
|
|
24
35
|
export type GrokCompleteOptions = Parameters<typeof grokComplete>[1];
|
|
25
36
|
export type GrokCompleteStreamOptions = Parameters<typeof grokCompleteStream>[1];
|
|
@@ -153,10 +164,11 @@ export function startProxyServer(opts: ProxyServerOptions): Promise<http.Server>
|
|
|
153
164
|
});
|
|
154
165
|
});
|
|
155
166
|
|
|
156
|
-
// Stop
|
|
167
|
+
// Stop timers and flush state when the server closes (timer-leak prevention)
|
|
157
168
|
server.on("close", () => {
|
|
158
169
|
stopTokenRefresh();
|
|
159
170
|
sessionManager.stop();
|
|
171
|
+
providerSessions.stop();
|
|
160
172
|
});
|
|
161
173
|
|
|
162
174
|
server.on("error", (err: NodeJS.ErrnoException) => {
|
|
@@ -547,7 +559,7 @@ async function handleRequest(
|
|
|
547
559
|
|
|
548
560
|
// ── BitNet local inference routing ────────────────────────────────────────
|
|
549
561
|
if (model.startsWith("local-bitnet/")) {
|
|
550
|
-
const bitnetUrl = opts.getBitNetServerUrl?.() ??
|
|
562
|
+
const bitnetUrl = opts.getBitNetServerUrl?.() ?? DEFAULT_BITNET_SERVER_URL;
|
|
551
563
|
const timeoutMs = opts.timeoutMs ?? 120_000;
|
|
552
564
|
// llama-server (BitNet build) crashes with std::runtime_error on multi-part
|
|
553
565
|
// content arrays (ref: https://github.com/ggerganov/llama.cpp/issues/8367).
|
|
@@ -564,18 +576,14 @@ async function handleRequest(
|
|
|
564
576
|
};
|
|
565
577
|
// BitNet has a 4096 token context window. Long sessions blow it up and
|
|
566
578
|
// cause a hard C++ crash (no graceful error). Truncate to system prompt +
|
|
567
|
-
// last
|
|
568
|
-
const BITNET_MAX_MESSAGES = 6;
|
|
569
|
-
// Replace the full system prompt (MEMORY.md etc, ~2k+ tokens) with a
|
|
570
|
-
// minimal one so BitNet's 4096-token context isn't blown by the system msg alone.
|
|
571
|
-
const BITNET_SYSTEM = "You are Akido, a concise AI assistant. Answer briefly and directly. Current user: Emre. Timezone: Europe/Berlin.";
|
|
579
|
+
// last N messages (~2k tokens max) to stay safely within the limit.
|
|
572
580
|
const allFlat = parsed.messages.map((m) => ({
|
|
573
581
|
role: m.role,
|
|
574
582
|
content: flattenContent(m.content),
|
|
575
583
|
}));
|
|
576
584
|
const nonSystemMsgs = allFlat.filter((m) => m.role !== "system");
|
|
577
585
|
const truncated = nonSystemMsgs.slice(-BITNET_MAX_MESSAGES);
|
|
578
|
-
const bitnetMessages = [{ role: "system", content:
|
|
586
|
+
const bitnetMessages = [{ role: "system", content: BITNET_SYSTEM_PROMPT }, ...truncated];
|
|
579
587
|
const requestBody = JSON.stringify({ ...parsed, messages: bitnetMessages, tools: undefined });
|
|
580
588
|
|
|
581
589
|
const bitnetStart = Date.now();
|
|
@@ -639,14 +647,23 @@ async function handleRequest(
|
|
|
639
647
|
let usedModel = model;
|
|
640
648
|
const routeOpts = { workdir, tools: hasTools ? tools : undefined, mediaFiles: mediaFiles.length ? mediaFiles : undefined, log: opts.log };
|
|
641
649
|
|
|
650
|
+
// ── Provider session: ensure a persistent session for this model ────────
|
|
651
|
+
// Extract provider prefix from model (e.g. "cli-claude" from "cli-claude/claude-sonnet-4-6")
|
|
652
|
+
const providerPrefix = model.split("/")[0];
|
|
653
|
+
const incomingSessionId = (parsed as { providerSessionId?: string }).providerSessionId;
|
|
654
|
+
const session = incomingSessionId
|
|
655
|
+
? (providerSessions.getSession(incomingSessionId) ?? providerSessions.ensureSession(providerPrefix, model))
|
|
656
|
+
: providerSessions.ensureSession(providerPrefix, model);
|
|
657
|
+
providerSessions.touchSession(session.id);
|
|
658
|
+
|
|
642
659
|
// ── Dynamic timeout: scale with conversation size ────────────────────────
|
|
643
660
|
// Per-model timeout takes precedence, then global proxyTimeoutMs, then 300s default.
|
|
644
661
|
const perModelTimeout = opts.modelTimeouts?.[model];
|
|
645
|
-
const baseTimeout = perModelTimeout ?? opts.timeoutMs ??
|
|
646
|
-
const msgExtra = Math.max(0, cleanMessages.length - 10) *
|
|
647
|
-
const toolExtra = (tools?.length ?? 0) *
|
|
648
|
-
const effectiveTimeout = Math.min(baseTimeout + msgExtra + toolExtra,
|
|
649
|
-
opts.log(`[cli-bridge] ${model} timeout: ${Math.round(effectiveTimeout / 1000)}s (base=${Math.round(baseTimeout / 1000)}s${perModelTimeout ? " per-model" : ""}, +${Math.round(msgExtra / 1000)}s msgs, +${Math.round(toolExtra / 1000)}s tools)`);
|
|
662
|
+
const baseTimeout = perModelTimeout ?? opts.timeoutMs ?? DEFAULT_PROXY_TIMEOUT_MS;
|
|
663
|
+
const msgExtra = Math.max(0, cleanMessages.length - 10) * TIMEOUT_PER_EXTRA_MSG_MS;
|
|
664
|
+
const toolExtra = (tools?.length ?? 0) * TIMEOUT_PER_TOOL_MS;
|
|
665
|
+
const effectiveTimeout = Math.min(baseTimeout + msgExtra + toolExtra, MAX_EFFECTIVE_TIMEOUT_MS);
|
|
666
|
+
opts.log(`[cli-bridge] ${model} session=${session.id} timeout: ${Math.round(effectiveTimeout / 1000)}s (base=${Math.round(baseTimeout / 1000)}s${perModelTimeout ? " per-model" : ""}, +${Math.round(msgExtra / 1000)}s msgs, +${Math.round(toolExtra / 1000)}s tools)`);
|
|
650
667
|
|
|
651
668
|
// ── SSE keepalive: send headers early so OpenClaw doesn't read-timeout ──
|
|
652
669
|
let sseHeadersSent = false;
|
|
@@ -660,22 +677,25 @@ async function handleRequest(
|
|
|
660
677
|
});
|
|
661
678
|
sseHeadersSent = true;
|
|
662
679
|
res.write(": keepalive\n\n");
|
|
663
|
-
keepaliveInterval = setInterval(() => { res.write(": keepalive\n\n"); },
|
|
680
|
+
keepaliveInterval = setInterval(() => { res.write(": keepalive\n\n"); }, SSE_KEEPALIVE_INTERVAL_MS);
|
|
664
681
|
}
|
|
665
682
|
|
|
666
683
|
const cliStart = Date.now();
|
|
667
684
|
try {
|
|
668
685
|
result = await routeToCliRunner(model, cleanMessages, effectiveTimeout, routeOpts);
|
|
669
686
|
metrics.recordRequest(model, Date.now() - cliStart, true);
|
|
687
|
+
providerSessions.recordRun(session.id, false);
|
|
670
688
|
} catch (err) {
|
|
671
689
|
const primaryDuration = Date.now() - cliStart;
|
|
672
690
|
const msg = (err as Error).message;
|
|
673
691
|
// ── Model fallback: retry once with a lighter model if configured ────
|
|
674
692
|
const isTimeout = msg.includes("timeout:") || msg.includes("exit 143") || msg.includes("exited 143");
|
|
693
|
+
// Record the run (with timeout flag) — session is preserved, not deleted
|
|
694
|
+
providerSessions.recordRun(session.id, isTimeout);
|
|
675
695
|
const fallbackModel = opts.modelFallbacks?.[model];
|
|
676
696
|
if (fallbackModel) {
|
|
677
697
|
metrics.recordRequest(model, primaryDuration, false);
|
|
678
|
-
const reason = isTimeout ?
|
|
698
|
+
const reason = isTimeout ? `timeout by supervisor, session=${session.id} preserved` : msg;
|
|
679
699
|
opts.warn(`[cli-bridge] ${model} failed (${reason}), falling back to ${fallbackModel}`);
|
|
680
700
|
const fallbackStart = Date.now();
|
|
681
701
|
try {
|
|
@@ -787,6 +807,8 @@ async function handleRequest(
|
|
|
787
807
|
},
|
|
788
808
|
],
|
|
789
809
|
usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
|
|
810
|
+
// Propagate session ID so callers can resume in the same session
|
|
811
|
+
provider_session_id: session.id,
|
|
790
812
|
};
|
|
791
813
|
|
|
792
814
|
res.writeHead(200, { "Content-Type": "application/json", ...corsHeaders() });
|
|
@@ -906,6 +928,26 @@ async function handleRequest(
|
|
|
906
928
|
return;
|
|
907
929
|
}
|
|
908
930
|
|
|
931
|
+
// ── Provider session endpoints ──────────────────────────────────────────────
|
|
932
|
+
|
|
933
|
+
// GET /v1/provider-sessions — list all provider sessions with stats
|
|
934
|
+
if (url === "/v1/provider-sessions" && req.method === "GET") {
|
|
935
|
+
const sessions = providerSessions.listSessions();
|
|
936
|
+
const stats = providerSessions.stats();
|
|
937
|
+
res.writeHead(200, { "Content-Type": "application/json", ...corsHeaders() });
|
|
938
|
+
res.end(JSON.stringify({ sessions, stats }));
|
|
939
|
+
return;
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
// DELETE /v1/provider-sessions/:id — delete a specific provider session
|
|
943
|
+
const provSessionMatch = url.match(/^\/v1\/provider-sessions\/([a-zA-Z0-9:_-]+)$/);
|
|
944
|
+
if (provSessionMatch && req.method === "DELETE") {
|
|
945
|
+
const ok = providerSessions.deleteSession(decodeURIComponent(provSessionMatch[1]));
|
|
946
|
+
res.writeHead(ok ? 200 : 404, { "Content-Type": "application/json", ...corsHeaders() });
|
|
947
|
+
res.end(JSON.stringify({ ok }));
|
|
948
|
+
return;
|
|
949
|
+
}
|
|
950
|
+
|
|
909
951
|
// 404
|
|
910
952
|
res.writeHead(404, { "Content-Type": "application/json" });
|
|
911
953
|
res.end(JSON.stringify({ error: { message: `Not found: ${url}`, type: "not_found" } }));
|
package/src/session-manager.ts
CHANGED
|
@@ -16,6 +16,11 @@ import { join } from "node:path";
|
|
|
16
16
|
import { execSync } from "node:child_process";
|
|
17
17
|
import { formatPrompt, type ChatMessage } from "./cli-runner.js";
|
|
18
18
|
import { createIsolatedWorkdir, cleanupWorkdir, sweepOrphanedWorkdirs } from "./workdir.js";
|
|
19
|
+
import {
|
|
20
|
+
SESSION_TTL_MS,
|
|
21
|
+
CLEANUP_INTERVAL_MS,
|
|
22
|
+
SESSION_KILL_GRACE_MS,
|
|
23
|
+
} from "./config.js";
|
|
19
24
|
|
|
20
25
|
// ──────────────────────────────────────────────────────────────────────────────
|
|
21
26
|
// Types
|
|
@@ -92,11 +97,7 @@ function buildMinimalEnv(): Record<string, string> {
|
|
|
92
97
|
// Session Manager
|
|
93
98
|
// ──────────────────────────────────────────────────────────────────────────────
|
|
94
99
|
|
|
95
|
-
|
|
96
|
-
const SESSION_TTL_MS = 30 * 60 * 1000;
|
|
97
|
-
const CLEANUP_INTERVAL_MS = 5 * 60 * 1000;
|
|
98
|
-
/** Grace period between SIGTERM and SIGKILL for session termination. */
|
|
99
|
-
const KILL_GRACE_MS = 5_000;
|
|
100
|
+
// SESSION_TTL_MS, CLEANUP_INTERVAL_MS, SESSION_KILL_GRACE_MS imported from config.ts
|
|
100
101
|
|
|
101
102
|
export class SessionManager {
|
|
102
103
|
private sessions = new Map<string, SessionEntry>();
|
|
@@ -227,7 +228,7 @@ export class SessionManager {
|
|
|
227
228
|
// If the process doesn't exit within the grace period, force-kill it
|
|
228
229
|
setTimeout(() => {
|
|
229
230
|
try { if (!entry.proc.killed) entry.proc.kill("SIGKILL"); } catch { /* already dead */ }
|
|
230
|
-
},
|
|
231
|
+
}, SESSION_KILL_GRACE_MS);
|
|
231
232
|
return true;
|
|
232
233
|
}
|
|
233
234
|
|
|
@@ -258,7 +259,7 @@ export class SessionManager {
|
|
|
258
259
|
// Escalate to SIGKILL after grace period
|
|
259
260
|
setTimeout(() => {
|
|
260
261
|
try { if (!entry.proc.killed) entry.proc.kill("SIGKILL"); } catch { /* already dead */ }
|
|
261
|
-
},
|
|
262
|
+
}, SESSION_KILL_GRACE_MS);
|
|
262
263
|
}
|
|
263
264
|
// Clean up isolated workdir if it wasn't cleaned on exit
|
|
264
265
|
if (entry.isolatedWorkdir) {
|
|
@@ -284,7 +285,7 @@ export class SessionManager {
|
|
|
284
285
|
entry.status = "killed";
|
|
285
286
|
setTimeout(() => {
|
|
286
287
|
try { if (!entry.proc.killed) entry.proc.kill("SIGKILL"); } catch { /* already dead */ }
|
|
287
|
-
},
|
|
288
|
+
}, SESSION_KILL_GRACE_MS);
|
|
288
289
|
}
|
|
289
290
|
if (entry.isolatedWorkdir) {
|
|
290
291
|
cleanupWorkdir(entry.isolatedWorkdir);
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the centralized config module.
|
|
3
|
+
* No mocks — tests the real exported values.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, it, expect } from "vitest";
|
|
7
|
+
import {
|
|
8
|
+
DEFAULT_PROXY_PORT,
|
|
9
|
+
DEFAULT_PROXY_API_KEY,
|
|
10
|
+
DEFAULT_PROXY_TIMEOUT_MS,
|
|
11
|
+
DEFAULT_CLI_TIMEOUT_MS,
|
|
12
|
+
TIMEOUT_GRACE_MS,
|
|
13
|
+
MAX_MESSAGES,
|
|
14
|
+
MAX_MSG_CHARS,
|
|
15
|
+
SESSION_TTL_MS,
|
|
16
|
+
CLEANUP_INTERVAL_MS,
|
|
17
|
+
SESSION_KILL_GRACE_MS,
|
|
18
|
+
DEFAULT_MODEL_TIMEOUTS,
|
|
19
|
+
DEFAULT_MODEL_FALLBACKS,
|
|
20
|
+
OPENCLAW_DIR,
|
|
21
|
+
CLI_TEST_DEFAULT_MODEL,
|
|
22
|
+
MAX_EFFECTIVE_TIMEOUT_MS,
|
|
23
|
+
TIMEOUT_PER_EXTRA_MSG_MS,
|
|
24
|
+
TIMEOUT_PER_TOOL_MS,
|
|
25
|
+
PROVIDER_SESSION_TTL_MS,
|
|
26
|
+
MEDIA_TMP_DIR,
|
|
27
|
+
PROFILE_DIRS,
|
|
28
|
+
STATE_FILE,
|
|
29
|
+
PENDING_FILE,
|
|
30
|
+
PROVIDER_SESSIONS_FILE,
|
|
31
|
+
DEFAULT_BITNET_SERVER_URL,
|
|
32
|
+
BITNET_MAX_MESSAGES,
|
|
33
|
+
BITNET_SYSTEM_PROMPT,
|
|
34
|
+
} from "../src/config.js";
|
|
35
|
+
|
|
36
|
+
describe("config.ts exports", () => {
|
|
37
|
+
it("exports sensible timeout defaults", () => {
|
|
38
|
+
expect(DEFAULT_PROXY_TIMEOUT_MS).toBe(300_000);
|
|
39
|
+
expect(DEFAULT_CLI_TIMEOUT_MS).toBe(120_000);
|
|
40
|
+
expect(TIMEOUT_GRACE_MS).toBe(5_000);
|
|
41
|
+
expect(MAX_EFFECTIVE_TIMEOUT_MS).toBe(600_000);
|
|
42
|
+
expect(SESSION_TTL_MS).toBe(30 * 60 * 1000);
|
|
43
|
+
expect(CLEANUP_INTERVAL_MS).toBe(5 * 60 * 1000);
|
|
44
|
+
expect(SESSION_KILL_GRACE_MS).toBe(5_000);
|
|
45
|
+
expect(PROVIDER_SESSION_TTL_MS).toBe(2 * 60 * 60 * 1000);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("exports dynamic timeout scaling factors", () => {
|
|
49
|
+
expect(TIMEOUT_PER_EXTRA_MSG_MS).toBe(2_000);
|
|
50
|
+
expect(TIMEOUT_PER_TOOL_MS).toBe(5_000);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("exports message limits", () => {
|
|
54
|
+
expect(MAX_MESSAGES).toBe(20);
|
|
55
|
+
expect(MAX_MSG_CHARS).toBe(4_000);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("exports proxy defaults", () => {
|
|
59
|
+
expect(DEFAULT_PROXY_PORT).toBe(31337);
|
|
60
|
+
expect(DEFAULT_PROXY_API_KEY).toBe("cli-bridge");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("exports per-model timeouts for all major models", () => {
|
|
64
|
+
expect(DEFAULT_MODEL_TIMEOUTS["cli-claude/claude-opus-4-6"]).toBe(300_000);
|
|
65
|
+
expect(DEFAULT_MODEL_TIMEOUTS["cli-claude/claude-sonnet-4-6"]).toBe(180_000);
|
|
66
|
+
expect(DEFAULT_MODEL_TIMEOUTS["cli-claude/claude-haiku-4-5"]).toBe(90_000);
|
|
67
|
+
expect(DEFAULT_MODEL_TIMEOUTS["cli-gemini/gemini-2.5-pro"]).toBe(180_000);
|
|
68
|
+
expect(DEFAULT_MODEL_TIMEOUTS["cli-gemini/gemini-2.5-flash"]).toBe(90_000);
|
|
69
|
+
expect(DEFAULT_MODEL_TIMEOUTS["openai-codex/gpt-5.4"]).toBe(300_000);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("exports model fallback chains", () => {
|
|
73
|
+
expect(DEFAULT_MODEL_FALLBACKS["cli-claude/claude-sonnet-4-6"]).toBe("cli-claude/claude-haiku-4-5");
|
|
74
|
+
expect(DEFAULT_MODEL_FALLBACKS["cli-claude/claude-opus-4-6"]).toBe("cli-claude/claude-sonnet-4-6");
|
|
75
|
+
expect(DEFAULT_MODEL_FALLBACKS["cli-gemini/gemini-2.5-pro"]).toBe("cli-gemini/gemini-2.5-flash");
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("exports path constants rooted in ~/.openclaw", () => {
|
|
79
|
+
expect(OPENCLAW_DIR).toContain(".openclaw");
|
|
80
|
+
expect(STATE_FILE).toContain("cli-bridge-state.json");
|
|
81
|
+
expect(PENDING_FILE).toContain("cli-bridge-pending.json");
|
|
82
|
+
expect(PROVIDER_SESSIONS_FILE).toContain("sessions.json");
|
|
83
|
+
expect(MEDIA_TMP_DIR).toContain("cli-bridge-media");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("exports browser profile directories", () => {
|
|
87
|
+
expect(PROFILE_DIRS.grok).toContain("grok-profile");
|
|
88
|
+
expect(PROFILE_DIRS.gemini).toContain("gemini-profile");
|
|
89
|
+
expect(PROFILE_DIRS.claude).toContain("claude-profile");
|
|
90
|
+
expect(PROFILE_DIRS.chatgpt).toContain("chatgpt-profile");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("exports BitNet defaults", () => {
|
|
94
|
+
expect(DEFAULT_BITNET_SERVER_URL).toBe("http://127.0.0.1:8082");
|
|
95
|
+
expect(BITNET_MAX_MESSAGES).toBe(6);
|
|
96
|
+
expect(BITNET_SYSTEM_PROMPT).toContain("Akido");
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("exports CLI test default model", () => {
|
|
100
|
+
expect(CLI_TEST_DEFAULT_MODEL).toBe("cli-claude/claude-sonnet-4-6");
|
|
101
|
+
});
|
|
102
|
+
});
|