agentus 0.10.0 → 0.11.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.
@@ -17,6 +17,7 @@ import { describeAcpError } from "./acp/errors.js";
17
17
  import { BACKENDS } from "./acp/backends.js";
18
18
  import { HERMES_DEFAULT_HOME, classifyError, checkedHealth, coerceRow, inspectRow, planFor, rowWarnings, seedRows, startupCheck } from "./acp/registry.js";
19
19
  import { ChangedOnDiskError, FsError, listDirs, readTextFile, statFile, writeTextFile } from "./fs.js";
20
+ import { acquireInstanceLock, humanAge } from "./instance-lock.js";
20
21
  import { terms } from "./term.js";
21
22
  import { VoiceError, listVoiceModels, setHotwordSource, synthesize, transcribe, voiceCapabilities } from "./voice.js";
22
23
  import { dictationContext, hotwordsFor, vocabularyOf } from "./hotwords.js";
@@ -133,6 +134,33 @@ if (process.env.AGENTUS_PRINT_PATHS) {
133
134
  }
134
135
 
135
136
  fs.mkdirSync(DATA_DIR, { recursive: true, mode: 0o700 }); // 0700: credentials live here
137
+
138
+ // ---- one data directory, ONE server (incident 2026-10-22, incident-20261022-orphan-reclaim.md §7) ----
139
+ // Claimed BEFORE the store is opened and long before anything reclaims or listens. The reclaim below
140
+ // kills the agent processes this database remembers, so a second instance on the same data dir is not
141
+ // a "port conflict" — it is the destruction of a running cockpit. Refuse, and touch nothing.
142
+ // A dead owner (crash, SIGKILL, reboot) is replaced, so a stale claim never bricks an install.
143
+ const instanceLock = acquireInstanceLock(DATA_DIR, { port: PORT });
144
+ if (!instanceLock.ok) {
145
+ const h = instanceLock.holder;
146
+ console.error(`[agentus] refusing to start: this data directory already has an owner`);
147
+ console.error(`[agentus] data dir: ${DATA_DIR} (${DATA_DIR_SOURCE})`);
148
+ if (h) {
149
+ console.error(`[agentus] owner: pid ${h.pid}${h.port ? ` on port ${h.port}` : ""}, up ${humanAge(Date.now() - h.startedAt)}`);
150
+ }
151
+ console.error(`[agentus] reason: ${instanceLock.why}`);
152
+ console.error(`[agentus] two servers on one data dir reclaim each other's agents — stop that instance first,`);
153
+ console.error(`[agentus] or give this one its own directory: AGENTUS_DATA=/some/other/dir`);
154
+ process.exit(1);
155
+ }
156
+ if (instanceLock.tookOver) {
157
+ const prev = instanceLock.tookOver;
158
+ console.log(
159
+ `[agentus] instance lock: took over ${DATA_DIR} from pid ${prev.pid}`
160
+ + ` (gone or recycled; up ${humanAge(Date.now() - (prev.startedAt || Date.now()))})`,
161
+ );
162
+ }
163
+
136
164
  const store = new Store(path.join(DATA_DIR, "agentus.sqlite"));
137
165
  // M6: an existing cockpit must behave exactly as before, so the registry starts as a copy of
138
166
  // the builtin (env-driven) rows and the operator edits from there. Seeding is one-shot: an
@@ -203,7 +231,12 @@ function emit(evt: ServerEvent): void {
203
231
  notify.observe(evt);
204
232
  }
205
233
 
206
- const mgr = new SessionManager(store, emit);
234
+ // The data dir and this run's instance id travel into the manager: they are what lets a reclaim tell
235
+ // "an agent of THIS directory from a previous run" from "some pid the database still remembers".
236
+ const mgr = new SessionManager(store, emit, {
237
+ dataDir: DATA_DIR,
238
+ instanceId: instanceLock.holder.instanceId,
239
+ });
207
240
 
208
241
  /** 工作播报 (issue #16). Constructed AFTER the manager so it can spawn observers through it, and
209
242
  * wired into `emit` above through this mutable handle — the hub itself is not in the broadcast
@@ -431,6 +464,12 @@ setHotwordSource((sessionId) => hotwordsFor(store, sessionId).map((h) => h.word)
431
464
  // behaviour); the switch exists because a synthetic turn spends tokens the operator may not want
432
465
  // spent at 3am — and it is read live, so flipping it applies to the next completion.
433
466
  mgr.setCompletionWakeSource(() => agentSettings().completionWake);
467
+ // 语音通话(issue #48): 「在打电话」 has exactly one authority in this process (the notify centre's
468
+ // presence, which the page already reports every 30 s), so the voice-mode block and the thinking
469
+ // dial derive from it instead of adding a second client→server state channel. The presence
470
+ // listener turns each report into the transitions the manager records.
471
+ mgr.setCallLiveSource(() => notify.callLiveSession());
472
+ notify.onPresenceChange(() => { void mgr.syncCallState(); });
434
473
  mgr.startIdleReaper({
435
474
  idleMs: () => envIdleMs ?? agentSettings().idleKillMin * 60_000,
436
475
  warmSlots: () => envWarm ?? agentSettings().warmSlots,
@@ -494,7 +533,9 @@ function installSafetyNet(): void {
494
533
  });
495
534
  }
496
535
 
497
- // AC5: reclaim orphans from previous run before serving anything.
536
+ // AC5: reclaim orphans from a previous run — only reachable once the instance lock above proved this
537
+ // process is the single owner of DATA_DIR, and only killing agents that receipt proves are ours
538
+ // (incident 2026-10-22: a second instance used to execute the live cockpit's slots here, every 10 s).
498
539
  const killed = mgr.reclaimOrphans();
499
540
  if (killed) console.log(`[agentus] reclaimed ${killed} orphan agent process(es)`);
500
541
 
@@ -0,0 +1,235 @@
1
+ // ONE data directory, ONE server.
2
+ //
3
+ // Why this file exists (incident 2026-10-22, `tasks/20261001-agentus/incident-20261022-orphan-reclaim.md` §7):
4
+ // a hand-written promote script started a SECOND server on the live data dir (a different port, so
5
+ // `listen` was happy). That instance then ran the startup orphan reclaim — which kills every agent
6
+ // pid the database remembers — and executed the agents of the server that was already serving. Every
7
+ // 10 s the launchd job retried, failed with EADDRINUSE, and killed the operator's freshly restored
8
+ // sessions again: the failure looked like "my sessions die on resume".
9
+ //
10
+ // So identity is claimed BEFORE anything that can touch another instance's processes:
11
+ // · the claim is an O_EXCL `server.pid` in the data dir (atomic on every filesystem we ship to),
12
+ // · a claim is only honoured while its pid is alive AND still looks like an agentus server,
13
+ // · a dead or foreign holder is replaced (crash/SIGKILL must not brick the data dir),
14
+ // · nothing here kills anything — refusing to start is the whole remedy.
15
+
16
+ import fs from "node:fs";
17
+ import path from "node:path";
18
+ import { execFileSync } from "node:child_process";
19
+ import { randomUUID } from "node:crypto";
20
+
21
+ /** The claim lives beside the sqlite store: same dir, same 0700 guarantees. */
22
+ export const LOCK_FILE = "server.pid";
23
+
24
+ export interface LockHolder {
25
+ pid: number;
26
+ port: number | null;
27
+ dataDir: string;
28
+ /** Identifies THIS process run. Agents record it too, so a reclaim can tell "previous run" from
29
+ * "mine" — see `acp/agent-owner.ts`. */
30
+ instanceId: string;
31
+ startedAt: number;
32
+ entry: string;
33
+ }
34
+
35
+ export interface LockHandle {
36
+ ok: true;
37
+ path: string;
38
+ holder: LockHolder;
39
+ /** Set when the previous holder was gone and we took the directory over. */
40
+ tookOver: LockHolder | null;
41
+ release(): void;
42
+ }
43
+
44
+ export interface LockRefused {
45
+ ok: false;
46
+ holder: LockHolder | null;
47
+ /** One line an operator can act on; `index.ts` prints it verbatim. */
48
+ why: string;
49
+ }
50
+
51
+ /** `ps -o command=` — the argv of a pid, or null when it is gone. */
52
+ export function commandLineOf(pid: number): string | null {
53
+ try {
54
+ const out = execFileSync("ps", ["-o", "command=", "-p", String(pid)], {
55
+ encoding: "utf8", stdio: ["ignore", "pipe", "ignore"],
56
+ });
57
+ const s = out.trim();
58
+ return s || null;
59
+ } catch {
60
+ return null;
61
+ }
62
+ }
63
+
64
+ /** Kernel start time of a pid (ms), or null when it cannot be read. Used only to detect pid REUSE. */
65
+ export function startedAtOf(pid: number): number | null {
66
+ try {
67
+ const out = execFileSync("ps", ["-o", "lstart=", "-p", String(pid)], {
68
+ encoding: "utf8", stdio: ["ignore", "pipe", "ignore"],
69
+ }).trim();
70
+ if (!out) return null;
71
+ const t = Date.parse(out); // "Thu Oct 22 23:42:48 2026" under the C locale ps uses
72
+ return Number.isFinite(t) ? t : null;
73
+ } catch {
74
+ return null;
75
+ }
76
+ }
77
+
78
+ export function pidAlive(pid: number): boolean {
79
+ if (!Number.isInteger(pid) || pid <= 0) return false;
80
+ try {
81
+ process.kill(pid, 0);
82
+ return true;
83
+ } catch (err) {
84
+ // EPERM = exists, just not ours to signal. Either way it is NOT free to take over silently.
85
+ return (err as NodeJS.ErrnoException).code === "EPERM";
86
+ }
87
+ }
88
+
89
+ /** Does this argv look like an agentus server? Deliberately narrow: a pid that is merely alive must
90
+ * never block a restart because its number was recycled by an unrelated program. Covers the source
91
+ * entry (`packages/server/src/index.ts`, tsx or node), the built one (`dist/server/index.js`) and a
92
+ * globally installed package (`…/node_modules/agentus/…`). */
93
+ export function looksLikeServer(cmd: string | null): boolean {
94
+ if (!cmd) return false;
95
+ if (!/\b(node|tsx|bun|deno)\b/.test(cmd)) return false;
96
+ return /packages[\\/]server[\\/](src[\\/]index|dist[\\/]index)\.(ts|js|mjs|cjs)\b/.test(cmd)
97
+ || /agentus[\\/](dist[\\/])?server[\\/]index\.(js|mjs|cjs)\b/.test(cmd);
98
+ }
99
+
100
+ /** Synchronous sleep: this runs during module init, before any event loop turn is meaningful. */
101
+ function sleepSync(ms: number): void {
102
+ try {
103
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
104
+ } catch {
105
+ const end = Date.now() + ms;
106
+ while (Date.now() < end) { /* spin: only when Atomics is unavailable */ }
107
+ }
108
+ }
109
+
110
+ function readHolder(lockPath: string): LockHolder | null {
111
+ try {
112
+ const raw = fs.readFileSync(lockPath, "utf8");
113
+ const j = JSON.parse(raw) as Partial<LockHolder>;
114
+ if (typeof j.pid !== "number" || !Number.isInteger(j.pid)) return null;
115
+ return {
116
+ pid: j.pid,
117
+ port: typeof j.port === "number" ? j.port : null,
118
+ dataDir: typeof j.dataDir === "string" ? j.dataDir : "",
119
+ instanceId: typeof j.instanceId === "string" ? j.instanceId : "",
120
+ startedAt: typeof j.startedAt === "number" ? j.startedAt : 0,
121
+ entry: typeof j.entry === "string" ? j.entry : "",
122
+ };
123
+ } catch {
124
+ return null; // unreadable or truncated: a crashed writer, treat as no claim
125
+ }
126
+ }
127
+
128
+ /** Is the recorded holder still a live agentus server on THIS data dir? */
129
+ function judgeHolder(holder: LockHolder, dataDir: string): { alive: boolean; why: string } {
130
+ if (holder.pid === process.pid) return { alive: false, why: `claim names this very pid (${holder.pid})` };
131
+ if (!pidAlive(holder.pid)) return { alive: false, why: `pid ${holder.pid} is gone` };
132
+ const cmd = commandLineOf(holder.pid);
133
+ if (!cmd) return { alive: false, why: `pid ${holder.pid} disappeared while checking` };
134
+ if (!looksLikeServer(cmd)) {
135
+ return { alive: false, why: `pid ${holder.pid} was recycled by another program (${cmd.slice(0, 120)})` };
136
+ }
137
+ if (holder.dataDir && path.resolve(holder.dataDir) !== path.resolve(dataDir)) {
138
+ // The file lives in OUR dir but names another one: copied/hand-edited file. Its pid may well be
139
+ // a healthy server for the OTHER directory — replacing the claim is right, killing is not.
140
+ return { alive: false, why: `claim names data dir ${holder.dataDir}, not ${dataDir}` };
141
+ }
142
+ if (holder.startedAt) {
143
+ const actual = startedAtOf(holder.pid);
144
+ // 1 s of slack: `ps` prints whole seconds. Anything larger means pid reuse, not our process.
145
+ if (actual !== null && Math.abs(actual - holder.startedAt) > 2000) {
146
+ return { alive: false, why: `pid ${holder.pid} start time does not match the claim (reused pid)` };
147
+ }
148
+ }
149
+ return { alive: true, why: `pid ${holder.pid} (port ${holder.port ?? "?"}) is serving this data dir` };
150
+ }
151
+
152
+ export interface AcquireOptions {
153
+ /** Port this instance intends to listen on; recorded for the operator, not used for judging. */
154
+ port?: number | null;
155
+ /** How long to wait for a dying holder to release (a restart hands over within a second; a live
156
+ * second instance must be refused quickly enough to be useful). */
157
+ waitMs?: number;
158
+ }
159
+
160
+ /**
161
+ * Claim `dataDir`. Returns a handle (already released on process exit) or a refusal with the reason.
162
+ *
163
+ * Callers MUST `process.exit(1)` on a refusal and MUST NOT touch the store, the agents or the port:
164
+ * the whole point is that a second instance leaves the first one's world untouched.
165
+ */
166
+ export function acquireInstanceLock(dataDir: string, opts: AcquireOptions = {}): LockHandle | LockRefused {
167
+ const lockPath = path.join(dataDir, LOCK_FILE);
168
+ const me: LockHolder = {
169
+ pid: process.pid,
170
+ port: opts.port ?? null,
171
+ dataDir: path.resolve(dataDir),
172
+ instanceId: randomUUID(),
173
+ startedAt: Date.now(),
174
+ entry: process.argv[1] ?? "",
175
+ };
176
+ const deadline = Date.now() + (opts.waitMs ?? 4000);
177
+ let tookOver: LockHolder | null = null;
178
+
179
+ for (;;) {
180
+ try {
181
+ // O_EXCL: the kernel decides who wins, even with two servers starting in the same tick.
182
+ const fd = fs.openSync(lockPath, "wx", 0o600);
183
+ try {
184
+ fs.writeFileSync(fd, JSON.stringify(me, null, 2));
185
+ } finally {
186
+ fs.closeSync(fd);
187
+ }
188
+ const handle: LockHandle = {
189
+ ok: true, path: lockPath, holder: me, tookOver,
190
+ release: () => {
191
+ // Only ever remove OUR claim: a slow instance must not delete the claim of the one that
192
+ // replaced it (that is how two servers end up owning one directory).
193
+ const onDisk = readHolder(lockPath);
194
+ if (!onDisk || onDisk.pid !== me.pid || onDisk.instanceId !== me.instanceId) return;
195
+ try { fs.unlinkSync(lockPath); } catch { /* already gone */ }
196
+ },
197
+ };
198
+ process.once("exit", handle.release);
199
+ return handle;
200
+ } catch (err) {
201
+ const code = (err as NodeJS.ErrnoException).code;
202
+ if (code !== "EEXIST") {
203
+ return { ok: false, holder: null, why: `cannot create ${lockPath}: ${(err as Error).message}` };
204
+ }
205
+ }
206
+
207
+ const holder = readHolder(lockPath);
208
+ if (!holder) {
209
+ // A crashed writer left junk (or a zero-byte file). Remove and retry.
210
+ try { fs.unlinkSync(lockPath); } catch { /* raced with another starter: loop decides */ }
211
+ continue;
212
+ }
213
+ const verdict = judgeHolder(holder, dataDir);
214
+ if (verdict.alive) {
215
+ if (Date.now() < deadline) {
216
+ // A restart tears the old process down first; give it a moment instead of failing a
217
+ // `tsx watch` reload or a promote on a scheduling hiccup.
218
+ sleepSync(250);
219
+ continue;
220
+ }
221
+ return { ok: false, holder, why: verdict.why };
222
+ }
223
+ tookOver = holder;
224
+ console.log(`[agentus] instance lock: taking over ${lockPath} — ${verdict.why}`);
225
+ try { fs.unlinkSync(lockPath); } catch { /* someone else cleaned it: the next open decides */ }
226
+ }
227
+ }
228
+
229
+ /** For a diagnostic line: "1m 12s". */
230
+ export function humanAge(ms: number): string {
231
+ const s = Math.max(0, Math.round(ms / 1000));
232
+ if (s < 60) return `${s}s`;
233
+ const m = Math.floor(s / 60);
234
+ return `${m}m ${s % 60}s`;
235
+ }
@@ -238,6 +238,13 @@ export class NotifyCenter {
238
238
  return next;
239
239
  }
240
240
 
241
+ /** Called after EVERY presence report (issue #48 wires it to the voice-mode transitions). Kept as
242
+ * a listener rather than a direct call so the notify centre stays free of session knowledge. */
243
+ #onPresence?: (p: Presence) => void;
244
+ onPresenceChange(fn: (p: Presence) => void): void {
245
+ this.#onPresence = fn;
246
+ }
247
+
241
248
  /** The cockpit says which session it is looking at; null/visible=false means "nobody is". */
242
249
  setPresence(sessionId: string | null, visible: boolean, call = false): Presence {
243
250
  this.presence = {
@@ -254,6 +261,9 @@ export class NotifyCenter {
254
261
  if (this.presence.sessionId && (this.presence.visible || this.presence.call)) {
255
262
  this.onFocus?.(this.presence.sessionId);
256
263
  }
264
+ // Announce AFTER the fields are settled: a listener asking `callLiveSession()` must see the
265
+ // report it was just told about, not the previous one.
266
+ try { this.#onPresence?.(this.presence); } catch { /* presence is a hint, never a requirement */ }
257
267
  return this.presence;
258
268
  }
259
269
 
@@ -273,6 +283,15 @@ export class NotifyCenter {
273
283
  return Boolean(this.presence.call) && Date.now() - this.presence.at < NotifyCenter.WATCH_TTL_MS;
274
284
  }
275
285
 
286
+ /** WHICH session is on a call right now, or null. The public form of `onCall()` for issue #48:
287
+ * the voice-mode block and the thinking-dial switch need the session id, not just a boolean, and
288
+ * they must agree with the notification rules about what "on a call" means (one presence, one TTL,
289
+ * one answer). */
290
+ callLiveSession(): string | null {
291
+ if (!this.onCall()) return null;
292
+ return this.presence.sessionId;
293
+ }
294
+
276
295
  /** True when a notification for this session would interrupt the operator looking at it. */
277
296
  private watching(sessionId: string): boolean {
278
297
  return this.rules().quietWhenWatching && this.operatorWatching(sessionId);
@@ -267,6 +267,29 @@ interface RawPlanRow {
267
267
  updated_at: number;
268
268
  }
269
269
 
270
+ /** 语音通话状态的一行(issue #48)—— 见 `voice_mode` 表。 */
271
+ export interface VoiceRow {
272
+ sessionId: string;
273
+ inCall: boolean;
274
+ boundary: "enter" | "exit" | null;
275
+ turns: number;
276
+ thoughtBefore: string | null;
277
+ thoughtTouched: boolean;
278
+ updatedAt: number;
279
+ }
280
+
281
+ export type VoiceModePatch = Partial<Omit<VoiceRow, "sessionId" | "updatedAt">>;
282
+
283
+ interface RawVoiceRow {
284
+ session_id: string;
285
+ in_call: number;
286
+ boundary: string | null;
287
+ turns: number;
288
+ thought_before: string | null;
289
+ thought_touched: number;
290
+ updated_at: number;
291
+ }
292
+
270
293
  /** What a plan write carries. `explanation: undefined` means "keep the remark that is there"
271
294
  * (only an agent writes one, and most plan updates carry none) — see `upsertPlan`. */
272
295
  export interface PlanPatch {
@@ -351,6 +374,19 @@ export class Store {
351
374
  items text not null, title text, explanation text, source text not null,
352
375
  terminal text, updated_at integer not null
353
376
  );
377
+ -- 语音通话模式(issue #48). The two boundaries must SURVIVE a reload, an idle-reap and a
378
+ -- cold start: if the exit boundary were only in memory, a page refresh after hanging up would
379
+ -- leave the model believing it is still on a call (the transcript's last turns all sound like
380
+ -- one). Same待遇 as the plan row: state the cockpit owns, not something the transcript records.
381
+ create table if not exists voice_mode (
382
+ session_id text primary key,
383
+ in_call integer not null default 0,
384
+ boundary text, -- 'enter' | 'exit' | null (owed, rides the next prompt)
385
+ turns integer not null default 0, -- prompts spoken inside this call (the exit block quotes it)
386
+ thought_before text, -- thinking depth to restore on hang-up (null = none taken)
387
+ thought_touched integer not null default 0, -- operator changed depth during the call
388
+ updated_at integer not null
389
+ );
354
390
  `);
355
391
  // The DB now holds the speech endpoint's key, so it is the operator's secret material:
356
392
  // 0600 like the file it replaced (a default 0644 sqlite file would be a downgrade).
@@ -913,6 +949,65 @@ export class Store {
913
949
  return this.getPlan(sessionId) as PlanSnapshot;
914
950
  }
915
951
 
952
+ /** 语音通话状态(issue #48)—— 读一行,缺省即「没在通话、没有欠着的边界」。 */
953
+ getVoiceMode(sessionId: string): VoiceRow {
954
+ const row = this.#db
955
+ .prepare("select session_id, in_call, boundary, turns, thought_before, thought_touched, updated_at from voice_mode where session_id = ?")
956
+ .get(sessionId) as RawVoiceRow | undefined;
957
+ return row
958
+ ? {
959
+ sessionId: row.session_id,
960
+ inCall: Boolean(row.in_call),
961
+ boundary: row.boundary === "enter" || row.boundary === "exit" ? row.boundary : null,
962
+ turns: row.turns ?? 0,
963
+ thoughtBefore: row.thought_before ?? null,
964
+ thoughtTouched: Boolean(row.thought_touched),
965
+ updatedAt: row.updated_at,
966
+ }
967
+ : { sessionId, inCall: false, boundary: null, turns: 0, thoughtBefore: null, thoughtTouched: false, updatedAt: 0 };
968
+ }
969
+
970
+ /** Write the parts that changed; undefined keeps the stored value (the plan patch's rule). */
971
+ upsertVoiceMode(sessionId: string, patch: VoiceModePatch): VoiceRow {
972
+ const prev = this.getVoiceMode(sessionId);
973
+ const next: VoiceRow = {
974
+ sessionId,
975
+ inCall: patch.inCall ?? prev.inCall,
976
+ boundary: patch.boundary === undefined ? prev.boundary : patch.boundary,
977
+ turns: patch.turns ?? prev.turns,
978
+ thoughtBefore: patch.thoughtBefore === undefined ? prev.thoughtBefore : patch.thoughtBefore,
979
+ thoughtTouched: patch.thoughtTouched ?? prev.thoughtTouched,
980
+ updatedAt: Date.now(),
981
+ };
982
+ this.#db
983
+ .prepare(
984
+ `insert into voice_mode (session_id, in_call, boundary, turns, thought_before, thought_touched, updated_at)
985
+ values (?, ?, ?, ?, ?, ?, ?)
986
+ on conflict(session_id) do update set
987
+ in_call = excluded.in_call, boundary = excluded.boundary, turns = excluded.turns,
988
+ thought_before = excluded.thought_before, thought_touched = excluded.thought_touched,
989
+ updated_at = excluded.updated_at`,
990
+ )
991
+ .run(
992
+ sessionId,
993
+ next.inCall ? 1 : 0,
994
+ next.boundary,
995
+ next.turns,
996
+ next.thoughtBefore,
997
+ next.thoughtTouched ? 1 : 0,
998
+ next.updatedAt,
999
+ );
1000
+ return next;
1001
+ }
1002
+
1003
+ /** Every session the store still believes is on a call — the list a hang-up has to sweep. */
1004
+ listInCall(): VoiceRow[] {
1005
+ const rows = this.#db
1006
+ .prepare("select session_id from voice_mode where in_call = 1")
1007
+ .all() as { session_id: string }[];
1008
+ return rows.map((r) => this.getVoiceMode(r.session_id));
1009
+ }
1010
+
916
1011
  appendMessage(m: Omit<StoredMessage, "seq">): StoredMessage {
917
1012
  const seq = this.nextSeq(m.sessionId);
918
1013
  this.#db