@emqo/claudebridge 0.8.0 → 0.9.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.
@@ -1,27 +1,42 @@
1
1
  import { spawn } from "child_process";
2
2
  import { mkdirSync } from "fs";
3
3
  import { join } from "path";
4
- import { UserLock } from "./lock.js";
4
+ import { SessionLock } from "./lock.js";
5
5
  import { AccessControl } from "./permissions.js";
6
6
  import { EndpointRotator } from "./keys.js";
7
7
  import { generateSkillDoc } from "../skills/bridge.js";
8
+ import { SessionManager } from "./session.js";
9
+ import { SessionRouter } from "./router.js";
10
+ import { log as rootLog } from "./logger.js";
11
+ import { getProvider } from "../providers/registry.js";
12
+ const log = rootLog.child("agent");
8
13
  export class AgentEngine {
9
14
  config;
10
15
  store;
11
16
  lock;
12
17
  rotator;
18
+ sessionMgr;
19
+ router;
20
+ sessionExpiryTimer;
13
21
  access;
14
22
  constructor(config, store) {
15
23
  this.config = config;
16
24
  this.store = store;
17
- this.lock = new UserLock(config.redis.enabled ? config.redis.url : undefined);
25
+ this.lock = new SessionLock(config.redis.enabled ? config.redis.url : undefined);
18
26
  this.access = new AccessControl(config.access.allowed_users, config.access.allowed_groups);
19
27
  this.rotator = new EndpointRotator(config.endpoints);
28
+ this.sessionMgr = new SessionManager(store, config.agent.session);
29
+ this.router = new SessionRouter(this.sessionMgr, this.rotator, config.agent.session);
30
+ // Periodic idle session expiry (every 5 min)
31
+ this.sessionExpiryTimer = setInterval(() => {
32
+ this.sessionMgr.expireIdle();
33
+ }, 5 * 60 * 1000);
20
34
  }
21
35
  reloadConfig(config) {
22
36
  this.config = config;
23
37
  this.access.reload(config.access.allowed_users, config.access.allowed_groups);
24
38
  this.rotator.reload(config.endpoints);
39
+ // SessionManager/Router pick up config changes via reference
25
40
  }
26
41
  getEndpoints() {
27
42
  return this.rotator.list();
@@ -35,6 +50,12 @@ export class AgentEngine {
35
50
  getMaxParallel() {
36
51
  return this.config.agent.max_parallel || 1;
37
52
  }
53
+ getSessionManager() {
54
+ return this.sessionMgr;
55
+ }
56
+ getRouter() {
57
+ return this.router;
58
+ }
38
59
  getWorkDir(userId) {
39
60
  if (!this.config.workspace.isolation) {
40
61
  return this.config.agent.cwd || process.cwd();
@@ -43,290 +64,283 @@ export class AgentEngine {
43
64
  mkdirSync(dir, { recursive: true });
44
65
  return dir;
45
66
  }
67
+ /** @deprecated Use isSessionLocked() for multi-session mode */
46
68
  isLocked(userId) {
47
69
  return this.lock.isLocked(userId);
48
70
  }
49
- async runStream(userId, prompt, platform, chatId, onChunk, overrideTimeoutMs) {
50
- const release = await this.lock.acquire(userId);
71
+ isSessionLocked(subSessionId) {
72
+ return this.lock.isLocked(subSessionId);
73
+ }
74
+ isMultiSessionEnabled() {
75
+ return this.config.agent.session?.enabled !== false;
76
+ }
77
+ // ─── Multi-Session Entry Point ───────────────────────────────
78
+ /**
79
+ * Main entry point for user messages in multi-session mode.
80
+ * Routes to the correct sub-session and executes concurrently.
81
+ */
82
+ async handleUserMessage(userId, prompt, platform, chatId, replyToMsgId, onChunk, overrideTimeoutMs) {
83
+ // 1. Route
84
+ const decision = await this.router.route(userId, platform, chatId, prompt, replyToMsgId);
85
+ // 2. Create or get sub-session
86
+ let subSession;
87
+ if (decision.action === "create") {
88
+ if (!this.sessionMgr.canCreate(userId, platform)) {
89
+ // Evict the oldest idle session to make room
90
+ const active = this.sessionMgr.getActive(userId, platform);
91
+ const oldest = active.sort((a, b) => a.lastActiveAt - b.lastActiveAt)[0];
92
+ if (oldest) {
93
+ this.sessionMgr.close(oldest.id);
94
+ log.info("evicted oldest sub-session", { sessionId: oldest.id.slice(0, 8), userId });
95
+ }
96
+ }
97
+ subSession = this.sessionMgr.create(userId, platform, chatId, decision.label);
98
+ log.info("created sub-session", { sessionId: subSession.id.slice(0, 8), label: subSession.label, userId });
99
+ }
100
+ else {
101
+ subSession = this.sessionMgr.get(decision.subSessionId);
102
+ if (!subSession) {
103
+ // Safety fallback: session was closed/deleted between route and get
104
+ subSession = this.sessionMgr.create(userId, platform, chatId, prompt.slice(0, 50));
105
+ log.warn("routed session not found, created new", { sessionId: subSession.id.slice(0, 8), userId });
106
+ }
107
+ }
108
+ // 3. Execute in sub-session (per-session lock)
109
+ const res = await this._executeSubSession(subSession, prompt, platform, chatId, onChunk, overrideTimeoutMs);
110
+ // 4. Post-processing
111
+ this.store.addHistory(userId, platform, "user", prompt);
112
+ this.store.addHistory(userId, platform, "assistant", res.text);
113
+ this.store.recordUsage(userId, platform, res.cost || 0);
114
+ this.sessionMgr.touch(subSession.id);
115
+ this.sessionMgr.addCost(subSession.id, res.cost || 0);
116
+ // 5. Auto-label: set label on first message if empty
117
+ if (subSession.messageCount === 0 && !subSession.label) {
118
+ this.sessionMgr.updateLabel(subSession.id, prompt.slice(0, 50));
119
+ subSession.label = prompt.slice(0, 50);
120
+ }
121
+ // 6. Auto-summarize
122
+ if (this.config.agent.memory?.auto_summary)
123
+ this._autoSummarize(userId, prompt, res.text);
124
+ return { ...res, subSessionId: subSession.id, label: subSession.label };
125
+ }
126
+ /**
127
+ * Execute a prompt within a specific sub-session.
128
+ * Acquires per-session lock, resumes claude session via -r flag.
129
+ */
130
+ async _executeSubSession(subSession, prompt, platform, chatId, onChunk, overrideTimeoutMs) {
131
+ const release = await this.lock.acquire(subSession.id);
51
132
  try {
52
- this.store.addHistory(userId, platform, "user", prompt);
53
- const memories = this.config.agent.memory?.enabled ? this.store.getMemories(userId) : [];
133
+ const memories = this.config.agent.memory?.enabled ? this.store.getMemories(subSession.userId) : [];
54
134
  const memoryPrompt = memories.length ? memories.map(m => `- ${m.content}`).join("\n") : "";
55
- const res = await this._executeWithRetry(userId, prompt, platform, chatId, onChunk, memoryPrompt, overrideTimeoutMs);
56
- this.store.addHistory(userId, platform, "assistant", res.text);
57
- this.store.recordUsage(userId, platform, res.cost || 0);
58
- if (this.config.agent.memory?.auto_summary)
59
- this._autoSummarize(userId, prompt, res.text);
60
- return res;
135
+ const ep = this.rotator.count
136
+ ? this.rotator.next()
137
+ : { name: "default", provider: "claude", model: "" };
138
+ return await this._executeWithSession(subSession, prompt, platform, chatId, ep, onChunk, memoryPrompt, overrideTimeoutMs);
61
139
  }
62
140
  finally {
63
141
  release();
64
142
  }
65
143
  }
66
- async runParallel(userId, prompt, platform, chatId, onChunk, overrideTimeoutMs) {
67
- // No per-user lock parallel tasks are independent
68
- // No session resume fresh session to prevent conflicts
69
- const memories = this.config.agent.memory?.enabled ? this.store.getMemories(userId) : [];
70
- const memoryPrompt = memories.length ? memories.map(m => `- ${m.content}`).join("\n") : "";
71
- const maxRetries = Math.max(Math.min(this.rotator.count, 3), 1);
72
- let lastErr;
73
- for (let i = 0; i < maxRetries; i++) {
74
- const ep = this.rotator.count
75
- ? this.rotator.next()
76
- : { name: "cli-default", api_key: "", base_url: "", model: "" };
77
- try {
78
- const res = await this._executeNoSession(userId, prompt, platform, chatId, ep, onChunk, memoryPrompt, overrideTimeoutMs);
79
- this.store.recordUsage(userId, platform, res.cost || 0);
80
- return res;
81
- }
82
- catch (err) {
83
- lastErr = err;
84
- const msg = String(err?.message || "");
85
- if (msg.includes("429") || msg.includes("401") || msg.includes("529")) {
86
- console.warn(`[agent] endpoint ${ep.name} failed (parallel), rotating`);
87
- this.rotator.markFailed(ep);
88
- continue;
89
- }
90
- throw err;
91
- }
144
+ /**
145
+ * Core execution: spawn claude CLI with session resume for a sub-session.
146
+ * Thin wrapper around _spawnAgent with sub-session persistence.
147
+ */
148
+ async _executeWithSession(subSession, prompt, platform, chatId, ep, onChunk, memoryPrompt, overrideTimeoutMs) {
149
+ try {
150
+ const res = await this._spawnAgent({
151
+ userId: subSession.userId, prompt, platform, chatId, ep, onChunk, memoryPrompt, overrideTimeoutMs,
152
+ resumeSessionId: subSession.claudeSessionId || undefined,
153
+ subSessionId: subSession.id,
154
+ subSessionLabel: subSession.label,
155
+ logLabel: `sub-session=${subSession.id.slice(0, 8)}`,
156
+ });
157
+ if (res.sessionId)
158
+ this.sessionMgr.setClaudeSessionId(subSession.id, res.sessionId);
159
+ return res;
160
+ }
161
+ catch (err) {
162
+ this.sessionMgr.setClaudeSessionId(subSession.id, "");
163
+ throw err;
92
164
  }
93
- throw lastErr;
94
165
  }
95
- async _executeWithRetry(userId, prompt, platform, chatId, onChunk, memoryPrompt, overrideTimeoutMs) {
96
- const maxRetries = Math.max(Math.min(this.rotator.count, 3), 1);
97
- let lastErr;
98
- for (let i = 0; i < maxRetries; i++) {
99
- const ep = this.rotator.count
100
- ? this.rotator.next()
101
- : { name: "cli-default", api_key: "", base_url: "", model: "" };
102
- try {
103
- return await this._execute(userId, prompt, platform, chatId, ep, onChunk, memoryPrompt, overrideTimeoutMs);
104
- }
105
- catch (err) {
106
- lastErr = err;
107
- const msg = String(err?.message || "");
108
- if (msg.includes("429") || msg.includes("401") || msg.includes("529")) {
109
- console.warn(`[agent] endpoint ${ep.name} failed, rotating`);
110
- this.rotator.markFailed(ep);
111
- continue;
112
- }
113
- throw err;
114
- }
166
+ // ─── Core Spawn Infrastructure ──────────────────────────────
167
+ /** Build the append system prompt (memories + skill doc) */
168
+ _buildAppendPrompt(opts) {
169
+ let appendPrompt = "";
170
+ if (opts.memoryPrompt)
171
+ appendPrompt += `User memories:\n${opts.memoryPrompt}\n\n`;
172
+ if (this.config.agent.skill?.enabled !== false) {
173
+ appendPrompt += generateSkillDoc({
174
+ userId: opts.userId, chatId: opts.chatId, platform: opts.platform,
175
+ locale: this.config.locale || "en",
176
+ ...(opts.subSessionId ? { subSessionId: opts.subSessionId, subSessionLabel: opts.subSessionLabel } : {}),
177
+ });
115
178
  }
116
- throw lastErr;
179
+ return appendPrompt.trim();
117
180
  }
118
- _execute(userId, prompt, platform, chatId, ep, onChunk, memoryPrompt, overrideTimeoutMs) {
181
+ /**
182
+ * Unified core: spawn provider CLI, parse stream, handle timeout.
183
+ * All execute methods delegate here.
184
+ */
185
+ _spawnAgent(opts) {
119
186
  return new Promise((resolve, reject) => {
120
- const sessionId = this.store.getSession(userId) || "";
121
- const cwd = this.getWorkDir(userId);
122
- const args = ["-p", prompt, "--verbose", "--output-format", "stream-json", "--permission-mode", this.config.agent.permission_mode || "acceptEdits"];
123
- if (ep.model)
124
- args.push("--model", ep.model);
125
- if (sessionId)
126
- args.push("-r", sessionId);
127
- if (this.config.agent.system_prompt)
128
- args.push("--system-prompt", this.config.agent.system_prompt);
129
- // Build combined append prompt: memories + skill doc
130
- let appendPrompt = "";
131
- if (memoryPrompt)
132
- appendPrompt += `User memories:\n${memoryPrompt}\n\n`;
133
- if (this.config.agent.skill?.enabled !== false) {
134
- appendPrompt += generateSkillDoc({ userId, chatId, platform, locale: this.config.locale || "en" });
135
- }
136
- if (appendPrompt)
137
- args.push("--append-system-prompt", appendPrompt.trim());
138
- if (this.config.agent.allowed_tools?.length)
139
- args.push("--allowed-tools", this.config.agent.allowed_tools.join(","));
140
- if (this.config.agent.max_turns)
141
- args.push("--max-turns", String(this.config.agent.max_turns));
142
- if (this.config.agent.max_budget_usd)
143
- args.push("--max-budget-usd", String(this.config.agent.max_budget_usd));
144
- const env = { ...process.env };
145
- if (ep.api_key)
146
- env.ANTHROPIC_API_KEY = ep.api_key;
147
- if (ep.base_url)
148
- env.ANTHROPIC_BASE_URL = ep.base_url;
149
- env.CLAUDEBRIDGE_DB = this.store.dbPath;
150
- const child = spawn("claude", args, { cwd, env, stdio: ["pipe", "pipe", "pipe"] });
187
+ const provider = getProvider(opts.ep.provider || "claude");
188
+ const cwd = this.getWorkDir(opts.userId);
189
+ const label = opts.logLabel || "";
190
+ const verbose = opts.verbose !== false;
191
+ const appendPrompt = this._buildAppendPrompt(opts);
192
+ const args = provider.buildArgs({
193
+ prompt: opts.prompt,
194
+ model: opts.ep.model,
195
+ resumeSessionId: provider.supportsSessionResume ? opts.resumeSessionId : undefined,
196
+ systemPrompt: this.config.agent.system_prompt || undefined,
197
+ appendSystemPrompt: appendPrompt || undefined,
198
+ allowedTools: this.config.agent.allowed_tools,
199
+ maxTurns: this.config.agent.max_turns,
200
+ maxBudgetUsd: this.config.agent.max_budget_usd,
201
+ permissionMode: this.config.agent.permission_mode || "acceptEdits",
202
+ });
203
+ const env = provider.buildEnv({ CLAUDEBRIDGE_DB: this.store.dbPath });
204
+ const child = spawn(provider.binary, args, { cwd, env, stdio: ["pipe", "pipe", "pipe"] });
151
205
  child.stdin.end();
152
- console.log(`[agent] spawned claude pid=${child.pid} cwd=${cwd} args=${args.join(" ")}`);
153
- const timeoutMs = overrideTimeoutMs !== undefined ? overrideTimeoutMs : (this.config.agent.timeout_seconds || 600) * 1000;
206
+ log.info("spawned agent", { provider: opts.ep.provider || "claude", pid: child.pid, label, cwd });
207
+ const timeoutMs = opts.overrideTimeoutMs !== undefined
208
+ ? opts.overrideTimeoutMs
209
+ : (this.config.agent.timeout_seconds || 600) * 1000;
154
210
  const timer = timeoutMs > 0 ? setTimeout(() => { try {
155
211
  child.kill("SIGTERM");
156
212
  }
157
213
  catch { } }, timeoutMs) : null;
158
214
  let fullText = "";
159
- let newSessionId = sessionId;
215
+ let sessionId = opts.resumeSessionId || "";
160
216
  let cost = 0;
161
217
  let buffer = "";
162
218
  child.stdout.on("data", (data) => {
163
219
  const chunk = data.toString();
164
- console.log(`[agent] stdout chunk: ${chunk.slice(0, 200)}`);
220
+ if (verbose)
221
+ log.debug("stdout chunk", { data: chunk.slice(0, 200) });
165
222
  buffer += chunk;
166
223
  const lines = buffer.split("\n");
167
224
  buffer = lines.pop() || "";
168
225
  for (const line of lines) {
169
226
  if (!line.trim())
170
227
  continue;
171
- try {
172
- const msg = JSON.parse(line);
173
- if (msg.type === "system" && msg.subtype === "init" && msg.session_id) {
174
- newSessionId = msg.session_id;
175
- }
176
- if (msg.type === "assistant" && msg.message?.content) {
177
- for (const block of msg.message.content) {
178
- if (block.type === "text" && block.text) {
179
- fullText += block.text + "\n";
180
- if (onChunk)
181
- onChunk(block.text, fullText);
182
- }
183
- }
184
- }
185
- if (msg.type === "result") {
186
- if (msg.result)
187
- fullText = msg.result;
188
- if (msg.total_cost_usd)
189
- cost = msg.total_cost_usd;
190
- if (msg.is_error) {
191
- console.error(`[agent] claude error result: subtype=${msg.subtype} message=${JSON.stringify(msg.error_message || msg.message || "unknown").slice(0, 500)}`);
228
+ const event = provider.parseLine(line);
229
+ switch (event.type) {
230
+ case "session_init":
231
+ if (event.sessionId)
232
+ sessionId = event.sessionId;
233
+ break;
234
+ case "text_chunk":
235
+ if (event.text) {
236
+ fullText += event.text + "\n";
237
+ if (opts.onChunk)
238
+ opts.onChunk(event.text, fullText);
192
239
  }
193
- }
240
+ break;
241
+ case "result":
242
+ if (event.text)
243
+ fullText = event.text;
244
+ if (event.cost)
245
+ cost = event.cost;
246
+ if (verbose && event.isError)
247
+ log.error("agent error result", { label });
248
+ break;
194
249
  }
195
- catch { }
196
250
  }
197
251
  });
198
252
  let stderr = "";
199
253
  child.stderr.on("data", (data) => {
200
254
  const s = data.toString();
201
255
  stderr += s;
202
- console.log(`[agent] stderr: ${s.slice(0, 200)}`);
256
+ if (verbose)
257
+ log.debug("stderr", { data: s.slice(0, 200) });
203
258
  });
204
259
  child.on("close", (code, signal) => {
205
260
  if (timer)
206
261
  clearTimeout(timer);
207
- console.log(`[agent] claude exited code=${code} signal=${signal} fullText=${fullText.length}chars stderr=${stderr.slice(0, 200)}`);
262
+ log.info("agent exited", { code, signal, label, textLen: fullText.length });
208
263
  if (signal === "SIGTERM") {
209
- console.warn(`[agent] claude timed out after ${timeoutMs / 1000}s`);
210
- if (newSessionId)
211
- this.store.setSession(userId, newSessionId, platform);
212
- resolve({ text: fullText.trim() || "(timed out)", sessionId: newSessionId, cost, timedOut: true });
264
+ log.warn("agent timed out", { seconds: timeoutMs / 1000 });
265
+ resolve({ text: fullText.trim() || "(timed out)", sessionId, cost, timedOut: true });
213
266
  return;
214
267
  }
215
268
  if (code === 0 || fullText.trim()) {
216
- if (newSessionId)
217
- this.store.setSession(userId, newSessionId, platform);
218
- resolve({ text: fullText.trim() || "(no response)", sessionId: newSessionId, cost });
269
+ resolve({ text: fullText.trim() || "(no response)", sessionId, cost });
219
270
  }
220
271
  else {
221
- // Don't save session on failure — stale session would lock the user in a failure loop
222
- this.store.clearSession(userId);
223
- reject(new Error(`claude exited ${code}: ${stderr.slice(0, 500)}`));
272
+ reject(new Error(`agent exited ${code}: ${stderr.slice(0, 500)}`));
224
273
  }
225
274
  });
226
275
  child.on("error", reject);
227
276
  });
228
277
  }
229
- _executeNoSession(userId, prompt, platform, chatId, ep, onChunk, memoryPrompt, overrideTimeoutMs) {
230
- return new Promise((resolve, reject) => {
231
- const cwd = this.getWorkDir(userId);
232
- const args = ["-p", prompt, "--verbose", "--output-format", "stream-json", "--permission-mode", this.config.agent.permission_mode || "acceptEdits"];
233
- if (ep.model)
234
- args.push("--model", ep.model);
235
- // No session resume fresh session for parallel execution
236
- if (this.config.agent.system_prompt)
237
- args.push("--system-prompt", this.config.agent.system_prompt);
238
- let appendPrompt = "";
239
- if (memoryPrompt)
240
- appendPrompt += `User memories:\n${memoryPrompt}\n\n`;
241
- if (this.config.agent.skill?.enabled !== false) {
242
- appendPrompt += generateSkillDoc({ userId, chatId, platform, locale: this.config.locale || "en" });
243
- }
244
- if (appendPrompt)
245
- args.push("--append-system-prompt", appendPrompt.trim());
246
- if (this.config.agent.allowed_tools?.length)
247
- args.push("--allowed-tools", this.config.agent.allowed_tools.join(","));
248
- if (this.config.agent.max_turns)
249
- args.push("--max-turns", String(this.config.agent.max_turns));
250
- if (this.config.agent.max_budget_usd)
251
- args.push("--max-budget-usd", String(this.config.agent.max_budget_usd));
252
- const env = { ...process.env };
253
- if (ep.api_key)
254
- env.ANTHROPIC_API_KEY = ep.api_key;
255
- if (ep.base_url)
256
- env.ANTHROPIC_BASE_URL = ep.base_url;
257
- env.CLAUDEBRIDGE_DB = this.store.dbPath;
258
- const child = spawn("claude", args, { cwd, env, stdio: ["pipe", "pipe", "pipe"] });
259
- child.stdin.end();
260
- console.log(`[agent] spawned claude (parallel) pid=${child.pid} cwd=${cwd}`);
261
- const timeoutMs = overrideTimeoutMs !== undefined ? overrideTimeoutMs : (this.config.agent.timeout_seconds || 600) * 1000;
262
- const timer = timeoutMs > 0 ? setTimeout(() => { try {
263
- child.kill("SIGTERM");
264
- }
265
- catch { } }, timeoutMs) : null;
266
- let fullText = "";
267
- let sessionId = "";
268
- let cost = 0;
269
- let buffer = "";
270
- child.stdout.on("data", (data) => {
271
- buffer += data.toString();
272
- const lines = buffer.split("\n");
273
- buffer = lines.pop() || "";
274
- for (const line of lines) {
275
- if (!line.trim())
276
- continue;
277
- try {
278
- const msg = JSON.parse(line);
279
- if (msg.type === "system" && msg.subtype === "init" && msg.session_id) {
280
- sessionId = msg.session_id;
281
- }
282
- if (msg.type === "assistant" && msg.message?.content) {
283
- for (const block of msg.message.content) {
284
- if (block.type === "text" && block.text) {
285
- fullText += block.text + "\n";
286
- if (onChunk)
287
- onChunk(block.text, fullText);
288
- }
289
- }
290
- }
291
- if (msg.type === "result") {
292
- if (msg.result)
293
- fullText = msg.result;
294
- if (msg.total_cost_usd)
295
- cost = msg.total_cost_usd;
296
- }
297
- }
298
- catch { }
299
- }
300
- });
301
- let stderr = "";
302
- child.stderr.on("data", (data) => { stderr += data.toString(); });
303
- child.on("close", (code, signal) => {
304
- if (timer)
305
- clearTimeout(timer);
306
- console.log(`[agent] claude (parallel) exited code=${code} signal=${signal} text=${fullText.length}chars`);
307
- if (signal === "SIGTERM") {
308
- console.warn(`[agent] claude (parallel) timed out after ${timeoutMs / 1000}s`);
309
- resolve({ text: fullText.trim() || "(timed out)", sessionId, cost, timedOut: true });
310
- }
311
- else if (code === 0 || fullText.trim()) {
312
- resolve({ text: fullText.trim() || "(no response)", sessionId, cost });
313
- }
314
- else {
315
- reject(new Error(`claude exited ${code}: ${stderr.slice(0, 500)}`));
316
- }
278
+ // ─── Legacy Single-Session Entry Points ──────────────────────
279
+ /** @deprecated Use handleUserMessage() for multi-session mode */
280
+ async runStream(userId, prompt, platform, chatId, onChunk, overrideTimeoutMs) {
281
+ const release = await this.lock.acquire(userId);
282
+ try {
283
+ this.store.addHistory(userId, platform, "user", prompt);
284
+ const memories = this.config.agent.memory?.enabled ? this.store.getMemories(userId) : [];
285
+ const memoryPrompt = memories.length ? memories.map(m => `- ${m.content}`).join("\n") : "";
286
+ const res = await this._executeWithRetry(userId, prompt, platform, chatId, onChunk, memoryPrompt, overrideTimeoutMs);
287
+ this.store.addHistory(userId, platform, "assistant", res.text);
288
+ this.store.recordUsage(userId, platform, res.cost || 0);
289
+ if (this.config.agent.memory?.auto_summary)
290
+ this._autoSummarize(userId, prompt, res.text);
291
+ return res;
292
+ }
293
+ finally {
294
+ release();
295
+ }
296
+ }
297
+ async runParallel(userId, prompt, platform, chatId, onChunk, overrideTimeoutMs) {
298
+ // No per-user lock — parallel tasks are independent
299
+ // No session resume — fresh session to prevent conflicts
300
+ const memories = this.config.agent.memory?.enabled ? this.store.getMemories(userId) : [];
301
+ const memoryPrompt = memories.length ? memories.map(m => `- ${m.content}`).join("\n") : "";
302
+ const ep = this.rotator.count
303
+ ? this.rotator.next()
304
+ : { name: "default", provider: "claude", model: "" };
305
+ const res = await this._executeNoSession(userId, prompt, platform, chatId, ep, onChunk, memoryPrompt, overrideTimeoutMs);
306
+ this.store.recordUsage(userId, platform, res.cost || 0);
307
+ return res;
308
+ }
309
+ async _executeWithRetry(userId, prompt, platform, chatId, onChunk, memoryPrompt, overrideTimeoutMs) {
310
+ const ep = this.rotator.count
311
+ ? this.rotator.next()
312
+ : { name: "default", provider: "claude", model: "" };
313
+ return await this._execute(userId, prompt, platform, chatId, ep, onChunk, memoryPrompt, overrideTimeoutMs);
314
+ }
315
+ /** Legacy single-session execution. Thin wrapper around _spawnAgent with store persistence. */
316
+ async _execute(userId, prompt, platform, chatId, ep, onChunk, memoryPrompt, overrideTimeoutMs) {
317
+ const resumeSessionId = this.store.getSession(userId) || undefined;
318
+ try {
319
+ const res = await this._spawnAgent({
320
+ userId, prompt, platform, chatId, ep, onChunk, memoryPrompt, overrideTimeoutMs,
321
+ resumeSessionId, logLabel: `legacy user=${userId}`,
317
322
  });
318
- child.on("error", reject);
323
+ if (res.sessionId)
324
+ this.store.setSession(userId, res.sessionId, platform);
325
+ return res;
326
+ }
327
+ catch (err) {
328
+ this.store.clearSession(userId);
329
+ throw err;
330
+ }
331
+ }
332
+ /** Parallel execution without session resume. Thin wrapper around _spawnAgent. */
333
+ _executeNoSession(userId, prompt, platform, chatId, ep, onChunk, memoryPrompt, overrideTimeoutMs) {
334
+ return this._spawnAgent({
335
+ userId, prompt, platform, chatId, ep, onChunk, memoryPrompt, overrideTimeoutMs,
336
+ logLabel: "parallel", verbose: false,
319
337
  });
320
338
  }
321
339
  _autoSummarize(userId, prompt, response) {
322
340
  const ep = this.rotator.count
323
341
  ? this.rotator.next()
324
- : { name: "cli-default", api_key: "", base_url: "", model: "" };
342
+ : { name: "default", provider: "claude", model: "" };
325
343
  const env = { ...process.env };
326
- if (ep.api_key)
327
- env.ANTHROPIC_API_KEY = ep.api_key;
328
- if (ep.base_url)
329
- env.ANTHROPIC_BASE_URL = ep.base_url;
330
344
  const summaryPrompt = `Extract 1-3 key facts worth remembering about the user from this exchange. Output only bullet points, no preamble. If nothing worth remembering, output "NONE".\n\nUser: ${prompt.slice(0, 500)}\nAssistant: ${response.slice(0, 1000)}`;
331
345
  const args = ["-p", summaryPrompt, "--verbose", "--output-format", "stream-json", "--max-turns", "1", "--max-budget-usd", "0.05"];
332
346
  if (ep.model)
@@ -337,7 +351,7 @@ export class AgentEngine {
337
351
  child.kill("SIGTERM");
338
352
  }
339
353
  catch { } }, 60000);
340
- console.log(`[agent] auto-summary spawned pid=${child.pid} for ${userId}`);
354
+ log.info("auto-summary spawned", { pid: child.pid, userId });
341
355
  let result = "";
342
356
  let cost = 0;
343
357
  let buffer = "";
@@ -365,24 +379,24 @@ export class AgentEngine {
365
379
  child.on("close", (code) => {
366
380
  clearTimeout(killTimer);
367
381
  if (code !== 0) {
368
- console.warn(`[agent] auto-summary failed code=${code} stderr=${stderr.slice(0, 200)} for ${userId}`);
382
+ log.warn("auto-summary failed", { code, stderr: stderr.slice(0, 200), userId });
369
383
  }
370
384
  if (cost > 0) {
371
385
  this.store.recordUsage(userId, "auto-summary", cost);
372
- console.log(`[agent] auto-summary cost=$${cost.toFixed(4)} for ${userId}`);
386
+ log.info("auto-summary cost", { cost: cost.toFixed(4), userId });
373
387
  }
374
388
  if (result && !result.includes("NONE")) {
375
389
  const saved = this.store.addMemory(userId, result.trim(), "auto");
376
390
  this.store.trimMemories(userId, this.config.agent.memory?.max_memories || 50);
377
391
  if (saved)
378
- console.log(`[agent] auto-summary saved for ${userId}`);
392
+ log.info("auto-summary saved", { userId });
379
393
  else
380
- console.log(`[agent] auto-summary skipped (duplicate) for ${userId}`);
394
+ log.info("auto-summary skipped (duplicate)", { userId });
381
395
  }
382
396
  else {
383
- console.log(`[agent] auto-summary result=NONE for ${userId}`);
397
+ log.info("auto-summary result=NONE", { userId });
384
398
  }
385
399
  });
386
- child.on("error", (err) => { console.warn(`[agent] auto-summary spawn error: ${err.message}`); });
400
+ child.on("error", (err) => { log.warn("auto-summary spawn error", { error: err.message }); });
387
401
  }
388
402
  }