niahere 0.3.11 → 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,107 +1,25 @@
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
- if (!error || error.toLowerCase() === "unknown error") {
22
+ if (getChatErrorSignal(error) === "provider_down") {
105
23
  return GENERIC_CHAT_ERROR;
106
24
  }
107
25
  if (error === "oauth_org_not_allowed") {
@@ -110,66 +28,9 @@ export function formatChatError(rawError: string | null | undefined): string {
110
28
  return `[error] ${error}`;
111
29
  }
112
30
 
113
- export function resolveSdkModel(contextModel?: string | null): string | undefined {
114
- const model = contextModel || getConfig().model;
115
- return model && model !== "default" ? model : undefined;
116
- }
117
-
118
- /**
119
- * Push-based async iterable for streaming user messages to the SDK.
120
- * Keeps the query subprocess alive between messages.
121
- */
122
- class MessageStream {
123
- private queue: SDKUserMessage[] = [];
124
- private waiting: (() => void) | null = null;
125
- private done = false;
126
-
127
- push(text: string, attachments?: Attachment[]): void {
128
- this.queue.push({
129
- type: "user",
130
- message: { role: "user", content: buildContentBlocks(text, attachments) },
131
- parent_tool_use_id: null,
132
- session_id: "",
133
- });
134
- this.waiting?.();
135
- }
136
-
137
- end(): void {
138
- this.done = true;
139
- this.waiting?.();
140
- }
141
-
142
- async *[Symbol.asyncIterator](): AsyncGenerator<SDKUserMessage> {
143
- while (true) {
144
- while (this.queue.length > 0) {
145
- yield this.queue.shift()!;
146
- }
147
- if (this.done) return;
148
- await new Promise<void>((r) => {
149
- this.waiting = r;
150
- });
151
- this.waiting = null;
152
- }
153
- }
154
- }
155
-
156
- interface PendingResult {
157
- userMessage: string;
158
- userSaved: boolean;
159
- onStream: StreamCallback | null;
160
- onActivity: ActivityCallback | null;
161
- accumulatedText: string;
162
- accumulatedThinking: string;
163
- lastThinkingLine: string;
164
- resolve: (value: SendResult) => void;
165
- reject: (error: Error) => void;
166
- }
167
-
168
- function sessionFileExists(sessionId: string, cwd: string): boolean {
169
- // SDK stores sessions at ~/.claude/projects/<encoded-cwd>/<session-id>.jsonl
170
- const encoded = cwd.replace(/\//g, "-");
171
- const sessionFile = join(homedir(), ".claude", "projects", encoded, `${sessionId}.jsonl`);
172
- return existsSync(sessionFile);
31
+ export function getChatErrorSignal(rawError: string | null | undefined): SendResult["signal"] | undefined {
32
+ const error = rawError?.trim();
33
+ return !error || error.toLowerCase() === "unknown error" ? "provider_down" : undefined;
173
34
  }
174
35
 
175
36
  export async function createChatEngine(opts: EngineOptions): Promise<ChatEngine> {
@@ -231,6 +92,12 @@ export async function createChatEngine(opts: EngineOptions): Promise<ChatEngine>
231
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}`;
232
93
  }
233
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
+
234
101
  let sessionId: string | null = null;
235
102
  if (typeof resume === "string") {
236
103
  // Specific session ID provided
@@ -239,19 +106,17 @@ export async function createChatEngine(opts: EngineOptions): Promise<ChatEngine>
239
106
  sessionId = await Session.getLatest(room);
240
107
  }
241
108
 
242
- // Verify session file exists on disk before attempting resume
243
- 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))) {
244
112
  sessionId = null;
245
113
  }
246
- let stream: MessageStream | null = null;
247
- let queryHandle: Query | null = null;
248
- let pending: PendingResult | null = null;
114
+
115
+ let session: AgentSession | null = null;
249
116
  let idleTimer: ReturnType<typeof setTimeout> | null = null;
250
117
  let longRunningTimer: ReturnType<typeof setTimeout> | null = null;
251
- let longRunningWarned = false;
252
- let alive = false;
253
118
  let messageCount = 0;
254
- let retryCount = 0;
119
+ let inFlight = false;
255
120
 
256
121
  function clearIdleTimer() {
257
122
  if (idleTimer) {
@@ -263,9 +128,9 @@ export async function createChatEngine(opts: EngineOptions): Promise<ChatEngine>
263
128
  function resetIdleTimer() {
264
129
  clearIdleTimer();
265
130
  idleTimer = setTimeout(async () => {
266
- if (pending) {
131
+ if (inFlight) {
267
132
  // Don't tear down while a request is in flight
268
- log.warn({ room }, "idle timer fired while request pending, skipping teardown");
133
+ log.warn({ room }, "idle timer fired while request in flight, skipping teardown");
269
134
  return;
270
135
  }
271
136
  // Enqueue finalization before "sleep"
@@ -274,7 +139,7 @@ export async function createChatEngine(opts: EngineOptions): Promise<ChatEngine>
274
139
  log.error({ err, room }, "finalization enqueue failed during idle teardown");
275
140
  });
276
141
  }
277
- teardown();
142
+ await teardown();
278
143
  }, IDLE_TIMEOUT);
279
144
  }
280
145
 
@@ -283,316 +148,45 @@ export async function createChatEngine(opts: EngineOptions): Promise<ChatEngine>
283
148
  clearTimeout(longRunningTimer);
284
149
  longRunningTimer = null;
285
150
  }
286
- longRunningWarned = false;
287
151
  }
288
152
 
289
153
  function startLongRunningTimer() {
290
154
  clearLongRunningTimer();
291
155
  longRunningTimer = setTimeout(() => {
292
- if (pending) {
293
- longRunningWarned = true;
294
- log.warn({ room, elapsed: LONG_RUNNING_WARN / 1000 }, "engine request running for 30+ minutes");
295
- }
156
+ log.warn({ room, elapsed: LONG_RUNNING_WARN / 1000 }, "engine request running for 30+ minutes");
296
157
  }, LONG_RUNNING_WARN);
297
158
  }
298
159
 
299
- function teardown() {
160
+ async function teardown() {
300
161
  clearIdleTimer();
301
162
  clearLongRunningTimer();
302
- if (stream) {
303
- stream.end();
304
- stream = null;
305
- }
306
- if (queryHandle) {
307
- queryHandle.close();
308
- queryHandle = null;
163
+ if (session) {
164
+ await session.close().catch(() => {});
165
+ session = null;
309
166
  }
310
167
  unregisterActiveHandle(room);
311
- alive = false;
312
168
  }
313
169
 
314
- async function abortActiveQuery(reason: string) {
315
- const activePending = pending;
316
- pending = null;
317
- if (activePending) {
318
- activePending.reject(new Error(reason));
319
- }
320
- teardown();
321
- await ActiveEngine.unregister(room).catch(() => {});
322
- }
323
-
324
- function startQuery() {
325
- stream = new MessageStream();
326
- alive = true;
327
-
328
- 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,
329
177
  systemPrompt,
330
178
  cwd,
331
- permissionMode: "bypassPermissions",
332
- includePartialMessages: true,
333
- settingSources: ["project", "user"],
334
- skills: getSdkSkillsSetting(),
335
- hooks: getSdkHooks(),
336
- };
337
- const model = resolveSdkModel(contextModel);
338
- if (model) {
339
- options.model = model;
340
- }
341
-
342
- if (sessionId) {
343
- options.resume = sessionId;
344
- } else {
345
- // Force a brand-new session with a unique ID so the claude subprocess
346
- // cannot auto-continue a prior session in the same CWD ($HOME).
347
- options.continue = false;
348
- options.sessionId = randomUUID();
349
- }
350
-
351
- if (mcpServers) {
352
- options.mcpServers = mcpServers;
353
- }
354
-
355
- const agentDefs = getAgentDefinitions();
356
- if (Object.keys(agentDefs).length > 0) {
357
- options.agents = agentDefs;
358
- }
359
-
360
- queryHandle = query({
361
- prompt: stream as any,
362
- options: options as any,
179
+ model: contextModel ?? undefined,
180
+ mcpServers,
181
+ resume: sessionId ?? false,
182
+ subagents: getAgentDefinitions(),
183
+ interactive: true,
363
184
  });
364
- registerActiveHandle(room, abortActiveQuery);
365
-
366
- // Background consumer — runs for the lifetime of the query
367
- (async () => {
368
- try {
369
- for await (const message of queryHandle!) {
370
- if (message.type === "system" && message.subtype === "init") {
371
- const newId = message.session_id;
372
- if (!sessionId || newId !== sessionId) {
373
- sessionId = newId;
374
- await Session.create(sessionId, room);
375
- }
376
-
377
- if (pending && !pending.userSaved) {
378
- await Message.save({
379
- sessionId,
380
- room,
381
- sender: "user",
382
- content: pending.userMessage,
383
- isFromAgent: false,
384
- });
385
- pending.userSaved = true;
386
- messageCount++;
387
- }
388
- }
389
-
390
- // Stream events: text deltas, thinking deltas, block lifecycle
391
- if (message.type === "stream_event" && pending) {
392
- const event = (message as any).event;
393
-
394
- if (event?.type === "content_block_delta") {
395
- const delta = event.delta;
396
- if (delta?.type === "text_delta" && delta.text) {
397
- pending.accumulatedText += delta.text;
398
- pending.onStream?.(pending.accumulatedText);
399
- }
400
- if (delta?.type === "thinking_delta" && delta.thinking) {
401
- pending.accumulatedThinking += delta.thinking;
402
- // Only update on complete lines (newline boundary)
403
- const lines = pending.accumulatedThinking.split("\n");
404
- if (lines.length > 1) {
405
- // Show the last complete line (not the partial one being typed)
406
- const completeLine = lines[lines.length - 2]?.trim();
407
- if (completeLine && completeLine !== pending.lastThinkingLine) {
408
- pending.lastThinkingLine = completeLine;
409
- pending.onActivity?.(truncate(completeLine, 70));
410
- }
411
- }
412
- }
413
- }
414
-
415
- if (event?.type === "content_block_start") {
416
- const block = event.content_block;
417
- if (block?.type === "thinking") {
418
- pending.accumulatedThinking = "";
419
- pending.lastThinkingLine = "";
420
- pending.onActivity?.("thinking...");
421
- }
422
- // tool_use: don't show here — wait for tool_use_summary with full input
423
- }
424
-
425
- if (event?.type === "content_block_stop") {
426
- pending.accumulatedThinking = "";
427
- pending.lastThinkingLine = "";
428
- }
429
- }
430
-
431
- if (message.type === "tool_use_summary" && pending) {
432
- const msg = message as any;
433
- const name = msg.tool_name || "tool";
434
- pending.onActivity?.(formatToolUse(name, msg.tool_input));
435
- }
436
-
437
- if (message.type === "tool_progress" && pending) {
438
- const msg = message as any;
439
- const toolName = msg.tool_name;
440
- const content = msg.content;
441
- if (toolName === "Bash" && content) {
442
- pending.onActivity?.(`$ ${truncate(content, 60)}`);
443
- } else if (content) {
444
- pending.onActivity?.(truncate(content, 70));
445
- }
446
- }
447
-
448
- // Task/agent lifecycle
449
- if (message.type === "system" && pending) {
450
- const msg = message as any;
451
- if (msg.subtype === "task_started" && msg.description) {
452
- pending.onActivity?.(truncate(msg.description, 60));
453
- }
454
- if (msg.subtype === "task_progress" && msg.last_tool_name) {
455
- pending.onActivity?.(msg.summary || msg.last_tool_name);
456
- }
457
- }
458
-
459
- if (message.type === "result" && pending) {
460
- const msg = message as any;
461
- if (!message.is_error) {
462
- const resultText = msg.result as string;
463
- const costUsd = msg.total_cost_usd as number;
464
- const turns = msg.num_turns as number;
465
-
466
- const metadata: Record<string, unknown> = {
467
- cost_usd: costUsd,
468
- turns,
469
- duration_ms: msg.duration_ms,
470
- duration_api_ms: msg.duration_api_ms,
471
- stop_reason: msg.stop_reason,
472
- terminal_reason: msg.terminal_reason,
473
- session_id: msg.session_id,
474
- subtype: msg.subtype,
475
- usage: msg.usage,
476
- model_usage: msg.modelUsage,
477
- };
478
-
479
- let messageId: number | undefined;
480
- if (sessionId && resultText) {
481
- const saveParams = {
482
- sessionId,
483
- room,
484
- sender: "nia",
485
- content: resultText,
486
- isFromAgent: true,
487
- deliveryStatus: "pending" as const,
488
- metadata,
489
- };
490
- try {
491
- messageId = await Message.save(saveParams);
492
- } catch {
493
- messageId = await Message.save({
494
- ...saveParams,
495
- metadata: undefined,
496
- });
497
- }
498
- await Session.touch(sessionId);
499
- Session.accumulateMetadata(sessionId, {
500
- ...metadata,
501
- channel,
502
- }).catch(() => {});
503
- }
504
-
505
- await ActiveEngine.unregister(room);
506
- clearLongRunningTimer();
507
- retryCount = 0;
508
- pending.resolve({
509
- result: resultText,
510
- costUsd,
511
- turns,
512
- messageId,
513
- });
514
- pending = null;
515
- resetIdleTimer();
516
- } else {
517
- const errors = msg.errors;
518
- const rawError = errors?.join(", ") || "unknown error";
519
-
520
- // Retry on transient API errors (500, overloaded, rate-limit)
521
- if (retryCount < MAX_SEND_RETRIES && isRetryableApiError(rawError)) {
522
- const delay = SEND_RETRY_DELAYS[retryCount] ?? 8_000;
523
- retryCount++;
524
- log.warn(
525
- { room, attempt: retryCount, error: rawError, delayMs: delay },
526
- "retrying chat send after transient API error",
527
- );
528
- const retryPending = pending;
529
- pending = null;
530
- clearLongRunningTimer();
531
-
532
- // Tear down current query and restart after delay
533
- teardown();
534
- await sleep(delay);
535
- startQuery();
536
-
537
- // Re-send: the user message is already saved in DB, so mark it saved
538
- pending = {
539
- ...retryPending,
540
- userSaved: true,
541
- accumulatedText: "",
542
- accumulatedThinking: "",
543
- lastThinkingLine: "",
544
- };
545
- retryPending.onActivity?.("retrying after API error...");
546
- stream!.push(retryPending.userMessage);
547
- } else {
548
- const errorText = formatChatError(rawError);
549
- log.error(
550
- {
551
- room,
552
- error: rawError,
553
- errors,
554
- subtype: msg.subtype,
555
- terminal_reason: msg.terminal_reason,
556
- session_id: msg.session_id,
557
- },
558
- "chat send failed with SDK result error",
559
- );
560
- await ActiveEngine.unregister(room);
561
- clearLongRunningTimer();
562
- pending.resolve({ result: errorText, costUsd: 0, turns: 0 });
563
- pending = null;
564
- retryCount = 0;
565
- resetIdleTimer();
566
- }
567
- }
568
- }
569
- }
570
-
571
- // Stream ended without a result — subprocess exited or was killed
572
- if (pending) {
573
- const partial = pending.accumulatedText;
574
- log.error(
575
- { room, partialChars: partial.length },
576
- "query stream ended without result, rejecting pending request",
577
- );
578
- await ActiveEngine.unregister(room).catch(() => {});
579
- pending.reject(new Error(`stream ended without result (${partial.length} chars accumulated)`));
580
- pending = null;
581
- }
582
- } catch (err) {
583
- if (pending) {
584
- await ActiveEngine.unregister(room).catch(() => {});
585
- pending.reject(err instanceof Error ? err : new Error(String(err)));
586
- pending = null;
587
- }
588
- } finally {
589
- clearLongRunningTimer();
590
- unregisterActiveHandle(room);
591
- alive = false;
592
- stream = null;
593
- queryHandle = null;
594
- }
595
- })();
185
+ registerActiveHandle(room, (reason) => {
186
+ s.abort(reason);
187
+ });
188
+ session = s;
189
+ return s;
596
190
  }
597
191
 
598
192
  return {
@@ -608,6 +202,7 @@ export async function createChatEngine(opts: EngineOptions): Promise<ChatEngine>
608
202
  // Clear idle timer — engine is not idle while processing a request
609
203
  clearIdleTimer();
610
204
  startLongRunningTimer();
205
+ inFlight = true;
611
206
 
612
207
  // Cancel any pending finalization — session is active again
613
208
  if (sessionId) {
@@ -616,52 +211,130 @@ export async function createChatEngine(opts: EngineOptions): Promise<ChatEngine>
616
211
 
617
212
  await ActiveEngine.register(room, channel);
618
213
 
619
- if (!alive || !stream) {
620
- startQuery();
621
- }
622
-
623
- // Save user message to DB if session already exists (resumed session).
624
- // 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.
625
216
  let userSaved = false;
626
217
  if (sessionId) {
627
- await Message.save({
628
- sessionId,
629
- room,
630
- sender: "user",
631
- content: userMessage,
632
- isFromAgent: false,
633
- });
218
+ await Message.save({ sessionId, room, sender: "user", content: userMessage, isFromAgent: false });
634
219
  await Session.touch(sessionId);
635
220
  userSaved = true;
636
221
  messageCount++;
637
222
  }
638
223
 
639
- return new Promise<SendResult>((resolve, reject) => {
640
- pending = {
641
- userMessage,
642
- userSaved,
643
- onStream: callbacks?.onStream || null,
644
- onActivity: callbacks?.onActivity || null,
645
- accumulatedText: "",
646
- accumulatedThinking: "",
647
- lastThinkingLine: "",
648
- resolve,
649
- reject,
650
- };
651
- stream!.push(userMessage, attachments);
652
- });
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;
653
325
  },
654
326
 
655
327
  async close() {
656
328
  // Enqueue finalization — processed by daemon or inline if we are the daemon
657
- if (sessionId && messageCount > 0 && !pending) {
329
+ if (sessionId && messageCount > 0 && !inFlight) {
658
330
  try {
659
331
  await finalizeSession(sessionId, room);
660
332
  } catch (err) {
661
333
  log.error({ err, room }, "finalization enqueue failed during close");
662
334
  }
663
335
  }
664
- await abortActiveQuery("chat engine closed");
336
+ await teardown();
337
+ await ActiveEngine.unregister(room).catch(() => {});
665
338
  },
666
339
  };
667
340
  }