niahere 0.3.12 → 0.4.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.
@@ -1,104 +1,22 @@
1
- import { query, type Query } from "@anthropic-ai/claude-agent-sdk";
2
- // @ts-ignore — SDK re-exports this type but tsc can't resolve the path under Bun
3
- import type { MessageParam } from "@anthropic-ai/sdk/resources";
4
1
  import { existsSync } from "fs";
5
- import { join } from "path";
6
2
  import { homedir } from "os";
7
- import { randomUUID } from "crypto";
8
3
  import { buildSystemPrompt, buildContextSuffix, getSessionContext } from "./identity";
9
4
  import { buildEmployeePrompt } from "./employee-prompt";
10
5
  import { getEmployee } from "../core/employees";
11
6
  import { getAgentDefinitions, scanAgents } from "../core/agents";
12
7
  import { Session, Message, ActiveEngine, Job } from "../db/models";
13
- import type {
14
- Attachment,
15
- SendResult,
16
- StreamCallback,
17
- ActivityCallback,
18
- SendCallbacks,
19
- ChatEngine,
20
- EngineOptions,
21
- } from "../types";
22
- import { truncate, formatToolUse } from "../utils/format-activity";
8
+ import type { Attachment, SendResult, SendCallbacks, ChatEngine, EngineOptions } from "../types";
23
9
  import { finalizeSession, cancelPending } from "../core/finalizer";
24
10
  import { log } from "../utils/log";
25
- import { getConfig } from "../utils/config";
26
- import { isRetryableApiError, sleep } from "../utils/retry";
27
11
  import { registerActiveHandle, unregisterActiveHandle } from "../core/active-handles";
28
12
  import { resolveJobPrompt } from "../core/job-prompt";
29
- import { getSdkSkillsSetting } from "../core/skills";
30
- import { getSdkHooks } from "../core/sdk-hooks";
13
+ import { resolveBackends, type AgentSession } from "../agent";
31
14
 
32
15
  const IDLE_TIMEOUT = 10 * 60 * 1000; // 10 minutes
33
16
  const LONG_RUNNING_WARN = 30 * 60 * 1000; // 30 minutes
34
- const MAX_SEND_RETRIES = 2;
35
- const SEND_RETRY_DELAYS = [3_000, 8_000];
36
17
  const GENERIC_CHAT_ERROR = "💀";
37
18
 
38
- interface SDKUserMessage {
39
- type: "user";
40
- message: MessageParam;
41
- parent_tool_use_id: null;
42
- session_id: string;
43
- }
44
-
45
- /** Convert provider-agnostic attachments to Anthropic content blocks. */
46
- export function buildContentBlocks(text: string, attachments?: Attachment[]): MessageParam["content"] {
47
- if (!attachments?.length) return text;
48
-
49
- const blocks: Array<
50
- | { type: "text"; text: string }
51
- | {
52
- type: "image";
53
- source: { type: "base64"; media_type: string; data: string };
54
- }
55
- > = [];
56
-
57
- const pathHints = attachments
58
- .map((att, idx) => {
59
- if (!att.sourcePath) return "";
60
- const label = att.filename || `${att.type}-${idx + 1}`;
61
- return `- ${idx + 1}. ${label} (${att.type}, ${att.mimeType}) -> ${att.sourcePath}`;
62
- })
63
- .filter(Boolean);
64
-
65
- if (pathHints.length > 0) {
66
- blocks.push({
67
- type: "text",
68
- text:
69
- "[Attachment local paths]\n" +
70
- "Use these absolute paths to inspect attachments. To resend/forward one, call send_message with media_path set to its path.\n" +
71
- pathHints.join("\n"),
72
- });
73
- }
74
-
75
- for (const att of attachments) {
76
- if (att.sourcePath) continue;
77
-
78
- if (att.type === "image") {
79
- blocks.push({
80
- type: "image",
81
- source: {
82
- type: "base64",
83
- media_type: att.mimeType,
84
- data: att.data.toString("base64"),
85
- },
86
- });
87
- } else if (att.type === "document") {
88
- const docText = att.data.toString("utf8");
89
- const label = att.filename ? `[${att.filename}]` : "[document]";
90
- blocks.push({ type: "text", text: `${label}\n${docText}` });
91
- }
92
- }
93
-
94
- if (text) {
95
- blocks.push({ type: "text", text });
96
- }
97
-
98
- return blocks as MessageParam["content"];
99
- }
100
-
101
- /** Convert SDK error text into a channel-safe chat response. */
19
+ /** Convert backend error text into a channel-safe chat response. */
102
20
  export function formatChatError(rawError: string | null | undefined): string {
103
21
  const error = rawError?.trim();
104
22
  if (getChatErrorSignal(error) === "provider_down") {
@@ -115,68 +33,6 @@ export function getChatErrorSignal(rawError: string | null | undefined): SendRes
115
33
  return !error || error.toLowerCase() === "unknown error" ? "provider_down" : undefined;
116
34
  }
117
35
 
118
- export function resolveSdkModel(contextModel?: string | null): string | undefined {
119
- const model = contextModel || getConfig().model;
120
- return model && model !== "default" ? model : undefined;
121
- }
122
-
123
- /**
124
- * Push-based async iterable for streaming user messages to the SDK.
125
- * Keeps the query subprocess alive between messages.
126
- */
127
- class MessageStream {
128
- private queue: SDKUserMessage[] = [];
129
- private waiting: (() => void) | null = null;
130
- private done = false;
131
-
132
- push(text: string, attachments?: Attachment[]): void {
133
- this.queue.push({
134
- type: "user",
135
- message: { role: "user", content: buildContentBlocks(text, attachments) },
136
- parent_tool_use_id: null,
137
- session_id: "",
138
- });
139
- this.waiting?.();
140
- }
141
-
142
- end(): void {
143
- this.done = true;
144
- this.waiting?.();
145
- }
146
-
147
- async *[Symbol.asyncIterator](): AsyncGenerator<SDKUserMessage> {
148
- while (true) {
149
- while (this.queue.length > 0) {
150
- yield this.queue.shift()!;
151
- }
152
- if (this.done) return;
153
- await new Promise<void>((r) => {
154
- this.waiting = r;
155
- });
156
- this.waiting = null;
157
- }
158
- }
159
- }
160
-
161
- interface PendingResult {
162
- userMessage: string;
163
- userSaved: boolean;
164
- onStream: StreamCallback | null;
165
- onActivity: ActivityCallback | null;
166
- accumulatedText: string;
167
- accumulatedThinking: string;
168
- lastThinkingLine: string;
169
- resolve: (value: SendResult) => void;
170
- reject: (error: Error) => void;
171
- }
172
-
173
- function sessionFileExists(sessionId: string, cwd: string): boolean {
174
- // SDK stores sessions at ~/.claude/projects/<encoded-cwd>/<session-id>.jsonl
175
- const encoded = cwd.replace(/\//g, "-");
176
- const sessionFile = join(homedir(), ".claude", "projects", encoded, `${sessionId}.jsonl`);
177
- return existsSync(sessionFile);
178
- }
179
-
180
36
  export async function createChatEngine(opts: EngineOptions): Promise<ChatEngine> {
181
37
  const { room, channel, resume, mcpServers } = opts;
182
38
  let systemPrompt = buildSystemPrompt("chat", channel);
@@ -236,6 +92,12 @@ export async function createChatEngine(opts: EngineOptions): Promise<ChatEngine>
236
92
  systemPrompt += `\n\n## Watch Mode — #${watchChannel}\n\nYou are monitoring this Slack channel. Follow the behavior instructions below.\nRespond with [NO_REPLY] if no action is needed — do not explain why.\n\n${behavior}`;
237
93
  }
238
94
 
95
+ // The backend chain: configured primary first, then provider-down fallbacks.
96
+ // Chat normally runs on the primary; a provider-down turn fails over to the
97
+ // next backend (answering the current message — see send()).
98
+ const backends = resolveBackends();
99
+ let backendIndex = 0;
100
+
239
101
  let sessionId: string | null = null;
240
102
  if (typeof resume === "string") {
241
103
  // Specific session ID provided
@@ -244,19 +106,17 @@ export async function createChatEngine(opts: EngineOptions): Promise<ChatEngine>
244
106
  sessionId = await Session.getLatest(room);
245
107
  }
246
108
 
247
- // Verify session file exists on disk before attempting resume
248
- if (sessionId && !sessionFileExists(sessionId, cwd)) {
109
+ // Verify the primary backend can actually resume this session before
110
+ // attempting it (Claude probes the on-disk jsonl; others use their own check).
111
+ if (sessionId && !(await backends[0]!.canResume(sessionId, cwd))) {
249
112
  sessionId = null;
250
113
  }
251
- let stream: MessageStream | null = null;
252
- let queryHandle: Query | null = null;
253
- let pending: PendingResult | null = null;
114
+
115
+ let session: AgentSession | null = null;
254
116
  let idleTimer: ReturnType<typeof setTimeout> | null = null;
255
117
  let longRunningTimer: ReturnType<typeof setTimeout> | null = null;
256
- let longRunningWarned = false;
257
- let alive = false;
258
118
  let messageCount = 0;
259
- let retryCount = 0;
119
+ let inFlight = false;
260
120
 
261
121
  function clearIdleTimer() {
262
122
  if (idleTimer) {
@@ -268,9 +128,9 @@ export async function createChatEngine(opts: EngineOptions): Promise<ChatEngine>
268
128
  function resetIdleTimer() {
269
129
  clearIdleTimer();
270
130
  idleTimer = setTimeout(async () => {
271
- if (pending) {
131
+ if (inFlight) {
272
132
  // Don't tear down while a request is in flight
273
- log.warn({ room }, "idle timer fired while request pending, skipping teardown");
133
+ log.warn({ room }, "idle timer fired while request in flight, skipping teardown");
274
134
  return;
275
135
  }
276
136
  // Enqueue finalization before "sleep"
@@ -279,7 +139,7 @@ export async function createChatEngine(opts: EngineOptions): Promise<ChatEngine>
279
139
  log.error({ err, room }, "finalization enqueue failed during idle teardown");
280
140
  });
281
141
  }
282
- teardown();
142
+ await teardown();
283
143
  }, IDLE_TIMEOUT);
284
144
  }
285
145
 
@@ -288,316 +148,45 @@ export async function createChatEngine(opts: EngineOptions): Promise<ChatEngine>
288
148
  clearTimeout(longRunningTimer);
289
149
  longRunningTimer = null;
290
150
  }
291
- longRunningWarned = false;
292
151
  }
293
152
 
294
153
  function startLongRunningTimer() {
295
154
  clearLongRunningTimer();
296
155
  longRunningTimer = setTimeout(() => {
297
- if (pending) {
298
- longRunningWarned = true;
299
- log.warn({ room, elapsed: LONG_RUNNING_WARN / 1000 }, "engine request running for 30+ minutes");
300
- }
156
+ log.warn({ room, elapsed: LONG_RUNNING_WARN / 1000 }, "engine request running for 30+ minutes");
301
157
  }, LONG_RUNNING_WARN);
302
158
  }
303
159
 
304
- function teardown() {
160
+ async function teardown() {
305
161
  clearIdleTimer();
306
162
  clearLongRunningTimer();
307
- if (stream) {
308
- stream.end();
309
- stream = null;
310
- }
311
- if (queryHandle) {
312
- queryHandle.close();
313
- queryHandle = null;
163
+ if (session) {
164
+ await session.close().catch(() => {});
165
+ session = null;
314
166
  }
315
167
  unregisterActiveHandle(room);
316
- alive = false;
317
- }
318
-
319
- async function abortActiveQuery(reason: string) {
320
- const activePending = pending;
321
- pending = null;
322
- if (activePending) {
323
- activePending.reject(new Error(reason));
324
- }
325
- teardown();
326
- await ActiveEngine.unregister(room).catch(() => {});
327
168
  }
328
169
 
329
- function startQuery() {
330
- stream = new MessageStream();
331
- alive = true;
332
-
333
- const options: Record<string, unknown> = {
170
+ /** Lazily open (and reuse) the current backend's session for this engine. */
171
+ async function ensureSession(): Promise<AgentSession> {
172
+ if (session) return session;
173
+ const backend = backends[backendIndex] ?? backends[0]!;
174
+ const s = await backend.openSession({
175
+ room,
176
+ channel,
334
177
  systemPrompt,
335
178
  cwd,
336
- permissionMode: "bypassPermissions",
337
- includePartialMessages: true,
338
- settingSources: ["project", "user"],
339
- skills: getSdkSkillsSetting(),
340
- hooks: getSdkHooks(),
341
- };
342
- const model = resolveSdkModel(contextModel);
343
- if (model) {
344
- options.model = model;
345
- }
346
-
347
- if (sessionId) {
348
- options.resume = sessionId;
349
- } else {
350
- // Force a brand-new session with a unique ID so the claude subprocess
351
- // cannot auto-continue a prior session in the same CWD ($HOME).
352
- options.continue = false;
353
- options.sessionId = randomUUID();
354
- }
355
-
356
- if (mcpServers) {
357
- options.mcpServers = mcpServers;
358
- }
359
-
360
- const agentDefs = getAgentDefinitions();
361
- if (Object.keys(agentDefs).length > 0) {
362
- options.agents = agentDefs;
363
- }
364
-
365
- queryHandle = query({
366
- prompt: stream as any,
367
- options: options as any,
179
+ model: contextModel ?? undefined,
180
+ mcpServers,
181
+ resume: sessionId ?? false,
182
+ subagents: getAgentDefinitions(),
183
+ interactive: true,
368
184
  });
369
- registerActiveHandle(room, abortActiveQuery);
370
-
371
- // Background consumer — runs for the lifetime of the query
372
- (async () => {
373
- try {
374
- for await (const message of queryHandle!) {
375
- if (message.type === "system" && message.subtype === "init") {
376
- const newId = message.session_id;
377
- if (!sessionId || newId !== sessionId) {
378
- sessionId = newId;
379
- await Session.create(sessionId, room);
380
- }
381
-
382
- if (pending && !pending.userSaved) {
383
- await Message.save({
384
- sessionId,
385
- room,
386
- sender: "user",
387
- content: pending.userMessage,
388
- isFromAgent: false,
389
- });
390
- pending.userSaved = true;
391
- messageCount++;
392
- }
393
- }
394
-
395
- // Stream events: text deltas, thinking deltas, block lifecycle
396
- if (message.type === "stream_event" && pending) {
397
- const event = (message as any).event;
398
-
399
- if (event?.type === "content_block_delta") {
400
- const delta = event.delta;
401
- if (delta?.type === "text_delta" && delta.text) {
402
- pending.accumulatedText += delta.text;
403
- pending.onStream?.(pending.accumulatedText);
404
- }
405
- if (delta?.type === "thinking_delta" && delta.thinking) {
406
- pending.accumulatedThinking += delta.thinking;
407
- // Only update on complete lines (newline boundary)
408
- const lines = pending.accumulatedThinking.split("\n");
409
- if (lines.length > 1) {
410
- // Show the last complete line (not the partial one being typed)
411
- const completeLine = lines[lines.length - 2]?.trim();
412
- if (completeLine && completeLine !== pending.lastThinkingLine) {
413
- pending.lastThinkingLine = completeLine;
414
- pending.onActivity?.(truncate(completeLine, 70));
415
- }
416
- }
417
- }
418
- }
419
-
420
- if (event?.type === "content_block_start") {
421
- const block = event.content_block;
422
- if (block?.type === "thinking") {
423
- pending.accumulatedThinking = "";
424
- pending.lastThinkingLine = "";
425
- pending.onActivity?.("thinking...");
426
- }
427
- // tool_use: don't show here — wait for tool_use_summary with full input
428
- }
429
-
430
- if (event?.type === "content_block_stop") {
431
- pending.accumulatedThinking = "";
432
- pending.lastThinkingLine = "";
433
- }
434
- }
435
-
436
- if (message.type === "tool_use_summary" && pending) {
437
- const msg = message as any;
438
- const name = msg.tool_name || "tool";
439
- pending.onActivity?.(formatToolUse(name, msg.tool_input));
440
- }
441
-
442
- if (message.type === "tool_progress" && pending) {
443
- const msg = message as any;
444
- const toolName = msg.tool_name;
445
- const content = msg.content;
446
- if (toolName === "Bash" && content) {
447
- pending.onActivity?.(`$ ${truncate(content, 60)}`);
448
- } else if (content) {
449
- pending.onActivity?.(truncate(content, 70));
450
- }
451
- }
452
-
453
- // Task/agent lifecycle
454
- if (message.type === "system" && pending) {
455
- const msg = message as any;
456
- if (msg.subtype === "task_started" && msg.description) {
457
- pending.onActivity?.(truncate(msg.description, 60));
458
- }
459
- if (msg.subtype === "task_progress" && msg.last_tool_name) {
460
- pending.onActivity?.(msg.summary || msg.last_tool_name);
461
- }
462
- }
463
-
464
- if (message.type === "result" && pending) {
465
- const msg = message as any;
466
- if (!message.is_error) {
467
- const resultText = msg.result as string;
468
- const costUsd = msg.total_cost_usd as number;
469
- const turns = msg.num_turns as number;
470
-
471
- const metadata: Record<string, unknown> = {
472
- cost_usd: costUsd,
473
- turns,
474
- duration_ms: msg.duration_ms,
475
- duration_api_ms: msg.duration_api_ms,
476
- stop_reason: msg.stop_reason,
477
- terminal_reason: msg.terminal_reason,
478
- session_id: msg.session_id,
479
- subtype: msg.subtype,
480
- usage: msg.usage,
481
- model_usage: msg.modelUsage,
482
- };
483
-
484
- let messageId: number | undefined;
485
- if (sessionId && resultText) {
486
- const saveParams = {
487
- sessionId,
488
- room,
489
- sender: "nia",
490
- content: resultText,
491
- isFromAgent: true,
492
- deliveryStatus: "pending" as const,
493
- metadata,
494
- };
495
- try {
496
- messageId = await Message.save(saveParams);
497
- } catch {
498
- messageId = await Message.save({
499
- ...saveParams,
500
- metadata: undefined,
501
- });
502
- }
503
- await Session.touch(sessionId);
504
- Session.accumulateMetadata(sessionId, {
505
- ...metadata,
506
- channel,
507
- }).catch(() => {});
508
- }
509
-
510
- await ActiveEngine.unregister(room);
511
- clearLongRunningTimer();
512
- retryCount = 0;
513
- pending.resolve({
514
- result: resultText,
515
- costUsd,
516
- turns,
517
- messageId,
518
- });
519
- pending = null;
520
- resetIdleTimer();
521
- } else {
522
- const errors = msg.errors;
523
- const rawError = errors?.join(", ") || "unknown error";
524
-
525
- // Retry on transient API errors (500, overloaded, rate-limit)
526
- if (retryCount < MAX_SEND_RETRIES && isRetryableApiError(rawError)) {
527
- const delay = SEND_RETRY_DELAYS[retryCount] ?? 8_000;
528
- retryCount++;
529
- log.warn(
530
- { room, attempt: retryCount, error: rawError, delayMs: delay },
531
- "retrying chat send after transient API error",
532
- );
533
- const retryPending = pending;
534
- pending = null;
535
- clearLongRunningTimer();
536
-
537
- // Tear down current query and restart after delay
538
- teardown();
539
- await sleep(delay);
540
- startQuery();
541
-
542
- // Re-send: the user message is already saved in DB, so mark it saved
543
- pending = {
544
- ...retryPending,
545
- userSaved: true,
546
- accumulatedText: "",
547
- accumulatedThinking: "",
548
- lastThinkingLine: "",
549
- };
550
- retryPending.onActivity?.("retrying after API error...");
551
- stream!.push(retryPending.userMessage);
552
- } else {
553
- const errorText = formatChatError(rawError);
554
- log.error(
555
- {
556
- room,
557
- error: rawError,
558
- errors,
559
- subtype: msg.subtype,
560
- terminal_reason: msg.terminal_reason,
561
- session_id: msg.session_id,
562
- },
563
- "chat send failed with SDK result error",
564
- );
565
- await ActiveEngine.unregister(room);
566
- clearLongRunningTimer();
567
- pending.resolve({ result: errorText, costUsd: 0, turns: 0, signal: getChatErrorSignal(rawError) });
568
- pending = null;
569
- retryCount = 0;
570
- resetIdleTimer();
571
- }
572
- }
573
- }
574
- }
575
-
576
- // Stream ended without a result — subprocess exited or was killed
577
- if (pending) {
578
- const partial = pending.accumulatedText;
579
- log.error(
580
- { room, partialChars: partial.length },
581
- "query stream ended without result, rejecting pending request",
582
- );
583
- await ActiveEngine.unregister(room).catch(() => {});
584
- pending.reject(new Error(`stream ended without result (${partial.length} chars accumulated)`));
585
- pending = null;
586
- }
587
- } catch (err) {
588
- if (pending) {
589
- await ActiveEngine.unregister(room).catch(() => {});
590
- pending.reject(err instanceof Error ? err : new Error(String(err)));
591
- pending = null;
592
- }
593
- } finally {
594
- clearLongRunningTimer();
595
- unregisterActiveHandle(room);
596
- alive = false;
597
- stream = null;
598
- queryHandle = null;
599
- }
600
- })();
185
+ registerActiveHandle(room, (reason) => {
186
+ s.abort(reason);
187
+ });
188
+ session = s;
189
+ return s;
601
190
  }
602
191
 
603
192
  return {
@@ -613,6 +202,7 @@ export async function createChatEngine(opts: EngineOptions): Promise<ChatEngine>
613
202
  // Clear idle timer — engine is not idle while processing a request
614
203
  clearIdleTimer();
615
204
  startLongRunningTimer();
205
+ inFlight = true;
616
206
 
617
207
  // Cancel any pending finalization — session is active again
618
208
  if (sessionId) {
@@ -621,52 +211,130 @@ export async function createChatEngine(opts: EngineOptions): Promise<ChatEngine>
621
211
 
622
212
  await ActiveEngine.register(room, channel);
623
213
 
624
- if (!alive || !stream) {
625
- startQuery();
626
- }
627
-
628
- // Save user message to DB if session already exists (resumed session).
629
- // For new sessions, the init handler saves it once sessionId is known.
214
+ // Save the user message eagerly for an already-known (resumed) session;
215
+ // for a brand-new session we save it once on the `session` event below.
630
216
  let userSaved = false;
631
217
  if (sessionId) {
632
- await Message.save({
633
- sessionId,
634
- room,
635
- sender: "user",
636
- content: userMessage,
637
- isFromAgent: false,
638
- });
218
+ await Message.save({ sessionId, room, sender: "user", content: userMessage, isFromAgent: false });
639
219
  await Session.touch(sessionId);
640
220
  userSaved = true;
641
221
  messageCount++;
642
222
  }
643
223
 
644
- return new Promise<SendResult>((resolve, reject) => {
645
- pending = {
646
- userMessage,
647
- userSaved,
648
- onStream: callbacks?.onStream || null,
649
- onActivity: callbacks?.onActivity || null,
650
- accumulatedText: "",
651
- accumulatedThinking: "",
652
- lastThinkingLine: "",
653
- resolve,
654
- reject,
655
- };
656
- stream!.push(userMessage, attachments);
657
- });
224
+ let result: SendResult = { result: "", costUsd: 0, turns: 0 };
225
+
226
+ // Run the turn on the current backend; on a provider-down result, fail over
227
+ // to the next backend and answer the current message there.
228
+ while (true) {
229
+ const sess = await ensureSession();
230
+ let accumulated = "";
231
+ let providerDown = false;
232
+
233
+ try {
234
+ for await (const ev of sess.send(userMessage, attachments)) {
235
+ switch (ev.type) {
236
+ case "session": {
237
+ if (!sessionId || ev.backendSessionId !== sessionId) {
238
+ sessionId = ev.backendSessionId;
239
+ await Session.create(sessionId, room);
240
+ }
241
+ if (!userSaved) {
242
+ await Message.save({ sessionId, room, sender: "user", content: userMessage, isFromAgent: false });
243
+ userSaved = true;
244
+ messageCount++;
245
+ }
246
+ break;
247
+ }
248
+ case "text":
249
+ accumulated += ev.delta;
250
+ callbacks?.onStream?.(accumulated);
251
+ break;
252
+ case "thinking":
253
+ callbacks?.onActivity?.(ev.delta);
254
+ break;
255
+ case "tool":
256
+ callbacks?.onActivity?.(ev.summary ?? ev.name);
257
+ break;
258
+ case "result": {
259
+ const costUsd = ev.usage.costUsd ?? 0;
260
+ const turns = ev.usage.turns ?? 0;
261
+ let messageId: number | undefined;
262
+ if (sessionId && ev.text) {
263
+ const saveParams = {
264
+ sessionId,
265
+ room,
266
+ sender: "nia",
267
+ content: ev.text,
268
+ isFromAgent: true,
269
+ deliveryStatus: "pending" as const,
270
+ metadata: ev.metadata,
271
+ };
272
+ try {
273
+ messageId = await Message.save(saveParams);
274
+ } catch {
275
+ messageId = await Message.save({ ...saveParams, metadata: undefined });
276
+ }
277
+ await Session.touch(sessionId);
278
+ Session.accumulateMetadata(sessionId, { ...(ev.metadata ?? {}), channel }).catch(() => {});
279
+ }
280
+ result = { result: ev.text, costUsd, turns, messageId };
281
+ break;
282
+ }
283
+ case "error": {
284
+ providerDown = ev.providerDown;
285
+ log.error(
286
+ { room, error: ev.message, terminal_reason: ev.terminalReason },
287
+ "chat send failed with backend error",
288
+ );
289
+ result = {
290
+ result: formatChatError(ev.message),
291
+ costUsd: 0,
292
+ turns: 0,
293
+ signal: ev.providerDown ? "provider_down" : undefined,
294
+ };
295
+ break;
296
+ }
297
+ }
298
+ }
299
+ } catch (err) {
300
+ await ActiveEngine.unregister(room).catch(() => {});
301
+ clearLongRunningTimer();
302
+ inFlight = false;
303
+ if (sess.backendSessionId) sessionId = sess.backendSessionId;
304
+ throw err instanceof Error ? err : new Error(String(err));
305
+ }
306
+
307
+ // Re-read the backend session id post-send so finalize/DB target it.
308
+ if (sess.backendSessionId) sessionId = sess.backendSessionId;
309
+
310
+ if (providerDown && backendIndex < backends.length - 1) {
311
+ backendIndex++;
312
+ log.warn({ room, to: backends[backendIndex]!.name }, "chat provider down, failing over to next backend");
313
+ await teardown(); // close the dead session so ensureSession opens the next backend
314
+ sessionId = null; // a cross-backend session id is meaningless; start fresh
315
+ continue;
316
+ }
317
+ break;
318
+ }
319
+
320
+ await ActiveEngine.unregister(room);
321
+ clearLongRunningTimer();
322
+ inFlight = false;
323
+ resetIdleTimer();
324
+ return result;
658
325
  },
659
326
 
660
327
  async close() {
661
328
  // Enqueue finalization — processed by daemon or inline if we are the daemon
662
- if (sessionId && messageCount > 0 && !pending) {
329
+ if (sessionId && messageCount > 0 && !inFlight) {
663
330
  try {
664
331
  await finalizeSession(sessionId, room);
665
332
  } catch (err) {
666
333
  log.error({ err, room }, "finalization enqueue failed during close");
667
334
  }
668
335
  }
669
- await abortActiveQuery("chat engine closed");
336
+ await teardown();
337
+ await ActiveEngine.unregister(room).catch(() => {});
670
338
  },
671
339
  };
672
340
  }