synabun 2.0.5 → 2.0.7

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 (233) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/README.md +2 -1
  3. package/THIRD-PARTY-LICENSES.md +13 -0
  4. package/docs/whatsapp.md +151 -16
  5. package/hooks/claude-code/shared.mjs +6 -3
  6. package/lib/child-path.js +303 -0
  7. package/lib/claude-hooks.js +2 -1
  8. package/lib/data-home-migration.js +2 -0
  9. package/lib/desktop-app.js +265 -0
  10. package/lib/external-tools.js +7 -3
  11. package/lib/log-redact.js +102 -0
  12. package/lib/packaged-runtime.js +138 -11
  13. package/mcp-server/dist/http.d.ts +3 -1
  14. package/mcp-server/dist/http.d.ts.map +1 -1
  15. package/mcp-server/dist/http.js +15 -4
  16. package/mcp-server/dist/http.js.map +1 -1
  17. package/mcp-server/dist/index.d.ts.map +1 -1
  18. package/mcp-server/dist/index.js +9 -6
  19. package/mcp-server/dist/index.js.map +1 -1
  20. package/mcp-server/dist/services/budget-judgments.d.ts +108 -0
  21. package/mcp-server/dist/services/budget-judgments.d.ts.map +1 -0
  22. package/mcp-server/dist/services/budget-judgments.js +153 -0
  23. package/mcp-server/dist/services/budget-judgments.js.map +1 -0
  24. package/mcp-server/dist/services/codex-config-heal.d.ts +5 -1
  25. package/mcp-server/dist/services/codex-config-heal.d.ts.map +1 -1
  26. package/mcp-server/dist/services/codex-config-heal.js +41 -4
  27. package/mcp-server/dist/services/codex-config-heal.js.map +1 -1
  28. package/mcp-server/dist/services/identity.d.ts +11 -0
  29. package/mcp-server/dist/services/identity.d.ts.map +1 -1
  30. package/mcp-server/dist/services/identity.js +24 -0
  31. package/mcp-server/dist/services/identity.js.map +1 -1
  32. package/mcp-server/dist/services/neural-interface.d.ts +13 -0
  33. package/mcp-server/dist/services/neural-interface.d.ts.map +1 -1
  34. package/mcp-server/dist/services/neural-interface.js +34 -2
  35. package/mcp-server/dist/services/neural-interface.js.map +1 -1
  36. package/mcp-server/dist/services/typesafe-config.d.ts +1 -1
  37. package/mcp-server/dist/services/typesafe-config.d.ts.map +1 -1
  38. package/mcp-server/dist/services/typesafe-config.js +4 -1
  39. package/mcp-server/dist/services/typesafe-config.js.map +1 -1
  40. package/mcp-server/dist/tools/agents.d.ts +10 -7
  41. package/mcp-server/dist/tools/agents.d.ts.map +1 -1
  42. package/mcp-server/dist/tools/agents.js +34 -13
  43. package/mcp-server/dist/tools/agents.js.map +1 -1
  44. package/mcp-server/dist/tools/whatsapp-send-file.d.ts +17 -0
  45. package/mcp-server/dist/tools/whatsapp-send-file.d.ts.map +1 -0
  46. package/mcp-server/dist/tools/whatsapp-send-file.js +44 -0
  47. package/mcp-server/dist/tools/whatsapp-send-file.js.map +1 -0
  48. package/mcp-server/dist/tui.js +2 -1
  49. package/mcp-server/dist/tui.js.map +1 -1
  50. package/mcp-server/dist/version.d.ts +5 -0
  51. package/mcp-server/dist/version.d.ts.map +1 -0
  52. package/mcp-server/dist/version.js +21 -0
  53. package/mcp-server/dist/version.js.map +1 -0
  54. package/mcp-server/package-lock.json +2 -2
  55. package/mcp-server/package.json +1 -1
  56. package/mcp-server/run.mjs +15 -9
  57. package/mcp-server/src/http.ts +18 -3
  58. package/mcp-server/src/index.ts +9 -6
  59. package/mcp-server/src/services/budget-judgments.ts +183 -0
  60. package/mcp-server/src/services/codex-config-heal.ts +35 -4
  61. package/mcp-server/src/services/identity.ts +27 -0
  62. package/mcp-server/src/services/neural-interface.ts +34 -2
  63. package/mcp-server/src/services/typesafe-config.ts +4 -1
  64. package/mcp-server/src/tools/agents.ts +33 -14
  65. package/mcp-server/src/tools/whatsapp-send-file.ts +51 -0
  66. package/mcp-server/src/tui.ts +2 -1
  67. package/mcp-server/src/version.ts +22 -0
  68. package/neural-interface/i18n/en.json +627 -57
  69. package/neural-interface/i18n/pt-BR.json +626 -56
  70. package/neural-interface/lib/account-usage.js +163 -0
  71. package/neural-interface/lib/assistant-api.js +255 -52
  72. package/neural-interface/lib/assistant-brains/claude.js +15 -2
  73. package/neural-interface/lib/assistant-brains/codex.js +3 -0
  74. package/neural-interface/lib/assistant-brains/opencode.js +17 -4
  75. package/neural-interface/lib/assistant-budget-planner.js +390 -0
  76. package/neural-interface/lib/assistant-budget-split.js +408 -0
  77. package/neural-interface/lib/assistant-budget.js +94 -95
  78. package/neural-interface/lib/assistant-config.js +62 -14
  79. package/neural-interface/lib/assistant-dispatch.js +432 -125
  80. package/neural-interface/lib/assistant-jev.js +26 -3
  81. package/neural-interface/lib/assistant-persona.js +68 -11
  82. package/neural-interface/lib/assistant-plan-permissions.js +8 -1
  83. package/neural-interface/lib/assistant-route-accounts.js +14 -0
  84. package/neural-interface/lib/assistant-route-gate.js +19 -3
  85. package/neural-interface/lib/assistant-router.js +373 -42
  86. package/neural-interface/lib/assistant-runtime.js +307 -47
  87. package/neural-interface/lib/assistant-user-writer.js +4 -2
  88. package/neural-interface/lib/augmented-path.js +317 -53
  89. package/neural-interface/lib/claude-accounts.js +342 -31
  90. package/neural-interface/lib/claude-agent-bridge.js +90 -3
  91. package/neural-interface/lib/claude-executable.js +6 -1
  92. package/neural-interface/lib/claude-model-catalog.js +3 -3
  93. package/neural-interface/lib/claude-panel-auth.js +221 -0
  94. package/neural-interface/lib/claude-profile-api-key.js +62 -0
  95. package/neural-interface/lib/cli-install-api.js +137 -0
  96. package/neural-interface/lib/cli-install.js +797 -0
  97. package/neural-interface/lib/cli-path.js +130 -0
  98. package/neural-interface/lib/cli-resolver.js +744 -0
  99. package/neural-interface/lib/cli-update-planner.js +143 -7
  100. package/neural-interface/lib/codex-account-auth.js +178 -0
  101. package/neural-interface/lib/codex-accounts.js +489 -0
  102. package/neural-interface/lib/codex-capabilities.js +2 -1
  103. package/neural-interface/lib/codex-compat-rpc.js +8 -2
  104. package/neural-interface/lib/codex-mcp-approvals.js +5 -1
  105. package/neural-interface/lib/codex-panel-auth.js +62 -0
  106. package/neural-interface/lib/codex-session-settings.js +1 -1
  107. package/neural-interface/lib/codex-settings-schema.js +120 -0
  108. package/neural-interface/lib/desktop/config.js +3 -0
  109. package/neural-interface/lib/git-bash/bzip2.js +339 -0
  110. package/neural-interface/lib/git-bash/claude-settings.js +73 -0
  111. package/neural-interface/lib/git-bash/extract-worker.js +34 -0
  112. package/neural-interface/lib/git-bash/install.js +529 -0
  113. package/neural-interface/lib/git-bash/licenses/COPYING +360 -0
  114. package/neural-interface/lib/git-bash/licenses/LGPL-2.1 +511 -0
  115. package/neural-interface/lib/git-bash/manager.js +268 -0
  116. package/neural-interface/lib/git-bash/pins.js +206 -0
  117. package/neural-interface/lib/git-bash/pins.json +34 -0
  118. package/neural-interface/lib/git-bash/resolver.js +194 -0
  119. package/neural-interface/lib/git-read.js +34 -2
  120. package/neural-interface/lib/install-details.js +145 -0
  121. package/neural-interface/lib/mcp-installer.js +14 -2
  122. package/neural-interface/lib/native-loop-providers.js +16 -3
  123. package/neural-interface/lib/native-loop-runtime.js +9 -4
  124. package/neural-interface/lib/os-path.js +319 -0
  125. package/neural-interface/lib/provider-panel-api-key-auth.js +92 -0
  126. package/neural-interface/lib/provider-panel-terminal-auth.js +95 -0
  127. package/neural-interface/lib/pty-host/core.js +2 -1
  128. package/neural-interface/lib/pty-host/manager.js +27 -2
  129. package/neural-interface/lib/remote-policy.js +20 -11
  130. package/neural-interface/lib/session-title-generator.js +4 -3
  131. package/neural-interface/lib/system-about.js +340 -0
  132. package/neural-interface/lib/system-activity.js +66 -0
  133. package/neural-interface/lib/terminal-env.js +11 -3
  134. package/neural-interface/lib/terminal-path.js +55 -0
  135. package/neural-interface/lib/virtual-socket.js +12 -1
  136. package/neural-interface/lib/whatsapp/baileys-adapter.js +165 -45
  137. package/neural-interface/lib/whatsapp/bridge.js +78 -34
  138. package/neural-interface/lib/whatsapp/commands.js +13 -14
  139. package/neural-interface/lib/whatsapp/config.js +134 -20
  140. package/neural-interface/lib/whatsapp/fake-baileys.js +99 -14
  141. package/neural-interface/lib/whatsapp/host-core.js +168 -35
  142. package/neural-interface/lib/whatsapp/inbound.js +5 -3
  143. package/neural-interface/lib/whatsapp/local-file.js +524 -0
  144. package/neural-interface/lib/whatsapp/manager.js +92 -32
  145. package/neural-interface/lib/whatsapp/paths.js +11 -1
  146. package/neural-interface/lib/whatsapp/protocol.js +65 -5
  147. package/neural-interface/lib/whatsapp/redact.js +63 -0
  148. package/neural-interface/lib/whatsapp/service.js +343 -91
  149. package/neural-interface/lib/whatsapp/transcription.js +463 -0
  150. package/neural-interface/package-lock.json +2 -2
  151. package/neural-interface/package.json +1 -1
  152. package/neural-interface/public/index.html +2 -0
  153. package/neural-interface/public/index2d.html +2 -0
  154. package/neural-interface/public/offline.html +16 -1
  155. package/neural-interface/public/onboarding.html +96 -2
  156. package/neural-interface/public/shared/account-i18n.js +7 -0
  157. package/neural-interface/public/shared/account-menu.js +539 -0
  158. package/neural-interface/public/shared/account-usage.js +19 -0
  159. package/neural-interface/public/shared/account-view.js +335 -0
  160. package/neural-interface/public/shared/agent-runtime-options.js +28 -8
  161. package/neural-interface/public/shared/assistant/asst-accounts.js +182 -184
  162. package/neural-interface/public/shared/assistant/asst-api.js +32 -2
  163. package/neural-interface/public/shared/assistant/asst-brain-picker.js +124 -25
  164. package/neural-interface/public/shared/assistant/asst-budget.js +259 -303
  165. package/neural-interface/public/shared/assistant/asst-mascot.js +126 -7
  166. package/neural-interface/public/shared/assistant/asst-menu.js +15 -3
  167. package/neural-interface/public/shared/assistant/asst-panel.js +48 -14
  168. package/neural-interface/public/shared/assistant/asst-render.js +38 -7
  169. package/neural-interface/public/shared/assistant/asst-route.js +72 -14
  170. package/neural-interface/public/shared/assistant/asst-routes-editor.js +45 -6
  171. package/neural-interface/public/shared/assistant/asst-slash.js +1 -1
  172. package/neural-interface/public/shared/assistant/asst-state.js +1 -1
  173. package/neural-interface/public/shared/assistant/asst-styles.js +99 -17
  174. package/neural-interface/public/shared/cdx/cdx-commands.js +4 -1
  175. package/neural-interface/public/shared/cdx/cdx-features.js +22 -14
  176. package/neural-interface/public/shared/cdx/cdx-icons.js +2 -0
  177. package/neural-interface/public/shared/cdx/cdx-panel.js +48 -107
  178. package/neural-interface/public/shared/cdx/cdx-settings.js +231 -0
  179. package/neural-interface/public/shared/cdx/cdx-styles.js +128 -107
  180. package/neural-interface/public/shared/cdx/cdx-tabs.js +261 -664
  181. package/neural-interface/public/shared/cdx/cdx-ws.js +2 -0
  182. package/neural-interface/public/shared/claude-account-view.js +3 -0
  183. package/neural-interface/public/shared/cli-install-view.js +392 -0
  184. package/neural-interface/public/shared/cli-status.js +282 -22
  185. package/neural-interface/public/shared/cp/cp-events.js +2 -1
  186. package/neural-interface/public/shared/cp/cp-styles.js +46 -0
  187. package/neural-interface/public/shared/desktop-bridge.js +39 -0
  188. package/neural-interface/public/shared/desktop-titlebar-page.js +59 -0
  189. package/neural-interface/public/shared/desktop-titlebar-view.js +122 -0
  190. package/neural-interface/public/shared/desktop-titlebar.js +153 -0
  191. package/neural-interface/public/shared/desktop-update-view.js +462 -0
  192. package/neural-interface/public/shared/editor/editor-panel.js +1 -1
  193. package/neural-interface/public/shared/explorer/fx-main.js +1 -1
  194. package/neural-interface/public/shared/git-bash-view.js +145 -0
  195. package/neural-interface/public/shared/html-shell.js +1 -0
  196. package/neural-interface/public/shared/i18n.js +19 -1
  197. package/neural-interface/public/shared/install-details-view.js +86 -0
  198. package/neural-interface/public/shared/ocp-v2/ocp-v2-panel.js +32 -0
  199. package/neural-interface/public/shared/ocp-v2/ocp-v2-send.js +17 -0
  200. package/neural-interface/public/shared/ocp-v2/ocp-v2-styles.js +118 -0
  201. package/neural-interface/public/shared/pwa-install-page.js +3 -1
  202. package/neural-interface/public/shared/settings/settings-about-pane.js +271 -0
  203. package/neural-interface/public/shared/settings/settings-about.js +254 -0
  204. package/neural-interface/public/shared/settings/settings-ia.js +15 -0
  205. package/neural-interface/public/shared/showcase-privacy.js +489 -0
  206. package/neural-interface/public/shared/storage.js +30 -3
  207. package/neural-interface/public/shared/styles.css +278 -31
  208. package/neural-interface/public/shared/synabun-mascot.js +48 -2
  209. package/neural-interface/public/shared/ui-claude-panel.js +212 -213
  210. package/neural-interface/public/shared/ui-cli-banner.js +355 -0
  211. package/neural-interface/public/shared/ui-git-bash.js +124 -0
  212. package/neural-interface/public/shared/ui-judgments.js +10 -1
  213. package/neural-interface/public/shared/ui-loading.js +30 -6
  214. package/neural-interface/public/shared/ui-menubar.js +13 -3
  215. package/neural-interface/public/shared/ui-navbar.js +3 -1
  216. package/neural-interface/public/shared/ui-provider-auth.js +104 -0
  217. package/neural-interface/public/shared/ui-provider-terminal-auth.js +223 -0
  218. package/neural-interface/public/shared/ui-sessions.js +9 -3
  219. package/neural-interface/public/shared/ui-settings.js +55 -3
  220. package/neural-interface/public/shared/ui-sidepanel-sessions.js +3 -1
  221. package/neural-interface/public/shared/ui-sync.js +6 -0
  222. package/neural-interface/public/shared/ui-terminal.js +17 -0
  223. package/neural-interface/public/shared/ui-tool-updates.js +5 -0
  224. package/neural-interface/public/shared/ui-update.js +594 -5
  225. package/neural-interface/public/shared/ui-whatsapp.js +21 -6
  226. package/neural-interface/public/shared/whatsapp/wa-view.js +51 -8
  227. package/neural-interface/public/shared/worker-end.js +2 -0
  228. package/neural-interface/public/sw.js +1 -1
  229. package/neural-interface/public/variant/2d/main.js +10 -1
  230. package/neural-interface/public/variant/3d/main.js +10 -1
  231. package/neural-interface/server.js +1219 -608
  232. package/package.json +4 -1
  233. package/setup.js +9 -8
@@ -12,7 +12,10 @@
12
12
  // unavailable | not_installed | installing | install_failed | ready |
13
13
  // linking (method qr|code; phase starting|waiting|scanned) | link_expired |
14
14
  // confirm_owner (kind self|code) | connected | reconnecting | paused |
15
- // logged_out (reason removed|inactive|banned) | error (code)
15
+ // logged_out (reason removed|inactive|banned|invalid_session) | error (code)
16
+ // `warning` ('replaced' | 'forbidden' | null) rides along: a saved session
17
+ // that another copy took over (440) or that WhatsApp refuses (403) keeps
18
+ // retrying every 5 minutes, and Settings says why until it is stable again.
16
19
  //
17
20
  // Secrets keep to one path each: a link QR or pairing code only goes down the
18
21
  // NDJSON response of the tab that asked for it, the claim code and wa.me link
@@ -30,14 +33,16 @@ import { createWhatsAppApi } from './api.js';
30
33
  import { brainChoiceView, checkBrainChoice, cleanBrainChoice } from './brain.js';
31
34
  import { createWhatsAppBridge, outboxPlan } from './bridge.js';
32
35
  import { parseCommand } from './commands.js';
33
- import { createWhatsAppConfigStore, currentLevel, MODES, SERVICE_DEFAULTS, USER_DEFAULTS, USER_FIELDS } from './config.js';
36
+ import { createWhatsAppConfigStore, currentLevel, MODES, SERVICE_DEFAULTS, TRANSCRIPTION_FIELDS, USER_DEFAULTS, USER_FIELDS } from './config.js';
34
37
  import { chunk, toWhatsApp } from './format.js';
35
38
  import { composePrompt, createCoalescer } from './inbound.js';
36
39
  import { createWhatsAppInstaller, NPM_CI_ARGS } from './installer.js';
37
40
  import { createWhatsAppManager } from './manager.js';
38
41
  import { isInside, resolveWhatsAppPaths } from './paths.js';
39
42
  import { renderQrSvg } from './qr-svg.js';
40
- import { maskNumber, redactWa } from './redact.js';
43
+ import { CONN_WARNINGS, FILE_SEND_AS, LIMITS, VOICE_MARKER } from './protocol.js';
44
+ import { resolveTranscriptionTools, TRANSCRIPTION_LIMITS } from './transcription.js';
45
+ import { maskNumber, redactFilePath, redactLogLine, redactRequestedPath, redactWa } from './redact.js';
41
46
  import { providerLabel, remoteBrainLimited, remoteBrainNotice } from '../remote-policy.js';
42
47
  import { normalizePhone, PHONE_ERRORS } from '../../public/shared/whatsapp/wa-phone.js';
43
48
 
@@ -51,12 +56,12 @@ export const SERVICE_LIMITS = Object.freeze({
51
56
  escalationMs: 5 * MINUTE, // the phone has this long to answer ALLOW <code>
52
57
  escalationTries: 3,
53
58
  escalationEchoMs: 10 * MINUTE, // a late ALLOW after this is passed on as text
54
- autonomousMs: 8 * HOUR,
55
59
  testEveryMs: 20_000,
56
60
  activityMax: 200,
57
61
  logRetentionDays: 7,
58
62
  broadcastDebounceMs: 150,
59
63
  inactiveAfterMs: 13 * DAY, // logged out after this long without a connection reads as "inactive"
64
+ warningStableMs: 10 * MINUTE, // a 440 / 403 warning clears after this long connected (the host's stable time)
60
65
  });
61
66
 
62
67
  export const TEST_MESSAGE = 'SynaBun test message. If you can read this, WhatsApp is working.';
@@ -80,7 +85,6 @@ export function whatsappError(status, code, message, field) {
80
85
  return error;
81
86
  }
82
87
 
83
- const hhmm = (ms) => new Date(ms).toTimeString().slice(0, 5);
84
88
  const ymd = (ms) => { const d = new Date(ms); return `${d.getFullYear()}${String(d.getMonth() + 1).padStart(2, '0')}${String(d.getDate()).padStart(2, '0')}`; };
85
89
  const pick = (obj, keys) => Object.fromEntries(keys.map((key) => [key, obj[key]]));
86
90
  /** "ABCD1234" → "ABCD-1234" (how WhatsApp shows a pairing code). */
@@ -104,6 +108,8 @@ export function formatPairingCode(code) {
104
108
  * @param {Function} [deps.bridgeFactory] (deps) → createWhatsAppBridge-shaped object
105
109
  * @param {() => object|null} [deps.getDefaultBrain] the Assistant panel's brain ("Same as the Assistant")
106
110
  * @param {() => object|null} [deps.getCatalog] the Assistant's model catalog ({ peek(), full() }): the WhatsApp brain is checked against it
111
+ * @param {Function} [deps.checkTranscription] ({ home, enginePath, modelPath, ffmpegPath, env, platform }) → what voice notes
112
+ * need and lack (transcription.js resolveTranscriptionTools; tests pass a fake). Read only: nothing is run here.
107
113
  */
108
114
  export function createWhatsAppService({
109
115
  dataHome,
@@ -128,6 +134,7 @@ export function createWhatsAppService({
128
134
  policy = undefined,
129
135
  getDefaultBrain = null,
130
136
  getCatalog = null,
137
+ checkTranscription = resolveTranscriptionTools,
131
138
  rng = undefined,
132
139
  bridgeLimits = undefined,
133
140
  now = Date.now,
@@ -140,11 +147,22 @@ export function createWhatsAppService({
140
147
  const disabled = String(env.SYNABUN_WHATSAPP || '').trim().toLowerCase() === 'off';
141
148
  const fake = env.SYNABUN_WHATSAPP_FAKE === '1';
142
149
  const paths = pathsOverride || resolveWhatsAppPaths({ dataHome, env, platform, extraBackupRoots });
143
- const say = (message, level = 'info') => { try { log(`[whatsapp] ${redactWa(message)}`, level); } catch {} };
150
+ // Every line passes redactWa, but send_file's audit line, whose exact shape redact.js fileAuditLine checks.
151
+ const say = (message, level = 'info') => { try { log(`[whatsapp] ${redactLogLine(message)}`, level); } catch {} };
144
152
  // A refused SYNABUN_WHATSAPP_HOME (inside a backed-up folder) says so once, in the server log.
145
153
  for (const warning of paths.warnings || []) say(warning, 'warn');
146
154
 
147
- const config = createWhatsAppConfigStore({ get: getKvConfig, set: setKvConfig, now, log: (tag, msg) => say(`${tag}: ${msg}`, 'warn') });
155
+ const config = createWhatsAppConfigStore({
156
+ get: getKvConfig, set: setKvConfig, now, log: (tag, msg) => say(`${tag}: ${msg}`, 'warn'),
157
+ // An 8-hour Autonomous of an earlier version was read (and stored) as Ask: one Activity line says so.
158
+ // Called from a config read, so never before `activity` below exists: no read happens while this function builds.
159
+ onLegacyAutonomous: () => {
160
+ try {
161
+ record('security', 'Autonomous from an earlier version ended; turn it on again to keep it on without a time limit');
162
+ notify();
163
+ } catch {}
164
+ },
165
+ });
148
166
  const installer = disabled ? null : installerFactory({ dataHome, paths, env, platform, log: (msg, level) => say(msg, level), now });
149
167
  const manager = disabled ? null : managerFactory({ dataHome, paths, env, platform, log: (msg, level) => say(msg, level), now });
150
168
 
@@ -160,15 +178,18 @@ export function createWhatsAppService({
160
178
  fatal: null, // { code, message, at }
161
179
  escalation: null, // { code, expiresAt, attemptsLeft, timer }
162
180
  escalationEndedAt: 0,
163
- autonomousTimer: null,
164
181
  retentionTimer: null,
165
182
  lastConn: null, // last conn snapshot (for transitions)
166
183
  lastStateAt: 0,
184
+ lastDisconnectAt: null, // conn.lastDisconnect.at of the last disconnect already looked at
185
+ warning: null, // { kind: 'replaced'|'forbidden', since } while a 440 / 403 episode lasts
186
+ warningTimer: null, // clears the warning once the connection was open for warningStableMs
167
187
  connectedAt: 0,
168
188
  reconnectAttempt: 0,
169
189
  wasConnected: false,
170
190
  testAt: 0,
171
191
  notesSkipped: 0, // self-chat notes without the "sb" trigger (never logged)
192
+ transcription: null, // { at, key, value }: what voice notes need and lack, looked at again at most every minute
172
193
  bridge: null,
173
194
  rev: 0,
174
195
  lastSignature: '',
@@ -188,7 +209,7 @@ export function createWhatsAppService({
188
209
  return value;
189
210
  }
190
211
  function managerStatus() {
191
- const empty = { host: { state: 'stopped', pid: null, restarts: 0, lastError: null }, conn: { state: 'idle', mode: null, registered: false, me: null, owner: null, lastDisconnect: null, retryInMs: null, counters: {}, paused: false, awaitingConfirmUntil: null, claim: null, lastError: null }, runtime: { loaded: false, version: null, fake: false, error: null } };
212
+ const empty = { host: { state: 'stopped', pid: null, restarts: 0, lastError: null, retryInMs: null }, conn: { state: 'idle', mode: null, registered: false, me: null, owner: null, lastDisconnect: null, retryInMs: null, counters: {}, paused: false, awaitingConfirmUntil: null, claim: null, lastError: null }, runtime: { loaded: false, version: null, fake: false, error: null } };
192
213
  if (!manager) return empty;
193
214
  try { const s = manager.status(); return { host: { ...empty.host, ...(s?.host || {}) }, conn: { ...empty.conn, ...(s?.conn || {}) }, runtime: { ...empty.runtime, ...(s?.runtime || {}) } }; }
194
215
  catch { return empty; }
@@ -203,7 +224,10 @@ export function createWhatsAppService({
203
224
  }
204
225
  // The host's own "sb" filter stays off (selfTrigger 'all'): it would drop /stop,
205
226
  // /status and card answers too. selfTrigger 'prefix' is applied here (applyTrigger).
206
- function prefsOf(cfg) { return { mode: cfg.mode, selfTrigger: 'all' }; }
227
+ // The voice-note settings go to the host, which transcribes (lib/whatsapp/transcription.js).
228
+ function prefsOf(cfg) {
229
+ return { mode: cfg.mode, selfTrigger: 'all', ...pick(cfg, TRANSCRIPTION_FIELDS) };
230
+ }
207
231
  function configureHost(cfg = config.read()) {
208
232
  if (!manager) return;
209
233
  Promise.resolve(manager.configure?.(prefsOf(cfg))).catch(() => {});
@@ -212,6 +236,32 @@ export function createWhatsAppService({
212
236
  if (cfg.replyLabel) return cfg.replyLabel;
213
237
  return cfg.mode === 'dedicated' ? '' : 'SynaBun:';
214
238
  }
239
+ /**
240
+ * Voice notes as Settings shows them: { enabled, available, missing, invalid, language, home }. The
241
+ * same resolution the host makes before it downloads a note (the host checks again right before a
242
+ * program runs); missing / invalid are 'engine' | 'model' | 'ffmpeg'. Read only, cached a minute.
243
+ */
244
+ function transcriptionView(cfg = config.read()) {
245
+ const key = JSON.stringify([cfg.transcriptionEnginePath, cfg.transcriptionModelPath, cfg.transcriptionFfmpegPath]);
246
+ let tools = state.transcription && state.transcription.key === key && now() - state.transcription.at < TRANSCRIPTION_LIMITS.recheckMs ? state.transcription.value : null;
247
+ if (!tools) {
248
+ try {
249
+ tools = checkTranscription({ home: paths.transcriptionDir, enginePath: cfg.transcriptionEnginePath, modelPath: cfg.transcriptionModelPath, ffmpegPath: cfg.transcriptionFfmpegPath, env, platform });
250
+ } catch {
251
+ tools = null;
252
+ }
253
+ if (!tools || !Array.isArray(tools.missing)) tools = { missing: ['engine', 'model', 'ffmpeg'], invalid: [], available: false };
254
+ state.transcription = { at: now(), key, value: tools };
255
+ }
256
+ return {
257
+ enabled: cfg.transcriptionEnabled === true,
258
+ available: tools.available === true,
259
+ missing: [...tools.missing],
260
+ invalid: Array.isArray(tools.invalid) ? [...tools.invalid] : [],
261
+ language: cfg.transcriptionLanguage,
262
+ home: paths.transcriptionDir || null,
263
+ };
264
+ }
215
265
 
216
266
  // ── the tab's state machine ────────────────────────────────────────────────
217
267
  function logoutReason(cfg) {
@@ -221,7 +271,6 @@ export function createWhatsAppService({
221
271
  function errorCodeOf(conn) {
222
272
  if (state.fatal?.code && state.fatal.code !== 'LOGGED_OUT') return state.fatal.code;
223
273
  const reason = String(conn.lastError || '');
224
- if (reason === 'reconnect_limit') return 'RECONNECT_LIMIT';
225
274
  if (reason === 'pairing_code_failed') return 'PAIRING_FAILED';
226
275
  return 'SESSION_ERROR';
227
276
  }
@@ -270,7 +319,7 @@ export function createWhatsAppService({
270
319
  const tab = tabState();
271
320
  const inst = installerStatus();
272
321
  const conn = managerStatus().conn;
273
- const level = currentLevel(cfg, now());
322
+ const level = currentLevel(cfg);
274
323
  const counters = {};
275
324
  for (const [key, value] of Object.entries(conn.counters || {})) if (Number.isFinite(value)) counters[key] = value;
276
325
  return {
@@ -280,13 +329,18 @@ export function createWhatsAppService({
280
329
  phase: tab.phase || null,
281
330
  mode: cfg.mode,
282
331
  level,
283
- levelExpiresAt: level === 'autonomous' ? cfg.autonomousUntil : null,
332
+ // Autonomous has no time limit: kept for older readers, always null.
333
+ levelExpiresAt: null,
284
334
  paused: cfg.paused,
285
335
  pausedBy: cfg.pausedBy,
286
336
  // The owner's switch for computer use from WhatsApp (what it allows per level: remoteComputerUse).
287
337
  computerUse: cfg.computerUse === true,
288
338
  // Which brain runs the WhatsApp conversation: null = same as the Assistant.
289
339
  brain: cfg.brain ? { provider: cfg.brain.provider, model: cfg.brain.model, effort: cfg.brain.effort || null } : null,
340
+ // A 440 / 403 the link keeps retrying: 'replaced' | 'forbidden' | null (its own field, so a clear re-reads every tab).
341
+ warning: state.warning?.kind || null,
342
+ // Voice notes on and set up (no path, no list of what is missing: the status has those).
343
+ transcription: (() => { const v = transcriptionView(cfg); return { enabled: v.enabled, available: v.available }; })(),
290
344
  rev: state.rev,
291
345
  connector: { installed: !!inst.installed, stage: state.install?.stage || null, updateAvailable: !!inst.outdated, errorCode: state.installError?.code || null },
292
346
  counters,
@@ -320,9 +374,17 @@ export function createWhatsAppService({
320
374
  if (state.logFile !== file) { state.logFile = file; try { chmodSync(file, 0o600); } catch {} }
321
375
  } catch {}
322
376
  }
323
- /** One activity row. `text` (a message body) is kept only while activityText is on. */
324
- function record(kind, detail, text = null) {
325
- const entry = { at: now(), kind, detail: redactWa(String(detail || '')).slice(0, 300) };
377
+ /**
378
+ * One activity row. `text` (a message body) is kept only while activityText is on. `filePath`: a
379
+ * file's path, appended through redactFilePath (redactWa would read most paths as key material);
380
+ * the rest of `detail` is SynaBun's own words and goes through redactWa as always.
381
+ */
382
+ function record(kind, detail, text = null, { filePath = null, requested = false } = {}) {
383
+ const words = redactWa(String(detail || ''));
384
+ // A path can hold control characters (a file or a link target named with a newline): never a second log line.
385
+ // A refused request's path is the model's own text: key-shaped runs in it are masked too (redactRequestedPath).
386
+ const shownPath = filePath ? (requested ? redactRequestedPath : redactFilePath)(filePath).replace(/[\u0000-\u001f\u007f\u2028\u2029]/g, '?') : '';
387
+ const entry = { at: now(), kind, detail: (filePath ? `${words} ${shownPath}` : words).slice(0, filePath ? 600 : 300) };
326
388
  if (text && config.read().activityText) entry.text = redactWa(String(text)).slice(0, 2000);
327
389
  activity.push(entry);
328
390
  if (activity.length > L.activityMax) activity.splice(0, activity.length - L.activityMax);
@@ -338,12 +400,19 @@ export function createWhatsAppService({
338
400
  if (name.slice(9, 17) < cutoff) { try { unlinkSync(path.join(paths.logsDir, name)); } catch {} }
339
401
  }
340
402
  }
403
+ // Why a voice note was not transcribed (the host's reason), for the activity log.
404
+ const AUDIO_REASONS = Object.freeze({
405
+ transcription_disabled: 'transcription is off', transcription_unavailable: 'transcription is not set up',
406
+ too_large: 'over 20 MB', too_long: 'over 10 minutes', stale: 'sent while SynaBun was offline', transcription_failed: 'transcription failed',
407
+ });
341
408
  function describeInbound(message) {
342
409
  const parts = [];
343
- if (String(message.text || '').trim()) parts.push('text');
410
+ if (message.voice === true) parts.push('a voice note (transcribed on this computer)');
411
+ else if (String(message.text || '').trim()) parts.push('text');
344
412
  const images = Array.isArray(message.images) ? message.images.length : 0;
345
413
  if (images) parts.push(`${images} image${images === 1 ? '' : 's'}`);
346
- if (message.unsupported) parts.push(`${String(message.unsupported.type || 'unsupported')} (not supported)`);
414
+ if (message.unsupported?.type === 'audio' && Object.hasOwn(AUDIO_REASONS, String(message.unsupported.reason))) parts.push(`a voice note (not transcribed: ${AUDIO_REASONS[message.unsupported.reason]})`);
415
+ else if (message.unsupported) parts.push(`${String(message.unsupported.type || 'unsupported')} (not supported)`);
347
416
  const flags = [message.forwarded ? 'forwarded' : null, message.quoted ? (message.quoted.fromBot ? 'reply to SynaBun' : 'quote') : null].filter(Boolean);
348
417
  return `Message from you: ${parts.join(', ') || 'empty'}${flags.length ? ` (${flags.join(', ')})` : ''}`;
349
418
  }
@@ -352,11 +421,12 @@ export function createWhatsAppService({
352
421
  // Why a refused reply was dropped (the bridge's outboxPlan decides; the phone cannot be told).
353
422
  const DROPPED_BECAUSE = {
354
423
  PAUSED: 'sending is paused because the owner looked different than expected. See Setup & link',
355
- HELD: 'the WhatsApp connector kept crashing and was stopped. Press Reconnect',
424
+ HELD: 'the WhatsApp connector does not match this SynaBun version (an update was installed while it ran). Restart SynaBun',
356
425
  };
426
+ const NO_MANAGER = Object.freeze({ ok: false, code: 'UNAVAILABLE', message: 'WhatsApp is not running on this computer' });
357
427
  const transport = {
358
428
  send: async (text, opts = {}) => {
359
- const result = await manager.send(text, opts);
429
+ const result = manager ? await manager.send(text, opts) : NO_MANAGER;
360
430
  if (result?.ok) record('reply', `Sent a message (${String(text).length} characters)`, text);
361
431
  else {
362
432
  const code = String(result?.code || 'unknown');
@@ -368,9 +438,9 @@ export function createWhatsAppService({
368
438
  notify();
369
439
  return result;
370
440
  },
371
- react: (id, name) => manager.react(id, name),
372
- presence: (value) => manager.presence(value),
373
- markRead: (ids) => manager.markRead(ids),
441
+ react: async (id, name) => (manager ? manager.react(id, name) : NO_MANAGER),
442
+ presence: async (value) => (manager ? manager.presence(value) : NO_MANAGER),
443
+ markRead: async (ids) => (manager ? manager.markRead(ids) : NO_MANAGER),
374
444
  connected: () => connectedNow(),
375
445
  };
376
446
  const bridgeConfig = { read: () => config.read(), write: (patch) => config.write(patch) };
@@ -409,7 +479,7 @@ export function createWhatsAppService({
409
479
  }
410
480
  function bridgeStatus() { try { return state.bridge?.status?.() || null; } catch { return null; } }
411
481
  function refreshBridge() { try { state.bridge?.refresh?.(); } catch {} }
412
- /** A notice from SynaBun itself (escalation, autonomous expiry): straight to the phone, labelled like the bridge's replies. */
482
+ /** A notice from SynaBun itself (the Autonomous request and its ALLOW answer): straight to the phone, labelled like the bridge's replies. */
413
483
  async function notice(text) {
414
484
  if (!manager || !connectedNow()) return false;
415
485
  const prefix = replyPrefix();
@@ -417,7 +487,7 @@ export function createWhatsAppService({
417
487
  return !!result?.ok;
418
488
  }
419
489
 
420
- // ── autonomous: the phone's ALLOW code, the 8 h window ────────────────────
490
+ // ── autonomous: the phone's ALLOW code; on until the owner lowers it ───────
421
491
  function endEscalation(reason) {
422
492
  const esc = state.escalation;
423
493
  if (!esc) return;
@@ -439,36 +509,15 @@ export function createWhatsAppService({
439
509
  esc.timer?.unref?.();
440
510
  state.escalation = esc;
441
511
  record('security', 'Autonomous requested on this computer; waiting for the ALLOW code from the phone');
442
- notice('Autonomous was requested on your computer. To turn it on for 8 hours, reply ALLOW followed by the 4-digit code shown in SynaBun, within 5 minutes. Ignore this message if it was not you.').catch(() => {});
512
+ notice('Autonomous was requested on your computer. To turn it on, reply ALLOW followed by the 4-digit code shown in SynaBun, within 5 minutes. Ignore this message if it was not you.').catch(() => {});
443
513
  notify();
444
514
  return { code, expiresAt: esc.expiresAt, attemptsLeft: esc.attemptsLeft };
445
515
  }
446
- function armAutonomousTimer() {
447
- T.clearTimeout(state.autonomousTimer);
448
- state.autonomousTimer = null;
449
- const cfg = config.read();
450
- if (cfg.level !== 'autonomous') return;
451
- const left = (cfg.autonomousUntil || 0) - now();
452
- if (left <= 0) { expireAutonomous(); return; }
453
- // setTimeout caps at 2^31-1 ms; 8 h is far below it.
454
- state.autonomousTimer = T.setTimeout(expireAutonomous, left);
455
- state.autonomousTimer?.unref?.();
456
- }
457
- function expireAutonomous() {
458
- state.autonomousTimer = null;
459
- const cfg = config.read();
460
- if (cfg.level !== 'autonomous') return;
461
- config.write({ level: 'ask', autonomousUntil: null });
462
- refreshBridge();
463
- record('security', 'Autonomous ended; back to Ask on my phone');
464
- notice('Autonomous ended: I ask on your phone again before acting.').catch(() => {});
465
- notify();
466
- }
467
516
  const sameCode = (a, b) => { const x = Buffer.from(String(a)); const y = Buffer.from(String(b)); return x.length === y.length && timingSafeEqual(x, y); };
468
- /** "ALLOW 1234" from the owner: complete, refuse or explain. → true when consumed. */
517
+ /** "ALLOW 1234" typed by the owner: complete, refuse or explain. → true when consumed. A voice note never is one. */
469
518
  function handleAllow(message) {
470
519
  const match = /^\s*allow\s+(\d{4})\s*$/i.exec(String(message.text || ''));
471
- if (!match || message.forwarded || (Array.isArray(message.images) && message.images.length)) return false;
520
+ if (!match || message.forwarded || message.voice === true || (Array.isArray(message.images) && message.images.length)) return false;
472
521
  const esc = state.escalation;
473
522
  if (!esc) {
474
523
  if (state.escalationEndedAt && now() - state.escalationEndedAt < L.escalationEchoMs) {
@@ -493,17 +542,55 @@ export function createWhatsAppService({
493
542
  }
494
543
  return true;
495
544
  }
496
- const until = now() + L.autonomousMs;
497
- config.write({ level: 'autonomous', autonomousUntil: until });
545
+ // No time limit: Autonomous stays on, restarts included, until the owner lowers it in Settings.
546
+ config.write({ level: 'autonomous', autonomousUntil: null });
498
547
  endEscalation('allowed');
499
548
  refreshBridge();
500
- armAutonomousTimer();
501
- record('security', `Autonomous turned on from the phone until ${hhmm(until)}`);
502
- notice(`Autonomous is on until ${hhmm(until)}. I act without asking for 8 hours, then go back to Ask. Send /pause to stop everything.`).catch(() => {});
549
+ record('security', 'Autonomous turned on from the phone; it stays on until it is turned off in Settings → WhatsApp');
550
+ notice('Autonomous is on. It stays on until you turn it off in Settings → WhatsApp on your computer. /pause pauses your messages and /stop stops the current task; neither changes the level.').catch(() => {});
503
551
  notify();
504
552
  return true;
505
553
  }
506
554
 
555
+ // ── a saved session another copy took over (440) or WhatsApp refuses (403) ─
556
+ // The host keeps retrying it every 5 minutes; this is what Settings shows meanwhile. One
557
+ // Activity line opens an episode and one closes it, after 10 minutes connected: the
558
+ // connects and drops in between are not logged one by one (the server log has each).
559
+ const WARNING_ACTIVITY = Object.freeze({
560
+ replaced: 'Another copy is using this WhatsApp session; SynaBun keeps retrying every 5 minutes',
561
+ forbidden: 'WhatsApp refused this account (it may be restricted or banned); SynaBun keeps retrying every 5 minutes',
562
+ });
563
+ function noteWarning(kind) {
564
+ if (!WARNING_ACTIVITY[kind]) return;
565
+ clearWarningTimer();
566
+ if (state.warning?.kind === kind) return;
567
+ state.warning = { kind, since: now() };
568
+ record('security', WARNING_ACTIVITY[kind]);
569
+ }
570
+ function clearWarningTimer() {
571
+ if (!state.warningTimer) return;
572
+ T.clearTimeout(state.warningTimer);
573
+ state.warningTimer = null;
574
+ }
575
+ function clearWarning() {
576
+ clearWarningTimer();
577
+ state.warning = null;
578
+ }
579
+ /** The connection is open: once it stays so for warningStableMs, the episode is over. */
580
+ function armWarningClear() {
581
+ if (!state.warning || state.warningTimer) return;
582
+ state.warningTimer = T.setTimeout(() => {
583
+ state.warningTimer = null;
584
+ if (!state.warning || !connectedNow()) return;
585
+ state.warning = null;
586
+ record('state', 'The WhatsApp connection is stable again');
587
+ notify();
588
+ }, L.warningStableMs);
589
+ state.warningTimer?.unref?.();
590
+ }
591
+ // A saved session WhatsApp itself rejected: a stream error naming a bad session, or a QR for linked credentials.
592
+ const INVALID_SESSION_ERRORS = new Set(['badSession', 'session_rejected']);
593
+
507
594
  // ── manager events ─────────────────────────────────────────────────────────
508
595
  function onState(snapshot) {
509
596
  const conn = snapshot?.conn || {};
@@ -511,17 +598,25 @@ export function createWhatsAppService({
511
598
  state.lastConn = conn;
512
599
  state.lastStateAt = now();
513
600
  const cfg = config.read();
601
+ // A disconnect this service has not looked at yet (a respawned host's conn repeats the last one).
602
+ const drop = conn.lastDisconnect && conn.lastDisconnect.at !== state.lastDisconnectAt ? conn.lastDisconnect : null;
603
+ if (drop) state.lastDisconnectAt = drop.at;
604
+ const warned = drop && conn.state === 'reconnecting' ? CONN_WARNINGS[drop.code] || null : null;
605
+ const episode = !!state.warning || !!warned;
514
606
  if (conn.state === 'open' && prev?.state !== 'open') {
515
607
  state.connectedAt = now();
516
608
  state.reconnectAttempt = 0;
517
609
  state.fatal = null;
518
610
  config.write({ setup: { ...(cfg.setup || {}), connectedAt: now(), lastEnd: null, ...(conn.registered && !cfg.setup?.linkedAt ? { linkedAt: now() } : {}) } });
519
- record('state', 'Connected to WhatsApp');
611
+ if (!episode) record('state', 'Connected to WhatsApp');
520
612
  }
613
+ if (conn.state === 'open') armWarningClear();
614
+ else clearWarningTimer();
521
615
  if (conn.state === 'reconnecting' && prev?.state !== 'reconnecting') {
522
616
  state.reconnectAttempt += 1;
523
- if (prev?.state === 'open') record('state', 'Connection lost; reconnecting');
617
+ if (prev?.state === 'open' && !episode) record('state', drop ? `Connection lost (code ${drop.code ?? 'none'}, ${drop.reason}); reconnecting` : 'Connection lost; reconnecting');
524
618
  }
619
+ if (warned) noteWarning(warned);
525
620
  // The link's phase follows the host: a QR/code on screen is "waiting", the phone accepting it is "scanned".
526
621
  // Terminal states only count once the host took the link request (a fresh host starts out "unlinked").
527
622
  const link = state.link;
@@ -542,10 +637,14 @@ export function createWhatsAppService({
542
637
  }
543
638
  if (!claim?.active && !conn.owner?.bound && prev?.claim?.active) endClaim({ type: 'expired' });
544
639
  }
545
- if (['logged_out', 'forbidden'].includes(conn.state) && prev?.state !== conn.state) onLoggedOut(conn.state === 'forbidden' ? 'banned' : logoutReason(cfg));
640
+ // 'forbidden' is a link attempt WhatsApp refused: a saved session retries a 403 instead (the warning above).
641
+ if (['logged_out', 'forbidden'].includes(conn.state) && prev?.state !== conn.state) {
642
+ onLoggedOut(conn.state === 'forbidden' ? 'banned' : INVALID_SESSION_ERRORS.has(conn.lastError) ? 'invalid_session' : logoutReason(cfg));
643
+ }
546
644
  if (conn.state === 'unlinked' && prev && prev.state !== 'unlinked' && !state.link) {
547
- // Confirmation refused or timed out, or the host wiped the link.
645
+ // Confirmation refused or timed out, or the host wiped the link: nothing is left to retry.
548
646
  config.write({ owner: null, setup: cfg.setup ? { ...cfg.setup, linkedAt: null } : null });
647
+ clearWarning();
549
648
  }
550
649
  // Every state change reaches the bridge (it sends the restart note and flushes its outbox on connect).
551
650
  const connected = conn.state === 'open';
@@ -554,14 +653,21 @@ export function createWhatsAppService({
554
653
  try { const r = bridge?.onConnection?.({ connected, state: conn.state }); if (r?.catch) r.catch(() => {}); } catch {}
555
654
  notify();
556
655
  }
656
+ const LOGGED_OUT_ACTIVITY = Object.freeze({
657
+ banned: 'WhatsApp refused this account (it may be banned)',
658
+ inactive: 'WhatsApp logged SynaBun out after a long time offline',
659
+ invalid_session: 'WhatsApp rejected the saved session as invalid; link again to pair this device',
660
+ removed: 'WhatsApp logged SynaBun out (removed from the phone)',
661
+ });
557
662
  function onLoggedOut(reason) {
558
663
  const cfg = config.read();
559
664
  config.write({ owner: null, setup: { ...(cfg.setup || {}), linkedAt: null, lastEnd: { state: 'logged_out', reason, at: now() } } });
560
- record('security', reason === 'banned' ? 'WhatsApp refused this account (it may be banned)' : reason === 'inactive' ? 'WhatsApp logged SynaBun out after a long time offline' : 'WhatsApp logged SynaBun out (removed from the phone)');
665
+ record('security', LOGGED_OUT_ACTIVITY[reason] || LOGGED_OUT_ACTIVITY.removed);
666
+ clearWarning();
561
667
  endEscalation('logged out');
562
668
  dropBridge().catch(() => {});
563
669
  // The host wiped the credentials; nothing is left for it to do.
564
- T.setTimeout(() => { if (!isLinked()) manager.stop().catch?.(() => {}); }, 1000)?.unref?.();
670
+ T.setTimeout(() => { if (!isLinked()) Promise.resolve(manager?.stop?.()).catch(() => {}); }, 1000)?.unref?.();
565
671
  }
566
672
  function onQr({ qr, expiresAt } = {}) {
567
673
  const link = state.link;
@@ -601,23 +707,52 @@ export function createWhatsAppService({
601
707
  notify();
602
708
  }
603
709
  const TRIGGER_RE = /^\s*sb(?:\s+|:\s*|$)/i; // "sb hi", "sb:hi", or a bare "sb" caption on a picture
710
+ // Spoken, the prefix comes back as "SB", "SB," "S.B." or "s b": letters s and b, then no other letter.
711
+ const SPOKEN_TRIGGER_RE = /^\s*s\.?\s?b\.?(?=$|[^\p{L}\p{N}])[\s,:;.!?…-]*/iu;
604
712
  const ALLOW_RE = /^\s*allow\s+\d{4}\s*$/i;
713
+ /** A transcribed voice note's text → its transcript (one line) and its caption (after the first "\n"). */
714
+ function voiceParts(text) {
715
+ const body = text.startsWith(VOICE_MARKER) ? text.slice(VOICE_MARKER.length) : text;
716
+ const cut = body.indexOf('\n');
717
+ return cut < 0 ? { transcript: body.trim(), caption: '' } : { transcript: body.slice(0, cut).trim(), caption: body.slice(cut + 1) };
718
+ }
719
+ const voiceText = (transcript, caption) => (transcript ? `${VOICE_MARKER} ${transcript}${caption.trim() ? `\n${caption}` : ''}` : caption);
720
+ /**
721
+ * A voice note in "Message yourself" with selfTrigger 'prefix': it is for SynaBun when its spoken
722
+ * transcript starts with the prefix (stripped; the marker stays), when its caption does (stripped),
723
+ * or when it replies to one of SynaBun's messages. Never a command or an ALLOW code. → the message or null.
724
+ */
725
+ function voiceTrigger(message, text) {
726
+ const { transcript, caption } = voiceParts(text);
727
+ const spoken = SPOKEN_TRIGGER_RE.exec(transcript);
728
+ if (spoken) {
729
+ const rest = transcript.slice(spoken[0].length).trim();
730
+ return rest || caption.trim() ? { ...message, text: voiceText(rest, caption) } : null;
731
+ }
732
+ const typed = TRIGGER_RE.exec(caption);
733
+ if (typed) return { ...message, text: voiceText(transcript, caption.slice(typed[0].length)) };
734
+ if (message.quoted?.fromBot === true) return message;
735
+ return null;
736
+ }
605
737
  /**
606
738
  * "Message yourself" with selfTrigger 'prefix': only messages that start with
607
739
  * "sb " or "sb:" are for SynaBun (the prefix is stripped). Commands (/stop,
608
740
  * /status …), the ALLOW answer and replies quoting SynaBun's own messages
609
- * always pass. Anything else is a note to self: dropped here, never logged.
741
+ * always pass. A voice note: voiceTrigger. Anything else is a note to self:
742
+ * dropped here, never logged.
610
743
  * → the message to act on, or null.
611
744
  */
612
745
  function applyTrigger(message) {
613
746
  const cfg = config.read();
614
747
  if (cfg.mode !== 'self' || cfg.selfTrigger !== 'prefix') return message;
615
748
  const text = typeof message.text === 'string' ? message.text : String(message.text ?? '');
749
+ if (message.voice === true) return voiceTrigger(message, text);
616
750
  const hasImages = Array.isArray(message.images) && message.images.length > 0;
617
751
  const match = TRIGGER_RE.exec(text);
618
752
  if (match) {
619
753
  const rest = text.slice(match[0].length);
620
- return rest.trim() || hasImages ? { ...message, text: rest } : null;
754
+ // A bare "sb" caption on a voice note that was not transcribed still gets the note that says why.
755
+ return rest.trim() || hasImages || message.unsupported?.type === 'audio' ? { ...message, text: rest } : null;
621
756
  }
622
757
  if (!message.forwarded && ALLOW_RE.test(text)) return message;
623
758
  const command = parseCommand(message);
@@ -653,8 +788,9 @@ export function createWhatsAppService({
653
788
  state.fatal = { code: String(code || 'ERROR'), message: message ? redactWa(message) : null, at: now() };
654
789
  const labels = {
655
790
  LOGGED_OUT: 'WhatsApp logged SynaBun out', REPLACED: 'Another copy of this link took over the session', FORBIDDEN: 'WhatsApp refused the connection',
656
- CONFIRM_TIMEOUT: 'The linked account was not confirmed within 10 minutes and was logged out', RECONNECT_LIMIT: 'Too many reconnects: SynaBun stopped trying',
657
- HOST_HELD: 'The WhatsApp connector kept crashing and was stopped', OWNER_ANOMALY: 'Sending paused: the owner looked different than expected', LOCKED: 'Another SynaBun is using the WhatsApp session files',
791
+ CONFIRM_TIMEOUT: 'The linked account was not confirmed within 10 minutes and was logged out',
792
+ CRASH: 'The WhatsApp connector crashed; SynaBun restarts it and reconnects by itself',
793
+ HOST_HELD: 'The WhatsApp connector does not match this SynaBun version and was stopped; restart SynaBun', OWNER_ANOMALY: 'Sending paused: the owner looked different than expected', LOCKED: 'Another SynaBun is using the WhatsApp session files',
658
794
  AUTH_PERMS: 'The WhatsApp session files could not be made private (fix the folder permissions, then reconnect)',
659
795
  };
660
796
  record('error', labels[state.fatal.code] || `WhatsApp connector: ${state.fatal.code}`);
@@ -679,26 +815,53 @@ export function createWhatsAppService({
679
815
  function requireAvailable() {
680
816
  if (disabled) throw whatsappError(409, 'UNAVAILABLE', 'WhatsApp is turned off on this computer (SYNABUN_WHATSAPP=off).');
681
817
  }
682
- /** Start the host and the linked session (autostart, resume, reconnect). */
818
+ /** The ops that talk to the host: a service built without a manager answers 409 instead of throwing. */
819
+ function requireManager() {
820
+ if (!manager) throw whatsappError(409, 'UNAVAILABLE', 'WhatsApp is not running on this computer.');
821
+ }
822
+ /** What a connect answered: nothing linked any more clears the link on record. */
823
+ function afterConnect(result, cfg) {
824
+ if (result?.ok) { ensureBridge(); return; }
825
+ if (result?.code === 'NOT_LINKED') config.write({ owner: null, setup: cfg.setup ? { ...cfg.setup, linkedAt: null } : null });
826
+ else state.fatal = { code: result?.code || 'CONNECT_FAILED', message: result?.message ? redactWa(result.message) : null, at: now() };
827
+ }
828
+ /** Start the host and the linked session (autostart, resume, reconnect, turning WhatsApp on). */
683
829
  async function run() {
830
+ if (!manager) {
831
+ state.fatal = { code: 'HOST_UNAVAILABLE', message: NO_MANAGER.message, at: now() };
832
+ notify();
833
+ return { ...NO_MANAGER };
834
+ }
684
835
  const cfg = config.read();
685
836
  wireManager();
686
837
  const started = await manager.start(prefsOf(cfg));
687
838
  if (!started?.ok) {
688
839
  state.fatal = { code: started?.code || 'HOST_UNAVAILABLE', message: started?.message ? redactWa(started.message) : null, at: now() };
840
+ // A host that is slow to start, or that crashed and is respawning, comes up later: the manager
841
+ // keeps the intent and connects on its ready. Not awaited: it may wait for that ready.
842
+ if (config.read().enabled && !state.stopping) {
843
+ Promise.resolve(manager.connect({ purpose: 'run', mode: cfg.mode })).then((result) => { if (result?.ok || result?.code === 'NOT_LINKED') { afterConnect(result, config.read()); notify(); } }, () => {});
844
+ }
689
845
  notify();
690
846
  return started || { ok: false, code: 'HOST_UNAVAILABLE' };
691
847
  }
692
848
  const result = await manager.connect({ purpose: 'run', mode: cfg.mode });
693
- if (!result?.ok) {
694
- if (result?.code === 'NOT_LINKED') config.write({ owner: null, setup: cfg.setup ? { ...cfg.setup, linkedAt: null } : null });
695
- else state.fatal = { code: result?.code || 'CONNECT_FAILED', message: result?.message ? redactWa(result.message) : null, at: now() };
696
- } else {
697
- ensureBridge();
698
- }
849
+ afterConnect(result, cfg);
699
850
  notify();
700
851
  return result;
701
852
  }
853
+ /**
854
+ * Settings turned WhatsApp off (`enabled: false`): the host stops, and with it every pending retry.
855
+ * The link (and its credentials) stays: turning it on again resumes the saved session.
856
+ */
857
+ async function stopForDisable() {
858
+ refreshBridge(); // control of the Mac ends first (the bridge re-registers its sessions without computer use)
859
+ if (state.link) finishLink('failed', { type: 'error', code: 'DISABLED' });
860
+ endClaim({ type: 'cancelled' });
861
+ endEscalation('WhatsApp turned off');
862
+ clearWarning();
863
+ if (manager) { try { await manager.stop(); } catch (error) { say(`stop: ${error?.message || error}`, 'warn'); } }
864
+ }
702
865
  async function start() {
703
866
  if (state.started) return status();
704
867
  state.started = true;
@@ -707,7 +870,6 @@ export function createWhatsAppService({
707
870
  pruneLogs();
708
871
  state.retentionTimer = T.setInterval(pruneLogs, 6 * HOUR);
709
872
  state.retentionTimer?.unref?.();
710
- armAutonomousTimer();
711
873
  const cfg = config.read();
712
874
  configureHost(cfg);
713
875
  if (installerStatus().installed && cfg.enabled && isLinked(cfg)) {
@@ -718,7 +880,7 @@ export function createWhatsAppService({
718
880
  }
719
881
  async function shutdown({ timeoutMs = 5000 } = {}) {
720
882
  state.stopping = true;
721
- for (const timer of [state.broadcastTimer, state.autonomousTimer, state.escalation?.timer, state.link?.timer]) if (timer) T.clearTimeout(timer);
883
+ for (const timer of [state.broadcastTimer, state.escalation?.timer, state.link?.timer, state.warningTimer]) if (timer) T.clearTimeout(timer);
722
884
  if (state.retentionTimer) T.clearInterval(state.retentionTimer);
723
885
  try { state.install?.controller?.abort(); } catch {}
724
886
  if (state.link) { state.link.stream.end({ type: 'error', code: 'SHUTTING_DOWN' }); state.link = null; }
@@ -730,6 +892,7 @@ export function createWhatsAppService({
730
892
  }
731
893
  function killNow() {
732
894
  state.stopping = true;
895
+ clearWarningTimer();
733
896
  const bridge = state.bridge;
734
897
  try { bridge?.endComputerUse?.('process exit'); } catch {}
735
898
  state.bridge = null;
@@ -749,8 +912,8 @@ export function createWhatsAppService({
749
912
  state.linkOutcome = { state: 'link_expired', at: now() };
750
913
  record('state', lastEvent?.code ? `Linking failed (${lastEvent.code})` : 'The link code expired before a phone used it');
751
914
  // Nothing linked: stop the host (and Baileys) until the next attempt.
752
- const r = manager.disconnect?.();
753
- Promise.resolve(r).finally(() => { if (!isLinked() && !state.link) Promise.resolve(manager.stop?.()).catch(() => {}); }).catch(() => {});
915
+ const r = manager?.disconnect?.();
916
+ Promise.resolve(r).finally(() => { if (!isLinked() && !state.link) Promise.resolve(manager?.stop?.()).catch(() => {}); }).catch(() => {});
754
917
  }
755
918
  notify();
756
919
  }
@@ -767,6 +930,7 @@ export function createWhatsAppService({
767
930
  if (state.link) throw whatsappError(409, 'LINK_BUSY', 'Another SynaBun tab is already linking WhatsApp. Finish or close it first.');
768
931
  if (state.install) throw whatsappError(409, 'INSTALL_BUSY', 'The WhatsApp connector is still installing.');
769
932
  if (!installerStatus().installed) throw whatsappError(409, 'NOT_INSTALLED', 'Install the WhatsApp connector first.');
933
+ requireManager();
770
934
  const cfg = config.read();
771
935
  if (isLinked(cfg)) throw whatsappError(409, 'ALREADY_LINKED', 'A WhatsApp account is already linked. Unlink it first.');
772
936
  if (body.mode !== undefined) {
@@ -785,7 +949,7 @@ export function createWhatsAppService({
785
949
  state.link = null;
786
950
  T.clearTimeout(current.timer);
787
951
  record('state', 'Linking cancelled (the settings tab closed)');
788
- Promise.resolve(manager.disconnect?.()).finally(() => { if (!isLinked() && !state.link) Promise.resolve(manager.stop?.()).catch(() => {}); }).catch(() => {});
952
+ Promise.resolve(manager?.disconnect?.()).finally(() => { if (!isLinked() && !state.link) Promise.resolve(manager?.stop?.()).catch(() => {}); }).catch(() => {});
789
953
  notify();
790
954
  });
791
955
  stream.write({ type: 'state', state: 'linking', phase: 'starting', method });
@@ -814,6 +978,7 @@ export function createWhatsAppService({
814
978
  async function confirmOwner(body = {}) {
815
979
  requireAvailable();
816
980
  if (typeof body.accept !== 'boolean') throw whatsappError(400, 'BAD_REQUEST', 'accept must be true or false', 'accept');
981
+ requireManager();
817
982
  const result = await manager.confirmOwner(body.accept);
818
983
  if (!result?.ok) throw whatsappError(409, result?.code || 'NOT_AWAITING', result?.message ? redactWa(result.message) : 'Nothing is waiting for confirmation.');
819
984
  if (!body.accept) {
@@ -821,7 +986,7 @@ export function createWhatsAppService({
821
986
  config.write({ owner: null, setup: cfg.setup ? { ...cfg.setup, linkedAt: null } : null });
822
987
  record('security', 'You said the linked account is not yours; SynaBun logged it out');
823
988
  await dropBridge();
824
- Promise.resolve(manager.stop?.()).catch(() => {});
989
+ Promise.resolve(manager?.stop?.()).catch(() => {});
825
990
  }
826
991
  notify();
827
992
  return { ok: true, status: status() };
@@ -839,6 +1004,7 @@ export function createWhatsAppService({
839
1004
  if (cfg.mode !== 'dedicated') throw whatsappError(409, 'WRONG_MODE', 'Claim codes are for the second-number mode.');
840
1005
  if (conn.state !== 'open') throw whatsappError(409, 'NOT_CONNECTED', 'WhatsApp is not connected.');
841
1006
  if (conn.owner?.bound) throw whatsappError(409, 'OWNER_BOUND', 'An owner is already set. Reset it first.');
1007
+ requireManager();
842
1008
  const result = await manager.claimStart({ ttlMs: L.claimTtlMs });
843
1009
  if (!result?.ok) throw whatsappError(409, result?.code || 'CLAIM_FAILED', result?.message ? redactWa(result.message) : 'Could not start a claim code.');
844
1010
  if (state.claim) endClaim({ type: 'cancelled' });
@@ -849,7 +1015,7 @@ export function createWhatsAppService({
849
1015
  stream.onAbort(() => {
850
1016
  if (state.claim !== current) return;
851
1017
  state.claim = null;
852
- Promise.resolve(manager.claimCancel?.()).catch(() => {});
1018
+ Promise.resolve(manager?.claimCancel?.()).catch(() => {});
853
1019
  notify();
854
1020
  });
855
1021
  let qrSvg = null;
@@ -861,6 +1027,7 @@ export function createWhatsAppService({
861
1027
  async function resetOwner() {
862
1028
  requireAvailable();
863
1029
  if (config.read().mode !== 'dedicated') throw whatsappError(409, 'WRONG_MODE', 'In "Message yourself" the owner is the linked account. Unlink to link another one.');
1030
+ requireManager();
864
1031
  const result = await manager.ownerReset();
865
1032
  if (!result?.ok) throw whatsappError(409, result?.code || 'RESET_FAILED', result?.message ? redactWa(result.message) : 'Could not reset the owner.');
866
1033
  // The second account stays linked: keep that on record while the owner is unbound.
@@ -884,7 +1051,7 @@ export function createWhatsAppService({
884
1051
  (async () => {
885
1052
  // The host holds the runtime's files open (Windows locks them): stop it first, restart after.
886
1053
  if (hostRunning()) await dropBridge();
887
- try { await manager.stop(); } catch {}
1054
+ try { await manager?.stop(); } catch {}
888
1055
  let result;
889
1056
  try {
890
1057
  result = await installer.install({
@@ -928,7 +1095,7 @@ export function createWhatsAppService({
928
1095
  requireAvailable();
929
1096
  if (state.install) throw whatsappError(409, 'INSTALL_BUSY', 'The connector is being installed.');
930
1097
  if (isLinked()) throw whatsappError(409, 'STILL_LINKED', 'Unlink WhatsApp before removing the connector.');
931
- try { await manager.stop(); } catch {}
1098
+ try { await manager?.stop(); } catch {}
932
1099
  const result = await installer.uninstall();
933
1100
  state.installCache = null;
934
1101
  state.installError = null;
@@ -986,6 +1153,67 @@ export function createWhatsAppService({
986
1153
  if (!result?.ok) throw whatsappError(502, result?.code || 'SEND_FAILED', result?.message ? redactWa(result.message) : 'The test message could not be sent.');
987
1154
  return { ok: true };
988
1155
  }
1156
+ // ── a local file to the owner (whatsapp_send_file; docs/whatsapp.md → Sending files) ──
1157
+ // The HTTP status of each refusal (POST /api/assistant/whatsapp/send-file answers with it).
1158
+ const FILE_STATUS = Object.freeze({
1159
+ BAD_REQUEST: 400, ADDRESS_FORBIDDEN: 400, FILE_FORBIDDEN: 403, FILE_TOO_LARGE: 413, FILE_INVALID: 422,
1160
+ NOT_CONNECTED: 409, PAUSED: 409, NO_OWNER: 409, HELD: 409, THROTTLED: 429, SEND_FAILED: 502, HOST_UNAVAILABLE: 503, TIMEOUT: 504,
1161
+ });
1162
+ const FILE_FIELDS = Object.freeze(['path', 'caption', 'as']);
1163
+ // Refusals whose message is about the file itself (local-file.js): its path, and why.
1164
+ const FILE_REFUSALS = new Set(['FILE_FORBIDDEN', 'FILE_TOO_LARGE', 'FILE_INVALID', 'BAD_REQUEST']);
1165
+ const UNCERTAIN_FILE = 'WhatsApp did not confirm the file: it may or may not have arrived. Do not send it again unless the user asks.';
1166
+ const fileSize = (bytes) => { const n = Number(bytes) || 0; return n >= 1024 * 1024 ? `${(n / (1024 * 1024)).toFixed(1)} MiB` : n >= 1024 ? `${Math.round(n / 1024)} KiB` : `${n} bytes`; };
1167
+ /**
1168
+ * Send a local file to the owner, for the WhatsApp conversation `sessionId` (the caller proved it
1169
+ * holds that conversation's brain: lib/assistant-api.js). Only { path, caption?, as? } (anything
1170
+ * else is a 400); only the active WhatsApp session (never an older one, a desktop chat or a
1171
+ * worker: 403 WHATSAPP_SESSION_REQUIRED); only while WhatsApp is on, not paused and connected
1172
+ * (409 PAUSED / NOT_CONNECTED). The host checks and reads the file (local-file.js). Exactly one
1173
+ * request, never retried: a refusal with `uncertain: true` (a timeout) may have been delivered.
1174
+ * Activity records the outcome with the path, never the caption or the bytes.
1175
+ */
1176
+ async function sendFile(sessionId, fields = {}) {
1177
+ requireAvailable();
1178
+ const body = fields && typeof fields === 'object' && !Array.isArray(fields) ? fields : null;
1179
+ if (!body) throw whatsappError(400, 'BAD_REQUEST', 'Send { path, caption?, as? }.');
1180
+ const extra = Object.keys(body).find((key) => !FILE_FIELDS.includes(key));
1181
+ if (extra !== undefined) throw whatsappError(400, 'BAD_REQUEST', `Unknown field "${extra.slice(0, 40)}": a file send takes path, caption and as only.`, extra.slice(0, 40));
1182
+ if (typeof body.path !== 'string' || !body.path.trim() || body.path.length > LIMITS.MAX_FILE_PATH || /[\u0000-\u001f\u007f]/.test(body.path)) throw whatsappError(400, 'BAD_REQUEST', `path must be the absolute path of a file on this computer (at most ${LIMITS.MAX_FILE_PATH} characters, no control characters).`, 'path');
1183
+ if (body.caption !== undefined && body.caption !== null && (typeof body.caption !== 'string' || body.caption.length > LIMITS.MAX_FILE_CAPTION)) throw whatsappError(400, 'BAD_REQUEST', `caption must be text of at most ${LIMITS.MAX_FILE_CAPTION} characters.`, 'caption');
1184
+ if (body.as !== undefined && body.as !== null && !FILE_SEND_AS.includes(body.as)) throw whatsappError(400, 'BAD_REQUEST', `as must be one of ${FILE_SEND_AS.join(', ')}.`, 'as');
1185
+ const cfg = config.read();
1186
+ const active = bridgeStatus()?.sessionId || cfg.sessionId || null;
1187
+ let channel = null;
1188
+ try { channel = sessionId ? getRuntime()?.sessionChannel?.(String(sessionId)) || null : null; } catch { channel = null; }
1189
+ if (!sessionId || !active || String(sessionId) !== String(active) || channel !== 'whatsapp') {
1190
+ throw whatsappError(403, 'WHATSAPP_SESSION_REQUIRED', 'Only the active WhatsApp conversation sends files to the phone.');
1191
+ }
1192
+ if (!cfg.enabled || cfg.paused || state.stopping) throw whatsappError(409, 'PAUSED', 'WhatsApp is turned off or paused on this computer: nothing was sent.');
1193
+ requireManager();
1194
+ if (!connectedNow()) throw whatsappError(409, 'NOT_CONNECTED', 'WhatsApp is not connected: nothing was sent.');
1195
+ const request = { path: body.path };
1196
+ if (typeof body.caption === 'string') request.caption = body.caption;
1197
+ if (typeof body.as === 'string') request.as = body.as;
1198
+ let result;
1199
+ try { result = await manager.sendFile(request); } catch (error) { result = { ok: false, code: 'SEND_FAILED', message: error?.message || String(error) }; }
1200
+ if (result?.ok) {
1201
+ record('reply', `Sent a file to your phone: ${result.type}${result.fallback ? ' (as a document: over 16 MiB)' : ''}, ${fileSize(result.size)}:`, null, { filePath: result.path });
1202
+ notify();
1203
+ return { ok: true, id: result.id ?? null, type: result.type, mimetype: result.mimetype, size: result.size, fileName: result.fileName, path: result.path, fallback: result.fallback === true };
1204
+ }
1205
+ const code = String(result?.code || 'SEND_FAILED');
1206
+ const uncertain = result?.uncertain === true;
1207
+ record('error', `A file was not sent (${code}${uncertain ? '; delivery unknown' : ''}):`, null, { filePath: body.path, requested: true });
1208
+ notify();
1209
+ // A file's own refusal keeps its path readable (redactFilePath: numbers, JIDs and credentials still hidden).
1210
+ const reason = result?.message ? (FILE_REFUSALS.has(code) ? redactFilePath(result.message) : redactWa(result.message)) : 'WhatsApp refused the file.';
1211
+ const error = whatsappError(FILE_STATUS[code] || 502, code, uncertain ? UNCERTAIN_FILE : reason);
1212
+ if (uncertain) error.uncertain = true;
1213
+ if (Number.isFinite(result?.retryAfterMs)) error.retryAfterMs = result.retryAfterMs;
1214
+ throw error;
1215
+ }
1216
+
989
1217
  function pause({ by = 'desktop' } = {}) {
990
1218
  requireAvailable();
991
1219
  // An agent's pause counts as the desktop's: the phone cannot undo it.
@@ -1012,6 +1240,7 @@ export function createWhatsAppService({
1012
1240
  requireAvailable();
1013
1241
  if (!installerStatus().installed) throw whatsappError(409, 'NOT_INSTALLED', 'Install the WhatsApp connector first.');
1014
1242
  if (!isLinked()) throw whatsappError(409, 'NOT_LINKED', 'No WhatsApp account is linked.');
1243
+ requireManager();
1015
1244
  const cfg = config.read();
1016
1245
  if (!cfg.enabled) config.write({ enabled: true });
1017
1246
  state.fatal = null;
@@ -1024,13 +1253,13 @@ export function createWhatsAppService({
1024
1253
  if (state.link) finishLink('failed', { type: 'error', code: 'UNLINKED' });
1025
1254
  endClaim({ type: 'cancelled' });
1026
1255
  endEscalation('unlinked');
1027
- T.clearTimeout(state.autonomousTimer);
1028
- state.autonomousTimer = null;
1029
1256
  await dropBridge();
1030
1257
  let loggedOut = false;
1031
1258
  let code = null;
1032
1259
  if (manager && installerStatus().installed) {
1033
- if (!hostRunning()) { const started = await manager.start(prefsOf(config.read())); if (!started?.ok) code = started?.code || 'HOST_UNAVAILABLE'; }
1260
+ // A host between restarts ('backoff') is started now: the logout must not wait out a respawn of up to 5 minutes,
1261
+ // and the link is not reconnected first (resume: false drops the run intent).
1262
+ if (managerStatus().host.state !== 'running') { const started = await manager.start(prefsOf(config.read()), { resume: false }); if (!started?.ok) code = started?.code || 'HOST_UNAVAILABLE'; }
1034
1263
  if (!code) {
1035
1264
  const result = await manager.logout();
1036
1265
  loggedOut = !!result?.ok;
@@ -1045,6 +1274,7 @@ export function createWhatsAppService({
1045
1274
  state.fatal = null;
1046
1275
  state.lastConn = null;
1047
1276
  state.wasConnected = false;
1277
+ clearWarning();
1048
1278
  return { loggedOut, code };
1049
1279
  }
1050
1280
  async function unlink() {
@@ -1092,7 +1322,8 @@ export function createWhatsAppService({
1092
1322
  const userView = (cfg) => pick(cfg, USER_FIELDS);
1093
1323
  function getConfig() {
1094
1324
  const cfg = config.read();
1095
- return { ok: true, config: userView(cfg), version: cfg.version, level: currentLevel(cfg, now()), levelExpiresAt: currentLevel(cfg, now()) === 'autonomous' ? cfg.autonomousUntil : null, escalation: escalationView() };
1325
+ // levelExpiresAt: Autonomous has no time limit (always null, kept for older readers).
1326
+ return { ok: true, config: userView(cfg), version: cfg.version, level: currentLevel(cfg), levelExpiresAt: null, escalation: escalationView() };
1096
1327
  }
1097
1328
  function escalationView() {
1098
1329
  const esc = state.escalation;
@@ -1113,7 +1344,7 @@ export function createWhatsAppService({
1113
1344
  patch.brain = checked.brain;
1114
1345
  }
1115
1346
  const before = config.read();
1116
- const wantsAutonomous = patch?.level === 'autonomous' && currentLevel(before, now()) !== 'autonomous';
1347
+ const wantsAutonomous = patch?.level === 'autonomous' && currentLevel(before) !== 'autonomous';
1117
1348
  if (wantsAutonomous && body.confirmEscalation === true && !connectedNow()) {
1118
1349
  throw whatsappError(409, 'PHONE_REQUIRED', 'Connect WhatsApp first: your phone confirms Autonomous with an ALLOW code.', 'level');
1119
1350
  }
@@ -1122,16 +1353,29 @@ export function createWhatsAppService({
1122
1353
  let pending = null;
1123
1354
  if (result.pending) pending = startEscalation();
1124
1355
  else if (patch && Object.hasOwn(patch, 'level') && patch.level !== 'autonomous') endEscalation('level lowered');
1125
- if (before.level !== after.level || before.autonomousUntil !== after.autonomousUntil) {
1126
- armAutonomousTimer();
1356
+ if (before.level !== after.level) {
1127
1357
  record('security', `Level set to ${after.level === 'read-only' ? 'Read-only' : after.level === 'ask' ? 'Ask on my phone' : 'Autonomous'} from this computer`);
1128
1358
  }
1129
1359
  if (before.computerUse !== after.computerUse) record('security', after.computerUse ? 'Computer use from WhatsApp turned on from this computer' : 'Computer use from WhatsApp turned off from this computer');
1130
1360
  if (before.activityText !== after.activityText) record('settings', after.activityText ? 'Message text is now kept in this activity log' : 'Message text is no longer kept in this activity log');
1131
1361
  if (before.selfTrigger !== after.selfTrigger) record('settings', after.selfTrigger === 'prefix' ? 'Only messages that start with sb go to SynaBun now' : 'Every message in "Message yourself" goes to SynaBun now');
1362
+ // Voice notes: the host gets the new settings at once (it transcribes), and Settings looks at the tools again.
1363
+ const transcriptionChanged = TRANSCRIPTION_FIELDS.some((key) => before[key] !== after[key]);
1364
+ if (transcriptionChanged) {
1365
+ state.transcription = null;
1366
+ if (before.transcriptionEnabled !== after.transcriptionEnabled) record('settings', after.transcriptionEnabled ? 'Voice notes are transcribed on this computer now' : 'Voice notes are no longer transcribed');
1367
+ if (TRANSCRIPTION_FIELDS.some((key) => key !== 'transcriptionEnabled' && before[key] !== after[key])) record('settings', 'Voice note settings changed (language or program paths)');
1368
+ configureHost(after);
1369
+ }
1132
1370
  if (JSON.stringify(before.brain) !== JSON.stringify(after.brain)) {
1133
1371
  record('settings', after.brain ? `WhatsApp now runs on ${providerLabel(after.brain.provider)} / ${after.brain.model}${after.brain.effort ? ` (${after.brain.effort})` : ''}, from the next message` : 'WhatsApp now runs on the same brain as the Assistant, from the next message');
1134
1372
  }
1373
+ // Off stops the host and every retry (the link stays); on resumes the saved session. Pause is separate.
1374
+ if (before.enabled !== after.enabled) {
1375
+ record('settings', after.enabled ? 'WhatsApp turned on from this computer' : 'WhatsApp turned off from this computer: disconnected, and no reconnect until it is turned on again');
1376
+ if (!after.enabled) await stopForDisable();
1377
+ else if (state.started && !state.stopping && manager && installerStatus().installed && isLinked(after)) await run().catch(() => {});
1378
+ }
1135
1379
  refreshBridge();
1136
1380
  notify();
1137
1381
  return { ok: true, config: userView(after), version: after.version, pending };
@@ -1192,7 +1436,7 @@ export function createWhatsAppService({
1192
1436
  * when it is Codex or OpenCode and the level asks for more than read-only.
1193
1437
  */
1194
1438
  function brainLimitView(cfg, sessionId) {
1195
- if (currentLevel(cfg, now()) === 'read-only') return null;
1439
+ if (currentLevel(cfg) === 'read-only') return null;
1196
1440
  let provider = null;
1197
1441
  try { provider = sessionId ? getRuntime()?.getSession?.(sessionId, { transcript: false })?.brain?.provider || null : null; } catch { provider = null; }
1198
1442
  if (!provider) provider = brainChoice(cfg).effective?.provider || null;
@@ -1204,6 +1448,7 @@ export function createWhatsAppService({
1204
1448
  if (!fakeEnabled()) throw whatsappError(404, 'NOT_FOUND', 'Not found');
1205
1449
  const action = String(body.action || '');
1206
1450
  const cfg = config.read();
1451
+ requireManager();
1207
1452
  const call = async (name, args = {}) => {
1208
1453
  const result = await manager.fake(name, args);
1209
1454
  if (result?.ok === false) throw whatsappError(409, result.code || 'FAKE_FAILED', result.message || `fake ${name} failed`);
@@ -1241,7 +1486,7 @@ export function createWhatsAppService({
1241
1486
  const tab = tabState();
1242
1487
  const { conn, host, runtime } = managerStatus();
1243
1488
  const inst = installerStatus();
1244
- const level = currentLevel(cfg, now());
1489
+ const level = currentLevel(cfg);
1245
1490
  const bridge = bridgeStatus();
1246
1491
  const sessionId = bridge?.sessionId || cfg.sessionId || null;
1247
1492
  let confirm = null;
@@ -1257,7 +1502,8 @@ export function createWhatsAppService({
1257
1502
  ...tab,
1258
1503
  mode: cfg.mode,
1259
1504
  level,
1260
- levelExpiresAt: level === 'autonomous' ? cfg.autonomousUntil : null,
1505
+ // Autonomous has no time limit: kept for older readers, always null.
1506
+ levelExpiresAt: null,
1261
1507
  enabled: cfg.enabled,
1262
1508
  paused: cfg.paused,
1263
1509
  pausedBy: cfg.pausedBy,
@@ -1268,7 +1514,7 @@ export function createWhatsAppService({
1268
1514
  installedAt: inst.installedAt ?? null, approxSizeMB: inst.approxSizeMB ?? null, stage: state.install?.stage || null,
1269
1515
  errorCode: state.installError?.code || null, errorMessage: state.installError?.message || null, runtime: runtime.loaded ? { version: runtime.version, fake: runtime.fake } : null,
1270
1516
  },
1271
- host: { state: host.state, restarts: host.restarts || 0 },
1517
+ host: { state: host.state, restarts: host.restarts || 0, retryInMs: Number.isFinite(host.retryInMs) ? host.retryInMs : null },
1272
1518
  account: conn.me ? { masked: conn.me.masked, name: conn.me.name ?? null } : null,
1273
1519
  owner,
1274
1520
  confirm,
@@ -1277,6 +1523,10 @@ export function createWhatsAppService({
1277
1523
  brainChoice: brainChoice(cfg),
1278
1524
  bridge: bridge ? { running: !!bridge.running, queued: bridge.queued || 0, pendingCards: bridge.pendingCards || 0, lastReplyAt: bridge.lastReplyAt || null } : null,
1279
1525
  connection: tab.state === 'reconnecting' ? { attempt: state.reconnectAttempt, nextRetryAt: Number.isFinite(conn.retryInMs) ? state.lastStateAt + conn.retryInMs : null } : null,
1526
+ // 'replaced' (440) | 'forbidden' (403) while the link retries one of them, until it is stable again.
1527
+ warning: state.warning?.kind || null,
1528
+ // Voice notes: on, and whether whisper-cli, the model and FFmpeg are found (what is missing when not).
1529
+ transcription: transcriptionView(cfg),
1280
1530
  counters: { ...(conn.counters || {}) },
1281
1531
  config: userView(cfg),
1282
1532
  version: cfg.version,
@@ -1299,6 +1549,8 @@ export function createWhatsAppService({
1299
1549
  return {
1300
1550
  router, start, shutdown, killNow, status,
1301
1551
  toSyncPayload,
1552
+ // Not a /api/whatsapp route: POST /api/assistant/whatsapp/send-file calls it for the brain it identified.
1553
+ sendFile,
1302
1554
  _internals: { state, config, ops, activity, record, tabState, handleAllow, flushBroadcast, manager, installer, paths, brainChoice },
1303
1555
  };
1304
1556
  }