openmausbot 0.1.92 → 0.1.94

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.
Files changed (163) hide show
  1. package/dist/assets/{DesktopViewer-Do6lBa4C.js → DesktopViewer-bxM7NV3N.js} +1 -1
  2. package/dist/assets/{abnfDiagram-O67JEVCF-Cd3X7Hxc.js → abnfDiagram-O67JEVCF-BhMdJhRJ.js} +1 -1
  3. package/dist/assets/{arc-DIYc7Ymx.js → arc-B5itKDcx.js} +1 -1
  4. package/dist/assets/{architectureDiagram-NJMV4G6O-DBmRGVyP.js → architectureDiagram-NJMV4G6O-C5d7UY6U.js} +1 -1
  5. package/dist/assets/{blockDiagram-BEXU5L5S-CPFePSEE.js → blockDiagram-BEXU5L5S-BrZIhMmu.js} +1 -1
  6. package/dist/assets/{c4Diagram-YGBWAQC7-DD_DO1qy.js → c4Diagram-YGBWAQC7-7I0ArfbD.js} +1 -1
  7. package/dist/assets/channel-CdJX6i8k.js +1 -0
  8. package/dist/assets/{chunk-2Q5K7J3B-BNEeNSjX.js → chunk-2Q5K7J3B-BnEsyN36.js} +1 -1
  9. package/dist/assets/{chunk-3FUC2YCW-BbinLaqH.js → chunk-3FUC2YCW-BXATQ52s.js} +2 -2
  10. package/dist/assets/{chunk-5VM5RSS4-BBgYrgDR.js → chunk-5VM5RSS4-B0BpTkEx.js} +1 -1
  11. package/dist/assets/{chunk-GTNCS2PH-ezXj-kA-.js → chunk-GTNCS2PH-BtLTz6FR.js} +1 -1
  12. package/dist/assets/{chunk-GWA4HPMP-9FxZ3diV.js → chunk-GWA4HPMP-D6gcZOYt.js} +1 -1
  13. package/dist/assets/{chunk-JWPE2WC7-CO-40JpI.js → chunk-JWPE2WC7-DCLrosvf.js} +1 -1
  14. package/dist/assets/{chunk-NETBCI7D-BEhjslYE.js → chunk-NETBCI7D-B9JsknV9.js} +1 -1
  15. package/dist/assets/{chunk-WEXAMYUT-CNeq4n7_.js → chunk-WEXAMYUT-CsoTxw7z.js} +1 -1
  16. package/dist/assets/{chunk-XXDRQBXY-OsCY7KSi.js → chunk-XXDRQBXY-0F_frB0c.js} +1 -1
  17. package/dist/assets/{classDiagram-v2-NBCMYWYE-CloRUEtk.js → classDiagram-v2-NBCMYWYE-DkrpnflX.js} +1 -1
  18. package/dist/assets/{cose-bilkent-JH36ORCC-D1BGZhp4.js → cose-bilkent-JH36ORCC-BNDwYBt9.js} +1 -1
  19. package/dist/assets/{cynefin-OW5HDTMX-CUf4T2CN.js → cynefin-OW5HDTMX-JUkNr1Mk.js} +1 -1
  20. package/dist/assets/{cynefinDiagram-VND7K2PF-DPEZrFGA.js → cynefinDiagram-VND7K2PF-DKeh9N4M.js} +1 -1
  21. package/dist/assets/{dagre-6A5THRUB-bDe6GIv5.js → dagre-6A5THRUB-ve-6T_wX.js} +1 -1
  22. package/dist/assets/{diagram-22UHCM2B-CdcUQz55.js → diagram-22UHCM2B-BuJ2d8rT.js} +1 -1
  23. package/dist/assets/{diagram-3UASUU5V-BZpqGg7c.js → diagram-3UASUU5V-BgOs_Ni-.js} +1 -1
  24. package/dist/assets/{diagram-ATOU4E4O-DvGM6ugp.js → diagram-ATOU4E4O-DMK6VmcP.js} +1 -1
  25. package/dist/assets/{diagram-CDSNMT55-DSTmMm0s.js → diagram-CDSNMT55-DksASlcY.js} +1 -1
  26. package/dist/assets/{diagram-MLGK6HIB-15cucnMn.js → diagram-MLGK6HIB-Bgn8I5k6.js} +1 -1
  27. package/dist/assets/{diagram-MPIPVDR6-tMbuaE6n.js → diagram-MPIPVDR6-zK0jqO8N.js} +1 -1
  28. package/dist/assets/{ebnfDiagram-ZINNZB2B-BYxYzjHI.js → ebnfDiagram-ZINNZB2B-vKrbzTwr.js} +1 -1
  29. package/dist/assets/{elk-276RUBZZ-v6bj852W.js → elk-276RUBZZ-Bzv2mPNf.js} +1 -1
  30. package/dist/assets/{erDiagram-OPXOYQCR-5lBBlnd2.js → erDiagram-OPXOYQCR-CvOyYuxu.js} +1 -1
  31. package/dist/assets/{flowDiagram-KWPJA3E3-CZyl1zIj.js → flowDiagram-KWPJA3E3-CtbnDbc0.js} +1 -1
  32. package/dist/assets/{ganttDiagram-FUAMR5RP-BiAI-ltZ.js → ganttDiagram-FUAMR5RP-c4dMRYyG.js} +1 -1
  33. package/dist/assets/{gitGraphDiagram-X574FWY7-BAZISkj3.js → gitGraphDiagram-X574FWY7-B34uaQho.js} +1 -1
  34. package/dist/assets/index-CPYrGsv1.js +610 -0
  35. package/dist/assets/{index-DGBaEXrM.js → index-CPiqc_DN.js} +1 -1
  36. package/dist/assets/index-CzT42GR8.css +1 -0
  37. package/dist/assets/{infoDiagram-VRGFBTTK-B6ooOy-Z.js → infoDiagram-VRGFBTTK-o64ma4CI.js} +1 -1
  38. package/dist/assets/{ishikawaDiagram-OU5B5YK6-BZ9ksi-B.js → ishikawaDiagram-OU5B5YK6-DV4C9BAm.js} +1 -1
  39. package/dist/assets/{journeyDiagram-ZHPQQLJL-DWqlzIEe.js → journeyDiagram-ZHPQQLJL-Bf0AHk1H.js} +1 -1
  40. package/dist/assets/{kanban-definition-PNTS6WVX-JX6piDwL.js → kanban-definition-PNTS6WVX-B1UMSKmE.js} +1 -1
  41. package/dist/assets/{linear-CmOWzX0P.js → linear-BpKhuyLm.js} +1 -1
  42. package/dist/assets/{mermaid.core-CF1zwoQ-.js → mermaid.core-B_BKNCw5.js} +5 -5
  43. package/dist/assets/{mindmap-definition-NLK3R4M7-CGh2K4fW.js → mindmap-definition-NLK3R4M7-DeARruT1.js} +1 -1
  44. package/dist/assets/{pegDiagram-GJSIUBJH-CDwYN0s7.js → pegDiagram-GJSIUBJH-BOQEk_1D.js} +1 -1
  45. package/dist/assets/{pieDiagram-5QR66LMP-1rfjJocB.js → pieDiagram-5QR66LMP-EIfoiTZo.js} +1 -1
  46. package/dist/assets/{quadrantDiagram-O4NWA36T-tKPvPiok.js → quadrantDiagram-O4NWA36T-GgZwT-fi.js} +1 -1
  47. package/dist/assets/{railroadDiagram-XR7U4H2S-BHLm5vXR.js → railroadDiagram-XR7U4H2S-D9rDQomj.js} +1 -1
  48. package/dist/assets/{requirementDiagram-PLB6GJNP-CB45STp7.js → requirementDiagram-PLB6GJNP-DAh1MIeo.js} +1 -1
  49. package/dist/assets/{sankeyDiagram-IPEJSGJF-55QA_huC.js → sankeyDiagram-IPEJSGJF-CPr6Uz64.js} +1 -1
  50. package/dist/assets/{sequenceDiagram-PO4LG4MO-BckFmQHn.js → sequenceDiagram-PO4LG4MO-BU6VIdcm.js} +1 -1
  51. package/dist/assets/{sizeCapture-INFHLROL-BFCOJF11.js → sizeCapture-INFHLROL-qF2HO41r.js} +1 -1
  52. package/dist/assets/{stateDiagram-v2-GCMORJYK-CZvmiVVN.js → stateDiagram-v2-GCMORJYK-DlJ8G4MD.js} +1 -1
  53. package/dist/assets/{swimlanes-2SLR337P-BpDUCEKy.js → swimlanes-2SLR337P-DzYE8I54.js} +1 -1
  54. package/dist/assets/swimlanesDiagram-TC7HE7FX-D-U4GtLQ.js +8 -0
  55. package/dist/assets/{timeline-definition-EJHVYXUP-BpxHq6IK.js → timeline-definition-EJHVYXUP-B7wzEQ0A.js} +1 -1
  56. package/dist/assets/{usecaseDiagram-POWQR4AR-Cj66vm02.js → usecaseDiagram-POWQR4AR-BvGgrxIP.js} +1 -1
  57. package/dist/assets/{vennDiagram-UO4OBE2U-C9nRkWE_.js → vennDiagram-UO4OBE2U-CEECdgQg.js} +1 -1
  58. package/dist/assets/{wardleyDiagram-VNRHLVJA-D-me_nWj.js → wardleyDiagram-VNRHLVJA-XQHA0P_P.js} +1 -1
  59. package/dist/assets/{xychartDiagram-PMCCYNJV-Dc_fL4mk.js → xychartDiagram-PMCCYNJV-B5mLcZI2.js} +1 -1
  60. package/dist/index.html +2 -2
  61. package/dist-server/cloud-home-start.js +1 -0
  62. package/dist-server/companion/src/routes.js +45 -1
  63. package/dist-server/container-mcp.js +99 -77
  64. package/dist-server/drivers/agents-proxy.js +40 -1
  65. package/dist-server/drivers/pi-mcp-extension.ts +245 -299
  66. package/dist-server/enterprise/server/index.js +1 -0
  67. package/dist-server/index.js +18361 -13794
  68. package/dist-server/local-computer-proxy.js +5 -1
  69. package/dist-server/local-computer.js +1 -0
  70. package/dist-server/mcp-gate.js +152 -20
  71. package/dist-server/mcp-remote-proxy.js +437 -0
  72. package/dist-server/openmausbot.js +8872 -7620
  73. package/dist-server/pair-cli.js +8872 -7620
  74. package/dist-server/permission-proxy.js +65 -1
  75. package/dist-server/proxy-paths.js +1 -0
  76. package/dist-server/server/auto-approve.js +33 -11
  77. package/dist-server/server/boat.js +3 -3
  78. package/dist-server/server/browser-live.js +14 -5
  79. package/dist-server/server/browser-runtime.js +6 -0
  80. package/dist-server/server/channel-queue.js +4 -1
  81. package/dist-server/server/cli.js +32 -12
  82. package/dist-server/server/cloud-move-http.js +9 -3
  83. package/dist-server/server/cloud-move.js +18 -0
  84. package/dist-server/server/cloud-owner.js +1 -1
  85. package/dist-server/server/computer-control.js +6 -0
  86. package/dist-server/server/computer-wait.js +33 -13
  87. package/dist-server/server/config.js +57 -10
  88. package/dist-server/server/container-computer.js +43 -32
  89. package/dist-server/server/decision-log.js +9 -6
  90. package/dist-server/server/delegations.js +47 -22
  91. package/dist-server/server/digest.js +12 -2
  92. package/dist-server/server/drivers/acp/core.js +229 -20
  93. package/dist-server/server/drivers/acp/cursor.js +13 -1
  94. package/dist-server/server/drivers/acp/grok.js +208 -3
  95. package/dist-server/server/drivers/acp/quiet-status.js +257 -0
  96. package/dist-server/server/drivers/acp/qwen.js +18 -0
  97. package/dist-server/server/drivers/agents-call.js +28 -0
  98. package/dist-server/server/drivers/agents-catalog.js +10 -1
  99. package/dist-server/server/drivers/boatagent.js +2 -0
  100. package/dist-server/server/drivers/builtIn.js +2 -0
  101. package/dist-server/server/drivers/cerebras.js +108 -0
  102. package/dist-server/server/drivers/chat-mcp-tools.js +45 -15
  103. package/dist-server/server/drivers/claude.js +35 -9
  104. package/dist-server/server/drivers/codex.js +84 -10
  105. package/dist-server/server/drivers/openai-chat.js +37 -5
  106. package/dist-server/server/drivers/pi.js +48 -4
  107. package/dist-server/server/drivers/prompt-split.js +2 -0
  108. package/dist-server/server/env-path.js +6 -2
  109. package/dist-server/server/environment.js +3 -0
  110. package/dist-server/server/guarded-requests.js +2 -1
  111. package/dist-server/server/index.js +1353 -311
  112. package/dist-server/server/live-call-controller.js +868 -0
  113. package/dist-server/server/live-call.js +216 -0
  114. package/dist-server/server/local-vm-stop-reason.js +26 -0
  115. package/dist-server/server/mcp-gate-config.js +43 -12
  116. package/dist-server/server/mcp-gate.js +127 -25
  117. package/dist-server/server/mcp-http.js +46 -8
  118. package/dist-server/server/mcp-remote-proxy.js +108 -0
  119. package/dist-server/server/new-bot-defaults.js +3 -0
  120. package/dist-server/server/peer-provenance.js +4 -2
  121. package/dist-server/server/permission-proxy.js +15 -1
  122. package/dist-server/server/plan-usage.js +797 -0
  123. package/dist-server/server/provider-key-check.js +2 -1
  124. package/dist-server/server/proxy-paths.js +1 -0
  125. package/dist-server/server/redact.js +2 -0
  126. package/dist-server/server/request-auth.js +3 -2
  127. package/dist-server/server/routes/desktop-viewer.js +50 -6
  128. package/dist-server/server/routes/live.js +98 -0
  129. package/dist-server/server/routines.js +6 -3
  130. package/dist-server/server/skill-library.js +207 -0
  131. package/dist-server/server/skills-library-migration.js +235 -0
  132. package/dist-server/server/skills.js +66 -3
  133. package/dist-server/server/steer-queue.js +13 -1
  134. package/dist-server/server/store.js +86 -4
  135. package/dist-server/server/surface.js +9 -3
  136. package/dist-server/server/team-backup.js +2 -1
  137. package/dist-server/server/thread-events.js +1 -1
  138. package/dist-server/server/tunnel.js +20 -1
  139. package/dist-server/server/turn-log.js +10 -0
  140. package/dist-server/server/turn-resources.js +124 -0
  141. package/dist-server/server/vps-computer.js +1 -1
  142. package/dist-server/server/workspace-backup.js +22 -0
  143. package/dist-server/server/workspace.js +1 -1
  144. package/dist-server/shared/approval-mode.js +1 -1
  145. package/dist-server/shared/ask-question.js +13 -0
  146. package/dist-server/shared/call-consent.js +28 -0
  147. package/dist-server/shared/live-approval.js +124 -0
  148. package/dist-server/shared/live-call.js +176 -0
  149. package/dist-server/shared/redact.js +1 -0
  150. package/dist-server/shared/skill-md.js +10 -0
  151. package/dist-server/shared/team-backup.js +3 -0
  152. package/dist-server/shared/tool-name.js +22 -0
  153. package/dist-server/shared/tool-scope-support.js +25 -0
  154. package/dist-server/shared/tool-scope.js +136 -0
  155. package/dist-server/src/lib/thread-refs.js +406 -0
  156. package/dist-server/vps-container-mcp.js +99 -77
  157. package/dist-server/workspace-backup.worker.js +103 -100
  158. package/enterprise/server/index.js +1 -0
  159. package/package.json +1 -1
  160. package/dist/assets/channel-Dtk5Mtic.js +0 -1
  161. package/dist/assets/index-Cdmjrl28.css +0 -1
  162. package/dist/assets/index-DR7YPMkW.js +0 -597
  163. package/dist/assets/swimlanesDiagram-TC7HE7FX-M0GYW9Y3.js +0 -8
@@ -0,0 +1,868 @@
1
+ // The harness side of a Live call (spec: docs/superpowers/specs/2026-09-25-live-call-bar-design.md).
2
+ //
3
+ // A client (desktop renderer, iPhone or Android app) holds the microphone
4
+ // and speaker over WebRTC, straight to OpenAI. This controller holds the
5
+ // sideband WebSocket to the same GPT-Live session and runs every rule that
6
+ // connects the voice to the bot, so all three clients behave the same:
7
+ // delegation → a user message "via call" on the bot's thread;
8
+ // bot progress → quiet thinking; the turn's final text → spoken commentary;
9
+ // an approval card → a strict spoken yes/no; a question card → the next request;
10
+ // a typed message → nothing until the bot answers it; then the answer is read
11
+ // with what was typed (readTypedReplies on), or nothing about it at all
12
+ // reaches OpenAI (off);
13
+ // no speech and no work for idleMinutes → hang up.
14
+ // Nothing said on the call is logged; the summary line has counters only.
15
+ import { randomUUID } from "node:crypto";
16
+ import { spokenConsent } from "../shared/call-consent.js";
17
+ import { LIVE_COPY, liveCardKind, liveStepLabel, spokenApprovalPrompt, spokenConnectorPrompt, spokenQuestionPrompt, spokenReviewPrompt, spokenSecretPrompt, } from "../shared/live-approval.js";
18
+ import { clampAppend, commentaryChunks, LiveTranscript } from "../shared/live-call.js";
19
+ import { liveCallSummaryLine, LiveSessionError, liveVoice } from "./live-call.js";
20
+ export const DELEGATION_SETTLE_MS = 700;
21
+ export const CONSENT_SETTLE_MS = 1_200;
22
+ export const PROGRESS_INTERVAL_MS = 4_000;
23
+ export const ATTACH_TIMEOUT_MS = 10_000;
24
+ export const CLOSE_TIMEOUT_MS = 5_000;
25
+ export const IDLE_CHECK_MS = 15_000;
26
+ /** How often the voice gets a quiet status note while the bot works. */
27
+ export const STATUS_INTERVAL_MS = 30_000;
28
+ const SOCKET_OPEN = 1;
29
+ const MAX_ERRORS = 5;
30
+ /** Unpaired phones remembered, so a start already in flight when its phone
31
+ * was unpaired is refused too. Ids are never reused: a new pairing gets a new one. */
32
+ const MAX_REVOKED_DEVICES = 200;
33
+ /** How far past a spoken decision GPT-Live's own delegation of the same
34
+ * yes/no may land on the session timeline (takeRequest allows 1.5 s of
35
+ * trailing transcript; the delegation point trails the words a little). */
36
+ const DECISION_ECHO_MS = 3_000;
37
+ export class LiveCallBusyError extends Error {
38
+ call;
39
+ constructor(call) {
40
+ super("A Live call is already running.");
41
+ this.call = call;
42
+ }
43
+ }
44
+ /** The person who started the call signed out, or their session was
45
+ * revoked: the call must not keep reaching the bot on their behalf. */
46
+ export class LiveCallSignedOutError extends LiveSessionError {
47
+ constructor() {
48
+ super("The sign-in that started this call has ended.", 401);
49
+ }
50
+ }
51
+ const CLOSE_REASONS = {
52
+ expired: "expired",
53
+ content: "content",
54
+ remote_hangup: "remote-hangup",
55
+ connection_lost: "connection-lost",
56
+ };
57
+ export class LiveCallController {
58
+ deps;
59
+ call = null;
60
+ revokedDevices = new Set();
61
+ constructor(deps) {
62
+ this.deps = deps;
63
+ }
64
+ current() {
65
+ return this.call && this.call.state.status !== "ended" ? { ...this.call.state } : null;
66
+ }
67
+ /** `device`: the paired phone the companion vouched for, when the request came through it. */
68
+ async start(input) {
69
+ if (this.call && this.call.state.status !== "ended")
70
+ throw new LiveCallBusyError({ ...this.call.state });
71
+ if (input.device && this.revokedDevices.has(input.device))
72
+ throw new LiveCallSignedOutError();
73
+ const settings = this.deps.settings();
74
+ const key = settings.key.trim();
75
+ if (!key)
76
+ throw new LiveSessionError("Add an OpenAI API key to use Live calls.", 409);
77
+ const voice = liveVoice(settings.voice);
78
+ const call = this.newCall(input, key, voice);
79
+ // Taken before the first await: a second start in the same tick is refused.
80
+ this.call = call;
81
+ let session;
82
+ try {
83
+ session = await this.deps.createSession({ key, sdp: input.sdp, botId: input.botId, threadId: input.threadId, voice });
84
+ }
85
+ catch (error) {
86
+ if (this.call === call)
87
+ this.call = null;
88
+ this.deps.log(`[live] call failed bot=${input.botId} client=${input.client} status=${error instanceof LiveSessionError ? error.status : "error"}`);
89
+ throw error;
90
+ }
91
+ if (this.call !== call) {
92
+ this.closeOrphan(session.sessionId, key);
93
+ throw new LiveSessionError("The call was cancelled.", 503);
94
+ }
95
+ try {
96
+ call.sessionId = session.sessionId;
97
+ call.state.startedAt = this.now();
98
+ call.lastActivityAt = this.now();
99
+ call.lastActivity = this.deps.activity(input.botId, input.threadId);
100
+ if (call.lastActivity === "working")
101
+ this.beginWork(call);
102
+ call.unsubscribe = this.deps.store.onChange((change) => this.onStoreChange(call, change));
103
+ this.deps.log(`[live] call started bot=${input.botId} voice=${voice} client=${input.client}`);
104
+ this.emit(call);
105
+ // Before attach: a sideband that fails at once finishes the call, and
106
+ // finish must find the interval to clear it.
107
+ call.idleTimer = setInterval(() => this.guarded(call, () => this.checkIdle(call)), IDLE_CHECK_MS);
108
+ call.idleTimer.unref?.();
109
+ this.attach(call);
110
+ }
111
+ catch (error) {
112
+ // Never leave the one call slot taken by a call that did not start.
113
+ const sidebandOpened = call.socket !== null;
114
+ this.finish(call, "error", "The call could not start.");
115
+ // No sideband owns the session OpenAI made: close it, as a cancelled start does.
116
+ if (!sidebandOpened)
117
+ this.closeOrphan(session.sessionId, key);
118
+ throw error;
119
+ }
120
+ return { call: { ...call.state }, sdp: session.sdp };
121
+ }
122
+ async end(callId) {
123
+ const call = this.call;
124
+ if (!call || call.state.callId !== callId || call.state.status === "ended")
125
+ return null;
126
+ await this.hangUp(call, "hung-up");
127
+ return { ...call.state };
128
+ }
129
+ async shutdown() {
130
+ const call = this.call;
131
+ if (!call || call.state.status === "ended")
132
+ return;
133
+ this.command(call, { type: "session.close" });
134
+ this.finish(call, "shutdown");
135
+ }
136
+ /** A paired phone was unpaired (the companion says so). Its call, if it
137
+ * holds the line, ends at once; a start of its still in flight is refused.
138
+ * Returns the call it ended, or null. */
139
+ deviceRevoked(deviceId) {
140
+ this.revokedDevices.add(deviceId);
141
+ if (this.revokedDevices.size > MAX_REVOKED_DEVICES)
142
+ this.revokedDevices.delete(this.revokedDevices.values().next().value);
143
+ const call = this.call;
144
+ if (!call || call.device !== deviceId)
145
+ return null;
146
+ return this.endSignedOut(call, null, LIVE_COPY.unpaired) ? { ...call.state } : null;
147
+ }
148
+ /** A sign-in was revoked or signed out. The idle check would notice within
149
+ * IDLE_CHECK_MS; this ends the call it started at once. */
150
+ sessionRevoked(sessionId) {
151
+ const call = this.call;
152
+ if (!call || call.auth.kind !== "session" || call.auth.session.id !== sessionId)
153
+ return null;
154
+ return this.endSignedOut(call, null) ? { ...call.state } : null;
155
+ }
156
+ // ── lifecycle ────────────────────────────────────────────────────────
157
+ newCall(input, key, voice) {
158
+ return {
159
+ state: { callId: randomUUID(), botId: input.botId, threadId: input.threadId, client: input.client, voice, startedAt: this.now(), status: "connecting" },
160
+ auth: input.auth,
161
+ device: input.device ?? null,
162
+ personKey: this.deps.personKey?.(input.auth),
163
+ botName: input.botName,
164
+ key,
165
+ sessionId: "",
166
+ socket: null,
167
+ attached: false,
168
+ transcript: new LiveTranscript(),
169
+ heardThroughMs: 0,
170
+ lastDecision: null,
171
+ eventCounter: 0,
172
+ activeDelegation: null,
173
+ lastProgressAt: 0,
174
+ lastActivityAt: this.now(),
175
+ lastActivity: "idle",
176
+ workStartedAt: null,
177
+ workSteps: 0,
178
+ lastStep: null,
179
+ lastStepAt: 0,
180
+ lastStatusAt: 0,
181
+ approval: null,
182
+ question: null,
183
+ waitingCards: [],
184
+ announced: new Set(),
185
+ pendingCall: new Set(),
186
+ claimNextTerminal: false,
187
+ queuedIds: new Set(),
188
+ typedTexts: new Map(),
189
+ batches: new Map(),
190
+ drainBatch: null,
191
+ spokenAnswers: new Set(),
192
+ pendingEnd: null,
193
+ timers: new Set(),
194
+ consentTimer: null,
195
+ idleTimer: null,
196
+ unsubscribe: null,
197
+ closeWaiters: [],
198
+ stats: { delegations: 0, sentToBot: 0, answers: 0, approvals: 0, notHeard: 0, replies: 0, seconds: null, errors: [] },
199
+ };
200
+ }
201
+ attach(call) {
202
+ let socket;
203
+ try {
204
+ socket = this.deps.openSocket(this.deps.attachUrl(call.sessionId), call.key);
205
+ }
206
+ catch {
207
+ this.finish(call, "sideband-lost", "The call could not connect to OpenAI.");
208
+ return;
209
+ }
210
+ call.socket = socket;
211
+ const attachTimer = this.later(call, () => {
212
+ if (!call.attached)
213
+ this.finish(call, "sideband-lost", "The call could not connect to OpenAI.");
214
+ }, ATTACH_TIMEOUT_MS);
215
+ socket.onopen = () => {
216
+ call.attached = true;
217
+ this.clear(call, attachTimer);
218
+ if (call.state.status === "connecting") {
219
+ call.state.status = "live";
220
+ this.emit(call);
221
+ }
222
+ };
223
+ socket.onmessage = (event) => this.guarded(call, () => this.onSideband(call, event.data));
224
+ // Node's WebSocket reports a refused handshake as an error without a close.
225
+ socket.onerror = () => {
226
+ if (!call.attached)
227
+ this.finish(call, "sideband-lost", "The call could not connect to OpenAI.");
228
+ };
229
+ socket.onclose = () => {
230
+ if (call.state.status === "ended")
231
+ return;
232
+ if (call.pendingEnd)
233
+ this.finish(call, call.pendingEnd);
234
+ else
235
+ this.finish(call, "sideband-lost", "The call connection to OpenAI dropped.");
236
+ };
237
+ }
238
+ /** A start cancelled while OpenAI created the session (a hang-up, an
239
+ * unpairing, a shutdown): no client will attach to it and no call owns it,
240
+ * so close it through its sideband instead of leaving it open until OpenAI
241
+ * gives up on it. Best effort, bounded by ATTACH_TIMEOUT_MS. */
242
+ closeOrphan(sessionId, key) {
243
+ let socket;
244
+ try {
245
+ socket = this.deps.openSocket(this.deps.attachUrl(sessionId), key);
246
+ }
247
+ catch {
248
+ return;
249
+ }
250
+ const done = () => {
251
+ clearTimeout(timer);
252
+ socket.onopen = socket.onmessage = socket.onerror = socket.onclose = null;
253
+ try {
254
+ socket.close(1000, "call cancelled");
255
+ }
256
+ catch { /* already closed */ }
257
+ };
258
+ const timer = setTimeout(done, ATTACH_TIMEOUT_MS);
259
+ timer.unref?.();
260
+ socket.onopen = () => {
261
+ try {
262
+ socket.send(JSON.stringify({ type: "session.close", event_id: "omb_cancelled" }));
263
+ }
264
+ catch { /* closing anyway */ }
265
+ done();
266
+ };
267
+ socket.onerror = done;
268
+ socket.onclose = () => clearTimeout(timer);
269
+ }
270
+ hangUp(call, reason) {
271
+ if (call.state.status === "ended")
272
+ return Promise.resolve();
273
+ call.pendingEnd ??= reason;
274
+ const done = new Promise((resolve) => call.closeWaiters.push(resolve));
275
+ // Already ending: session.close was sent and the close timer runs.
276
+ if (call.state.status === "ending")
277
+ return done;
278
+ call.state.status = "ending";
279
+ this.emit(call);
280
+ if (!this.command(call, { type: "session.close" }))
281
+ this.finish(call, call.pendingEnd);
282
+ else
283
+ this.later(call, () => this.finish(call, call.pendingEnd ?? reason), CLOSE_TIMEOUT_MS);
284
+ return done;
285
+ }
286
+ finish(call, reason, error) {
287
+ if (call.state.status === "ended")
288
+ return;
289
+ call.state.status = "ended";
290
+ call.state.endReason = reason;
291
+ if (error)
292
+ call.state.error = error;
293
+ for (const timer of call.timers)
294
+ clearTimeout(timer);
295
+ call.timers.clear();
296
+ if (call.idleTimer)
297
+ clearInterval(call.idleTimer);
298
+ call.idleTimer = null;
299
+ call.unsubscribe?.();
300
+ call.unsubscribe = null;
301
+ const socket = call.socket;
302
+ call.socket = null;
303
+ if (socket) {
304
+ socket.onopen = socket.onmessage = socket.onerror = socket.onclose = null;
305
+ try {
306
+ socket.close(1000, "call ended");
307
+ }
308
+ catch { /* already closed */ }
309
+ }
310
+ this.emit(call);
311
+ this.deps.log(liveCallSummaryLine({
312
+ botId: call.state.botId,
313
+ voice: call.state.voice,
314
+ client: call.state.client,
315
+ seconds: call.stats.seconds ?? (this.now() - call.state.startedAt) / 1000,
316
+ delegations: call.stats.delegations,
317
+ sentToBot: call.stats.sentToBot,
318
+ answers: call.stats.answers,
319
+ approvals: call.stats.approvals,
320
+ notHeard: call.stats.notHeard,
321
+ replies: call.stats.replies,
322
+ end: reason,
323
+ errors: call.stats.errors,
324
+ }));
325
+ if (this.call === call)
326
+ this.call = null;
327
+ for (const resolve of call.closeWaiters.splice(0))
328
+ resolve();
329
+ }
330
+ checkIdle(call) {
331
+ if (call.state.status !== "live" && call.state.status !== "connecting")
332
+ return;
333
+ // Talking keeps a call from idling out; it must not keep a signed-out person's call up.
334
+ if (this.deps.signedIn && !this.deps.signedIn(call.auth)) {
335
+ this.endSignedOut(call, null);
336
+ return;
337
+ }
338
+ if (this.deps.activity(call.state.botId, call.state.threadId) === "working") {
339
+ this.touch(call);
340
+ this.maybeStatus(call);
341
+ return;
342
+ }
343
+ const idleMs = this.deps.settings().idleMinutes * 60_000;
344
+ if (this.now() - call.lastActivityAt >= idleMs)
345
+ void this.hangUp(call, "idle");
346
+ }
347
+ // ── sideband events ──────────────────────────────────────────────────
348
+ onSideband(call, raw) {
349
+ const text = typeof raw === "string" ? raw : Buffer.isBuffer(raw) ? raw.toString("utf8") : raw instanceof ArrayBuffer ? Buffer.from(raw).toString("utf8") : "";
350
+ // The sideband mirrors the audio; skip it without parsing megabytes of base64.
351
+ if (!text || text.includes('"session.input_audio.append"') || text.includes('"session.output_audio.delta"'))
352
+ return;
353
+ let event;
354
+ try {
355
+ event = JSON.parse(text);
356
+ }
357
+ catch {
358
+ return;
359
+ }
360
+ if (call.state.status === "ended")
361
+ return;
362
+ switch (event.type) {
363
+ case "session.started":
364
+ if (call.state.status === "connecting") {
365
+ call.state.status = "live";
366
+ this.emit(call);
367
+ }
368
+ return;
369
+ case "session.input_transcript.delta":
370
+ call.transcript.addInput(String(event.delta ?? ""), Number(event.start_ms), Number(event.end_ms));
371
+ call.heardThroughMs = Math.max(call.heardThroughMs, Number(event.end_ms) || 0);
372
+ this.touch(call);
373
+ if (call.approval && !call.approval.submitted)
374
+ this.scheduleConsentCheck(call);
375
+ return;
376
+ case "session.output_transcript.delta":
377
+ // its timing, when given, marks where the voice itself was talking
378
+ call.transcript.addOutput(Number(event.start_ms), Number(event.end_ms));
379
+ this.touch(call);
380
+ return;
381
+ case "session.delegation.created": {
382
+ const delegation = event.delegation;
383
+ if (delegation?.target !== "client" || typeof delegation.id !== "string")
384
+ return;
385
+ const id = delegation.id;
386
+ const offset = Number(event.offset_ms) || 0;
387
+ call.stats.delegations += 1;
388
+ this.touch(call);
389
+ this.later(call, () => this.delegate(call, id, offset), DELEGATION_SETTLE_MS);
390
+ return;
391
+ }
392
+ case "session.usage.updated":
393
+ case "session.closed": {
394
+ const seconds = Number(event.usage?.seconds);
395
+ if (Number.isFinite(seconds))
396
+ call.stats.seconds = seconds;
397
+ if (event.type === "session.closed") {
398
+ const reason = CLOSE_REASONS[String(event.reason)] ?? call.pendingEnd ?? "hung-up";
399
+ this.finish(call, reason);
400
+ }
401
+ return;
402
+ }
403
+ case "error": {
404
+ this.recordError(call, String(event.error?.code ?? "error"));
405
+ return;
406
+ }
407
+ default:
408
+ return;
409
+ }
410
+ }
411
+ // ── voice → bot ──────────────────────────────────────────────────────
412
+ async delegate(call, id, offsetMs) {
413
+ if (call.state.status !== "live")
414
+ return;
415
+ const heard = call.transcript.takeRequestParts(offsetMs);
416
+ const said = heard.text;
417
+ if (call.approval && !call.approval.submitted) {
418
+ // the voice's own words, heard back, are never the person's answer
419
+ await this.decide(call, heard.withoutEcho, id, said);
420
+ return;
421
+ }
422
+ const question = call.question;
423
+ if (question && !question.submitted && said) {
424
+ question.submitted = true;
425
+ call.activeDelegation = id;
426
+ call.stats.answers += 1;
427
+ // The voice reading the question, heard back, is not part of the
428
+ // answer. When that leaves nothing, the person spoke over the voice:
429
+ // keep everything heard.
430
+ const answer = heard.withoutEcho || said;
431
+ const result = await this.respond(call, { auth: call.auth, threadId: call.state.threadId, requestId: question.requestId, behavior: "answer", message: answer }, id);
432
+ if (!result)
433
+ return;
434
+ if (result.ok) {
435
+ this.append(call, "thinking", LIVE_COPY.answerPassed, id);
436
+ }
437
+ else if (call.question === question) {
438
+ question.submitted = false;
439
+ this.append(call, "commentary", LIVE_COPY.saveFailed(result.error.trim().slice(0, 200)), id);
440
+ }
441
+ return;
442
+ }
443
+ const decision = call.lastDecision;
444
+ if (!said && decision && offsetMs <= decision.throughMs + DECISION_ECHO_MS) {
445
+ // The quiet-window rule already decided on these words; this is
446
+ // GPT-Live delegating the same yes/no. Do not ask the user to repeat it.
447
+ this.append(call, "thinking", decision.copy, id);
448
+ return;
449
+ }
450
+ if (!said) {
451
+ call.stats.notHeard += 1;
452
+ this.append(call, "instructions", LIVE_COPY.notHeard, id);
453
+ return;
454
+ }
455
+ call.activeDelegation = id;
456
+ call.stats.sentToBot += 1;
457
+ this.append(call, "thinking", LIVE_COPY.working, id);
458
+ try {
459
+ const result = await this.deps.send({ auth: call.auth, botId: call.state.botId, threadId: call.state.threadId, text: said });
460
+ if (result.kind === "queued")
461
+ call.queuedIds.add(result.queueId);
462
+ else if (result.kind === "steered") {
463
+ call.pendingCall.add(result.messageId);
464
+ call.claimNextTerminal = true;
465
+ }
466
+ else
467
+ call.pendingCall.add(result.messageId);
468
+ }
469
+ catch (error) {
470
+ if (error instanceof LiveCallSignedOutError) {
471
+ this.endSignedOut(call, id);
472
+ return;
473
+ }
474
+ const detail = error instanceof Error ? error.message.trim().slice(0, 200) : "";
475
+ this.recordError(call, "send-failed");
476
+ this.append(call, "commentary", `The request could not be sent to ${call.botName}${detail ? `: ${detail}` : "."}`, id);
477
+ }
478
+ }
479
+ /** `said`: the person's words; `heard`: everything heard, echo included
480
+ * (asking again only makes sense when something was heard at all). */
481
+ async decide(call, said, delegationId, heard = said) {
482
+ if (call.state.status !== "live")
483
+ return;
484
+ const open = call.approval;
485
+ if (!open || open.submitted)
486
+ return;
487
+ // Live's rule: hedges ("okay…", "sure") never decide on an open microphone
488
+ const decision = spokenConsent(said, "live");
489
+ if (!decision) {
490
+ if (heard.trim())
491
+ this.append(call, "instructions", LIVE_COPY.notClear, delegationId);
492
+ return;
493
+ }
494
+ call.transcript.consumeAll();
495
+ open.submitted = true;
496
+ call.stats.approvals += 1;
497
+ const copy = decision === "allow" ? LIVE_COPY.granted : LIVE_COPY.denied;
498
+ call.lastDecision = { throughMs: call.heardThroughMs, copy };
499
+ this.append(call, "commentary", copy, delegationId);
500
+ const result = await this.respond(call, {
501
+ auth: call.auth,
502
+ threadId: call.state.threadId,
503
+ requestId: open.requestId,
504
+ behavior: decision,
505
+ message: decision === "deny" ? LIVE_COPY.deniedMessage : undefined,
506
+ }, delegationId);
507
+ if (!result)
508
+ return;
509
+ if (!result.ok && call.approval === open) {
510
+ open.submitted = false;
511
+ this.append(call, "commentary", LIVE_COPY.saveFailed(result.error.trim().slice(0, 200)), delegationId);
512
+ }
513
+ }
514
+ scheduleConsentCheck(call) {
515
+ if (call.consentTimer)
516
+ this.clear(call, call.consentTimer);
517
+ call.consentTimer = this.later(call, () => {
518
+ call.consentTimer = null;
519
+ if (!call.approval || call.approval.submitted)
520
+ return;
521
+ const pending = call.transcript.pending({ skipEcho: true });
522
+ return spokenConsent(pending, "live") ? this.decide(call, pending, call.activeDelegation) : undefined;
523
+ }, CONSENT_SETTLE_MS);
524
+ }
525
+ // ── bot → voice ──────────────────────────────────────────────────────
526
+ onStoreChange(call, change) {
527
+ if (call.state.status === "ended")
528
+ return;
529
+ // Listeners run inside the store's write; do the work after it.
530
+ if (change.type === "thread.deleted" && change.threadId === call.state.threadId)
531
+ this.soon(call, () => this.hangUp(call, "deleted"));
532
+ else if (change.type === "bot.deleted" && change.botId === call.state.botId)
533
+ this.soon(call, () => this.hangUp(call, "deleted"));
534
+ else if ((change.type === "message" || change.type === "message.patch") && change.threadId === call.state.threadId) {
535
+ const { type, message } = change;
536
+ this.soon(call, () => this.onMessage(call, type, message));
537
+ }
538
+ else if (change.type === "bot" && change.botId === call.state.botId)
539
+ this.soon(call, () => this.onBotChange(call));
540
+ }
541
+ onBotChange(call) {
542
+ if (call.state.status === "ended")
543
+ return;
544
+ const activity = this.deps.activity(call.state.botId, call.state.threadId);
545
+ const was = call.lastActivity;
546
+ call.lastActivity = activity;
547
+ if (activity === "working")
548
+ this.touch(call);
549
+ if (activity === "working" && (was === "idle" || call.workStartedAt === null))
550
+ this.beginWork(call);
551
+ if (activity === "idle")
552
+ call.workStartedAt = null;
553
+ if (was === "idle" || activity !== "idle")
554
+ return;
555
+ // A turn settled. If it carried a call request and said nothing, say so.
556
+ this.forgetUnqueued(call);
557
+ if (call.approval || call.question || call.queuedIds.size)
558
+ return;
559
+ if (!call.pendingCall.size && !call.claimNextTerminal)
560
+ return;
561
+ call.pendingCall.clear();
562
+ call.claimNextTerminal = false;
563
+ this.append(call, "commentary", LIVE_COPY.noAnswer);
564
+ }
565
+ /** A queued call request leaves queuedIds when it is delivered (onMessage).
566
+ * Edited or cancelled in a client, it leaves the queue without arriving;
567
+ * waiting for it would keep "the result is in the chat" unsaid for good. */
568
+ forgetUnqueued(call) {
569
+ for (const queueId of call.queuedIds) {
570
+ if (!this.deps.queued(call.state.botId, call.state.threadId, queueId))
571
+ call.queuedIds.delete(queueId);
572
+ }
573
+ }
574
+ onMessage(call, type, message) {
575
+ if (call.state.status === "ended")
576
+ return;
577
+ this.touch(call);
578
+ if (type === "message")
579
+ this.trackDrain(call, message);
580
+ if (message.role === "user") {
581
+ if (type !== "message" || message.kind !== "text")
582
+ return;
583
+ if (message.via === "call") {
584
+ if (message.queueId)
585
+ call.queuedIds.delete(message.queueId);
586
+ call.pendingCall.add(message.id);
587
+ }
588
+ else if (this.typedByCaller(call, message)) {
589
+ // Only the bot answers a typed message. Telling the voice now made it
590
+ // answer too, and then again when the bot's answer was relayed.
591
+ call.typedTexts.set(message.id, message.text ?? "");
592
+ }
593
+ return;
594
+ }
595
+ if (message.kind === "options") {
596
+ this.onCard(call, message);
597
+ return;
598
+ }
599
+ if (message.kind === "connector" || message.kind === "secret") {
600
+ this.onSetupCard(call, message);
601
+ return;
602
+ }
603
+ if (message.kind === "activity") {
604
+ if (type !== "message" || !message.tool)
605
+ return;
606
+ if (call.workStartedAt !== null) {
607
+ call.workSteps += 1;
608
+ call.lastStep = liveStepLabel(message.tool);
609
+ call.lastStepAt = this.now();
610
+ }
611
+ if (!this.mayNarrate(call))
612
+ return;
613
+ if (message.tool.spoken && this.now() - call.lastProgressAt > PROGRESS_INTERVAL_MS) {
614
+ call.lastProgressAt = this.now();
615
+ this.append(call, "thinking", LIVE_COPY.progress(message.tool.spoken));
616
+ }
617
+ return;
618
+ }
619
+ if (message.kind === "text" && message.turnTerminal && !call.spokenAnswers.has(message.id))
620
+ this.onAnswer(call, message);
621
+ }
622
+ /** A line the caller typed in a client (every client sends a sendId with
623
+ * what is typed), as the same person. Not a peer bot's line (ask_bot,
624
+ * start_thread, an aside: the answer goes back to that bot), not a line a
625
+ * routine, a webhook or the local API added (no sendId, or via "api"), not
626
+ * a line a worker relayed for someone else (the Slack relay, any guarded
627
+ * send: relayed), and not another member's line in a shared workspace. */
628
+ typedByCaller(call, message) {
629
+ if (message.peerAsk || message.aside || message.relayed || message.via === "api" || !message.sendId)
630
+ return false;
631
+ return message.sender?.id === call.personKey;
632
+ }
633
+ /** Keep the lines one drain delivered together: they arrive back to back,
634
+ * and any other message closes the batch. */
635
+ trackDrain(call, message) {
636
+ if (message.role !== "user" || message.kind !== "text" || !message.queueId) {
637
+ call.drainBatch = null;
638
+ return;
639
+ }
640
+ call.drainBatch ??= [];
641
+ call.drainBatch.push(message.id);
642
+ call.batches.set(message.id, call.drainBatch);
643
+ if (call.batches.size > 200)
644
+ call.batches.delete(call.batches.keys().next().value);
645
+ }
646
+ onAnswer(call, message) {
647
+ const request = message.requestMessageId;
648
+ // A drained turn answers every line of its batch, not only the last one.
649
+ const batch = request === undefined ? [] : call.batches.get(request) ?? [request];
650
+ // A batch that holds a spoken line is the call's: answered aloud, even
651
+ // when a typed line came last.
652
+ const forCall = batch.some((id) => call.pendingCall.has(id)) || call.claimNextTerminal || (request === undefined && call.pendingCall.size > 0);
653
+ const typed = !forCall && request !== undefined ? call.typedTexts.get(request) : undefined;
654
+ const settle = () => {
655
+ for (const id of batch) {
656
+ call.pendingCall.delete(id);
657
+ call.typedTexts.delete(id);
658
+ call.batches.delete(id);
659
+ }
660
+ };
661
+ if (!forCall && typed === undefined)
662
+ return;
663
+ if (!forCall && !this.deps.settings().readTypedReplies) {
664
+ // Off means nothing about a typed exchange reaches OpenAI, not even as context.
665
+ settle();
666
+ call.spokenAnswers.add(message.id);
667
+ return;
668
+ }
669
+ const lead = !forCall && typed !== undefined ? [LIVE_COPY.typedAnswerLead(typed)] : [];
670
+ const utterances = this.deps.speakable(message.text ?? "");
671
+ if (!utterances.length)
672
+ return;
673
+ const chunks = commentaryChunks([...lead, ...utterances]);
674
+ if (!chunks.length)
675
+ return;
676
+ call.spokenAnswers.add(message.id);
677
+ settle();
678
+ if (forCall) {
679
+ if (request === undefined || call.claimNextTerminal)
680
+ call.pendingCall.clear();
681
+ call.claimNextTerminal = false;
682
+ }
683
+ call.stats.replies += 1;
684
+ const delegationId = forCall ? call.activeDelegation : null;
685
+ for (const chunk of chunks)
686
+ this.append(call, "commentary", chunk, delegationId);
687
+ }
688
+ onCard(call, message) {
689
+ const card = message.card;
690
+ if (!card?.requestId)
691
+ return;
692
+ const kind = liveCardKind(card);
693
+ if (!kind) {
694
+ this.onCardSettled(call, card.requestId);
695
+ return;
696
+ }
697
+ if (call.announced.has(card.requestId))
698
+ return;
699
+ if ((kind === "approval" || kind === "question") && (call.approval || call.question)) {
700
+ if (!call.waitingCards.some((waiting) => waiting.card?.requestId === card.requestId))
701
+ call.waitingCards.push(message);
702
+ return;
703
+ }
704
+ call.announced.add(card.requestId);
705
+ if (kind === "review") {
706
+ this.append(call, "instructions", spokenReviewPrompt(card));
707
+ }
708
+ else if (kind === "approval") {
709
+ call.approval = { requestId: card.requestId, messageId: message.id, submitted: false };
710
+ call.transcript.consumeAll();
711
+ this.append(call, "instructions", LIVE_COPY.permissionRequest(spokenApprovalPrompt(card)));
712
+ }
713
+ else {
714
+ call.question = { requestId: card.requestId, messageId: message.id, submitted: false };
715
+ call.transcript.consumeAll();
716
+ this.append(call, "instructions", spokenQuestionPrompt(card));
717
+ }
718
+ }
719
+ /** A connect-an-app or credential card: the bot waits on the person in
720
+ * the chat. The voice says so once; neither can be done by voice, and a
721
+ * credential must never be spoken into the call. */
722
+ onSetupCard(call, message) {
723
+ const { connector, secret } = message;
724
+ const waiting = message.kind === "connector"
725
+ ? Boolean(connector && connector.status !== "connected" && !connector.dismissed && !connector.resumed)
726
+ : Boolean(secret && !secret.provided && !secret.dismissed && !secret.superseded && !secret.resumed);
727
+ const key = `${message.kind}:${message.id}`;
728
+ if (!waiting || call.announced.has(key))
729
+ return;
730
+ call.announced.add(key);
731
+ this.append(call, "instructions", connector ? spokenConnectorPrompt(connector.label) : spokenSecretPrompt(secret?.label ?? ""));
732
+ }
733
+ onCardSettled(call, requestId) {
734
+ call.waitingCards = call.waitingCards.filter((waiting) => waiting.card?.requestId !== requestId);
735
+ const open = call.approval?.requestId === requestId ? call.approval : call.question?.requestId === requestId ? call.question : null;
736
+ if (!open)
737
+ return;
738
+ if (call.approval === open)
739
+ call.approval = null;
740
+ else
741
+ call.question = null;
742
+ if (!open.submitted)
743
+ this.append(call, "thinking", LIVE_COPY.answeredInChat, null);
744
+ const next = call.waitingCards.shift();
745
+ if (next)
746
+ this.onCard(call, next);
747
+ }
748
+ // ── status notes ─────────────────────────────────────────────────────
749
+ beginWork(call) {
750
+ const now = this.now();
751
+ call.workStartedAt = now;
752
+ call.workSteps = 0;
753
+ call.lastStep = null;
754
+ call.lastStepAt = now;
755
+ call.lastStatusAt = now;
756
+ }
757
+ /** A quiet fact for the voice while the bot works, so "is it stuck?" gets an
758
+ * answer from the real state instead of a guess (or a question steered into
759
+ * the running turn). Names steps, never their arguments. */
760
+ maybeStatus(call) {
761
+ if (call.workStartedAt === null) {
762
+ this.beginWork(call);
763
+ return;
764
+ }
765
+ const now = this.now();
766
+ if (now - call.lastStatusAt < STATUS_INTERVAL_MS)
767
+ return;
768
+ call.lastStatusAt = now;
769
+ if (!this.mayNarrate(call))
770
+ return;
771
+ this.append(call, "thinking", LIVE_COPY.status(now - call.workStartedAt, call.workSteps, call.lastStep, now - call.lastStepAt), null);
772
+ }
773
+ /** Whether the voice may hear about the bot's current work (progress and
774
+ * status notes). With typed replies off, only work on a spoken request:
775
+ * the steps the bot takes for a typed message are about that exchange. */
776
+ mayNarrate(call) {
777
+ return this.deps.settings().readTypedReplies || call.pendingCall.size > 0 || call.claimNextTerminal;
778
+ }
779
+ // ── plumbing ─────────────────────────────────────────────────────────
780
+ append(call, kind, content, delegationId = call.activeDelegation) {
781
+ return this.command(call, { type: `session.${kind}.append`, delegation_id: delegationId, content: clampAppend(content) });
782
+ }
783
+ command(call, event) {
784
+ const socket = call.socket;
785
+ if (!socket || socket.readyState !== SOCKET_OPEN || call.state.status === "ended")
786
+ return false;
787
+ try {
788
+ socket.send(JSON.stringify({ ...event, event_id: `omb_${++call.eventCounter}` }));
789
+ return true;
790
+ }
791
+ catch {
792
+ return false;
793
+ }
794
+ }
795
+ emit(call) {
796
+ try {
797
+ this.deps.broadcast({ kind: "live.call", botId: call.state.botId, threadId: call.state.threadId, call: { ...call.state } });
798
+ }
799
+ catch {
800
+ this.recordError(call, "broadcast");
801
+ }
802
+ }
803
+ touch(call) {
804
+ call.lastActivityAt = this.now();
805
+ }
806
+ later(call, fn, ms) {
807
+ const timer = setTimeout(() => {
808
+ call.timers.delete(timer);
809
+ this.guarded(call, fn);
810
+ }, ms);
811
+ timer.unref?.();
812
+ call.timers.add(timer);
813
+ return timer;
814
+ }
815
+ /** Run deferred work in the next microtask, after the store's write. */
816
+ soon(call, fn) {
817
+ queueMicrotask(() => this.guarded(call, fn));
818
+ }
819
+ /** Timers, microtasks and socket events run outside any caller's
820
+ * try/catch, and the server has no unhandled-rejection handler: a bug in
821
+ * the voice relay must cost a counter, never the harness process. */
822
+ guarded(call, fn) {
823
+ try {
824
+ const result = fn();
825
+ if (result instanceof Promise)
826
+ result.catch(() => this.recordError(call, "internal"));
827
+ }
828
+ catch {
829
+ this.recordError(call, "internal");
830
+ }
831
+ }
832
+ /** Null when the call is ending because its sign-in ended: say nothing more. */
833
+ async respond(call, input, delegationId) {
834
+ try {
835
+ return await this.deps.respond(input);
836
+ }
837
+ catch (error) {
838
+ if (error instanceof LiveCallSignedOutError) {
839
+ this.endSignedOut(call, delegationId);
840
+ return null;
841
+ }
842
+ this.recordError(call, "respond-failed");
843
+ return { ok: false, error: "" };
844
+ }
845
+ }
846
+ /** The sign-in (or paired phone) that started the call ended: say so once,
847
+ * then hang up. False when the call was already ending. */
848
+ endSignedOut(call, delegationId, why = LIVE_COPY.signedOut) {
849
+ if (call.state.status === "ending" || call.state.status === "ended")
850
+ return false;
851
+ this.recordError(call, "signed-out");
852
+ this.append(call, "commentary", why, delegationId);
853
+ call.state.error = why;
854
+ void this.hangUp(call, "signed-out");
855
+ return true;
856
+ }
857
+ recordError(call, code) {
858
+ if (call.stats.errors.length < MAX_ERRORS)
859
+ call.stats.errors.push(code);
860
+ }
861
+ clear(call, timer) {
862
+ clearTimeout(timer);
863
+ call.timers.delete(timer);
864
+ }
865
+ now() {
866
+ return this.deps.now?.() ?? Date.now();
867
+ }
868
+ }