obsbot-mcp 0.3.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/README.md +197 -38
  2. package/dist/codec/commands.d.ts +42 -4
  3. package/dist/codec/commands.js +72 -10
  4. package/dist/codec/commands.js.map +1 -1
  5. package/dist/codec/frame.d.ts +1 -0
  6. package/dist/codec/frame.js +1 -1
  7. package/dist/codec/frame.js.map +1 -1
  8. package/dist/codec/preset.d.ts +40 -0
  9. package/dist/codec/preset.js +198 -0
  10. package/dist/codec/preset.js.map +1 -0
  11. package/dist/codec/types.d.ts +4 -0
  12. package/dist/device/helper-factory.d.ts +25 -0
  13. package/dist/device/helper-factory.js +40 -0
  14. package/dist/device/helper-factory.js.map +1 -0
  15. package/dist/device/manager.d.ts +326 -2
  16. package/dist/device/manager.js +720 -24
  17. package/dist/device/manager.js.map +1 -1
  18. package/dist/ipc/client.d.ts +19 -0
  19. package/dist/ipc/client.js +81 -0
  20. package/dist/ipc/client.js.map +1 -0
  21. package/dist/ipc/coordinator.d.ts +29 -0
  22. package/dist/ipc/coordinator.js +94 -0
  23. package/dist/ipc/coordinator.js.map +1 -0
  24. package/dist/ipc/owner.d.ts +21 -0
  25. package/dist/ipc/owner.js +59 -0
  26. package/dist/ipc/owner.js.map +1 -0
  27. package/dist/ipc/protocol.d.ts +21 -0
  28. package/dist/ipc/protocol.js +56 -0
  29. package/dist/ipc/protocol.js.map +1 -0
  30. package/dist/ipc/rendezvous.d.ts +26 -0
  31. package/dist/ipc/rendezvous.js +93 -0
  32. package/dist/ipc/rendezvous.js.map +1 -0
  33. package/dist/mcp/log-sink.d.ts +12 -0
  34. package/dist/mcp/log-sink.js +27 -0
  35. package/dist/mcp/log-sink.js.map +1 -0
  36. package/dist/mcp/ready.d.ts +16 -4
  37. package/dist/mcp/ready.js +11 -8
  38. package/dist/mcp/ready.js.map +1 -1
  39. package/dist/mcp/render.js +1 -1
  40. package/dist/mcp/render.js.map +1 -1
  41. package/dist/mcp/server.js +68 -18
  42. package/dist/mcp/server.js.map +1 -1
  43. package/dist/mcp/tools.d.ts +5 -3
  44. package/dist/mcp/tools.js +787 -186
  45. package/dist/mcp/tools.js.map +1 -1
  46. package/dist/transport/helper-process.d.ts +36 -0
  47. package/dist/transport/helper-process.js +190 -10
  48. package/dist/transport/helper-process.js.map +1 -1
  49. package/dist/transport/linux.d.ts +40 -0
  50. package/dist/transport/linux.js +92 -2
  51. package/dist/transport/linux.js.map +1 -1
  52. package/dist/transport/macos.d.ts +13 -0
  53. package/dist/transport/macos.js +59 -3
  54. package/dist/transport/macos.js.map +1 -1
  55. package/dist/transport/read-serial.d.ts +26 -0
  56. package/dist/transport/read-serial.js +87 -0
  57. package/dist/transport/read-serial.js.map +1 -0
  58. package/dist/transport/transport.d.ts +24 -0
  59. package/dist/transport/windows.d.ts +4 -0
  60. package/dist/transport/windows.js +31 -4
  61. package/dist/transport/windows.js.map +1 -1
  62. package/native/prebuilt/darwin-arm64/obsbot-helper +0 -0
  63. package/native/prebuilt/darwin-x64/obsbot-helper +0 -0
  64. package/native/prebuilt/linux-x64/obsbot-helper +0 -0
  65. package/native/prebuilt/win32-x64/obsbot-helper.exe +0 -0
  66. package/package.json +3 -2
  67. package/dist/device/session.d.ts +0 -22
  68. package/dist/device/session.js +0 -37
  69. package/dist/device/session.js.map +0 -1
@@ -1,45 +1,741 @@
1
1
  import { WindowsTransport } from "../transport/windows.js";
2
2
  import { LinuxTransport } from "../transport/linux.js";
3
3
  import { MacosTransport } from "../transport/macos.js";
4
+ // USB vendor ID 0x3564 is registered to **Remo Inc.** — the manufacturer. OBSBOT
5
+ // is a Remo product brand, and Remo ships non-OBSBOT (and non-camera) devices
6
+ // under the same VID, so candidacy must gate on VID **and** a known model PID —
7
+ // never VID alone. Add a PID here (and mirror it in native/macos/helper.m's
8
+ // OBSBOT_MODEL_PIDS) when a new OBSBOT camera model is verified on hardware.
9
+ const REMO_VID = 0x3564;
10
+ const OBSBOT_MODEL_PIDS = new Map([
11
+ [REMO_VID, new Set([0xfef8 /* Tiny 2 */])],
12
+ ]);
13
+ // Legacy name match — used ONLY as a fallback on platforms whose helper does not
14
+ // yet report USB vid/pid (Linux today: its /dev/videoN paths carry no USB
15
+ // identity). Windows and macOS helpers report vid/pid, so there the gate is
16
+ // strict and branded software sources — e.g. the "OBSBOT Virtual Camera"
17
+ // DirectShow filter, which matched this regex and poisoned multi-candidate
18
+ // binding — are correctly excluded.
19
+ const OBSBOT_NAME_RE = /obsbot/i;
20
+ /** Message text from an unknown thrown value, for diagnostics. */
21
+ const errText = (e) => (e instanceof Error ? e.message : String(e));
22
+ /**
23
+ * Is this enumerated device a controllable OBSBOT camera worth binding? On
24
+ * Windows/macOS the helper reports USB vid/pid, so gate strictly on the Remo
25
+ * VID + known-model PID table (a software "OBSBOT Virtual Camera" has no vid/pid
26
+ * and is rejected). On Linux the helper does not report vid/pid yet, so fall
27
+ * back to the name match there until it does.
28
+ */
29
+ function isObsbotCamera(d) {
30
+ if (process.platform === "linux")
31
+ return OBSBOT_NAME_RE.test(d.name);
32
+ if (d.vid === undefined || d.pid === undefined)
33
+ return false;
34
+ return OBSBOT_MODEL_PIDS.get(d.vid)?.has(d.pid) ?? false;
35
+ }
36
+ /** Thrown by get() with no selector when more than one camera is attached. */
37
+ export class AmbiguousCameraError extends Error {
38
+ available;
39
+ constructor(available) {
40
+ super(`multiple cameras attached; specify one of: ${available.join(", ")}`);
41
+ this.name = "AmbiguousCameraError";
42
+ this.available = available;
43
+ }
44
+ }
45
+ /** Thrown by get(serial) when no attached, bindable camera has that serial. */
46
+ export class UnknownCameraError extends Error {
47
+ available;
48
+ constructor(serial, available) {
49
+ super(`unknown camera "${serial}"; available: ${available.length ? available.join(", ") : "(none)"}`);
50
+ this.name = "UnknownCameraError";
51
+ this.available = available;
52
+ }
53
+ }
54
+ /**
55
+ * Serial-keyed multi-camera registry. Identity is the serial (read via
56
+ * ObsbotTransport.readSerial() on a freshly-opened device); locationId is
57
+ * only ever a display/correlation hint, never persisted as truth for
58
+ * binding.
59
+ *
60
+ * Steady state is one HelperProcess per bound camera (Map<serial, {helper,
61
+ * transport, locationId}>), spawned lazily — nothing is spawned until a
62
+ * camera is actually needed (get() or listCameras()). While scanning for a
63
+ * camera to bind, candidates are tried on a single scratch helper reused
64
+ * across attempts, mirroring the native helper's own behaviour: `doOpen`
65
+ * unconditionally releases whatever device it previously held before
66
+ * opening the next one, so re-opening a different candidate on the same
67
+ * helper is exactly what one physical device-swap looks like to it. A
68
+ * scratch helper that turns out to be the winning candidate is promoted
69
+ * directly into the registry (no extra spawn); one that never opens a
70
+ * usable camera is kept around for the next scan instead of being spawned
71
+ * again. Consequently DeviceManager never closes a helper it did not spawn
72
+ * itself for a *losing* attempt — ownership of a bound camera's helper
73
+ * transfers to the registry, and callers that hand in an already-started,
74
+ * externally-owned helper (see the .mjs scripts) keep owning its shutdown.
75
+ *
76
+ * USB open is exclusive: a camera another process holds fails `open` with
77
+ * an exclusive-access error. Per multi-camera spec §4.3, ANY open failure
78
+ * during a scan is treated as "not mine, skip" — not fatal — because the
79
+ * helper does not currently surface a clean, stable way to distinguish
80
+ * exclusive-access from other open failures at this layer.
81
+ *
82
+ * invalidate() is the escape hatch out of "once bound, stay bound": it
83
+ * drops a registry entry (best-effort closing its helper first) so the
84
+ * next get()/bind() re-scans through a fresh helper instead of handing back
85
+ * a transport that may be talking to a device that already re-enumerated.
86
+ */
4
87
  export class DeviceManager {
5
- helper;
6
- constructor(helper) {
7
- this.helper = helper;
88
+ makeHelper;
89
+ registry = new Map();
90
+ /** Lazily spawned, reused across scans until promoted into the registry. */
91
+ scanHelper;
92
+ /**
93
+ * Long-lived listener. Holds no device and never scans for one — its only job
94
+ * is to still be alive, and still subscribed, when the cable moves.
95
+ */
96
+ watcher;
97
+ /**
98
+ * Reconnect tracking, folded in from the retired DeviceSession. `everBound`
99
+ * records every serial this manager has bound at least once — deliberately
100
+ * SEPARATE from the registry Map so it survives invalidate() dropping a
101
+ * registry entry (the registry says "bound right now", this says "bound at
102
+ * some point"). A promote() of a serial already in `everBound` is therefore a
103
+ * RE-bind — a self-heal after a mid-session disconnect — and lands the serial
104
+ * in `reconnectedSerials`, which the readiness gate drains via
105
+ * takeReconnected() to surface `reconnected: true` on the next command.
106
+ */
107
+ everBound = new Set();
108
+ reconnectedSerials = new Set();
109
+ /**
110
+ * @param makeHelper Factory for a HelperProcess, invoked whenever a fresh
111
+ * scratch helper is needed (each scan, and each rebind after
112
+ * invalidate()). CONTRACT: it MUST return a freshly-started,
113
+ * exclusively-owned HelperProcess on every call — DeviceManager may call
114
+ * it repeatedly across a session and always expects a clean handle, not
115
+ * a shared one. A factory that instead returns the SAME already-started
116
+ * helper on every call (as the single-camera `.mjs` scripts under
117
+ * scripts/ do, to keep one native session alive for their whole run)
118
+ * MUST NOT bind more than one serial through this manager: a second
119
+ * scan would hand that same shared helper back and silently steal the
120
+ * first bound camera's native session out from under it. No current
121
+ * caller does this (all are single-camera); this is latent and not
122
+ * guarded in code — the proper fix belongs to multi-camera .mjs/CLI
123
+ * wiring, not to DeviceManager itself.
124
+ */
125
+ /** True while an arrival re-bind ladder is running (see handleCameraArrived). */
126
+ rebinding = false;
127
+ /**
128
+ * Delays before each arrival re-bind attempt, in order — so four attempts
129
+ * spanning ~4.5s, the first immediate.
130
+ *
131
+ * Sized from hardware, 2026-07-21: for many seconds after a USB
132
+ * re-enumeration the Tiny 2's vendor mailbox is intermittently not-ready
133
+ * (the reply slot reads back with its magic byte zeroed), and readSerial's
134
+ * own 8 x 30ms poll is too short to ride it out. Measured 22 failures in 80
135
+ * attempts across the first 14s after a replug, against 0 in 120 in steady
136
+ * state — so a single attempt on arrival is roughly a 1-in-4 coin flip, and
137
+ * losing it silently leaves the camera unbound until the user's next call.
138
+ * Four spread attempts make a lost re-bind unlikely without retrying forever
139
+ * at a camera that is simply not answering.
140
+ */
141
+ arrivalBackoffMs;
142
+ /**
143
+ * Where a failed or retried arrival re-bind is reported. STDERR by default —
144
+ * never stdout, which is the JSON-RPC channel.
145
+ *
146
+ * Exists because the ladder was silent: a ladder that fired and a clean first
147
+ * attempt looked identical from outside the process, so the retry could not
148
+ * be observed on hardware even once it existed. A self-heal that fails
149
+ * quietly is the failure mode that cost a day of debugging.
150
+ */
151
+ log;
152
+ constructor(makeHelper, opts = {}) {
153
+ this.makeHelper = makeHelper;
154
+ this.arrivalBackoffMs = opts.arrivalBackoffMs ?? [0, 400, 1200, 3000];
155
+ this.log = opts.log ?? ((m) => console.error(m));
8
156
  }
9
- createTransport() {
157
+ createTransport(helper) {
10
158
  if (process.platform === "linux") {
11
- return new LinuxTransport(this.helper);
159
+ return new LinuxTransport(helper);
12
160
  }
13
161
  if (process.platform === "darwin") {
14
- return new MacosTransport(this.helper);
162
+ return new MacosTransport(helper);
163
+ }
164
+ return new WindowsTransport(helper);
165
+ }
166
+ /**
167
+ * Close and forget the scratch helper. Closing matters as much as forgetting:
168
+ * the process is alive and holding whatever it opened, so dropping only the
169
+ * reference leaks it and leaves the replacement unable to open the camera —
170
+ * the same mistake pruneDeadEntries() makes if it merely deletes an entry.
171
+ */
172
+ async discardScanHelper() {
173
+ const helper = this.scanHelper;
174
+ this.scanHelper = undefined;
175
+ if (!helper)
176
+ return;
177
+ try {
178
+ await helper.close();
179
+ }
180
+ catch {
181
+ // best-effort — a disconnected helper's close() may itself throw
15
182
  }
16
- return new WindowsTransport(this.helper);
17
183
  }
184
+ /**
185
+ * Keep one helper alive purely to receive bus events.
186
+ *
187
+ * Bus events are delivered per PROCESS (helperFactory subscribes each one it
188
+ * spawns), and every process that could be listening gets closed: a departure
189
+ * closes the registry helper, and a failed bind closes the scratch scanner.
190
+ * Since promote() clears `scanHelper`, the bound steady state is exactly ONE
191
+ * live helper — the registry's — so handleCameraDeparted() alone drops the
192
+ * subscriber count to zero. The arrival that follows is then delivered to
193
+ * nobody and the camera stays unbound until a tool call fails against it.
194
+ *
195
+ * PRIMING IS NOT OPTIONAL. A helper that has never enumerated receives
196
+ * nothing. Measured on hardware (darwin-arm64, Tiny 2, 2026-07-21) across one
197
+ * same-port replug, three processes, none of which opened the device:
198
+ *
199
+ * enumerated while camera present -> departure YES, arrival YES
200
+ * never enumerated -> departure NO, arrival NO
201
+ * enumerated while camera absent -> arrival YES
202
+ *
203
+ * The silent one stayed alive the whole run and answered a later enumerate
204
+ * correctly, so that was genuine non-delivery. Its first enumerate took 71ms
205
+ * against ~1ms for the primed pair — AVFoundation's discovery subsystem
206
+ * starting up for the first time. Registering the observers
207
+ * (helper.m:registerCameraNotifications) is NOT what starts delivery;
208
+ * touching the device list is.
209
+ *
210
+ * Windows enforces the same rule for an unrelated reason: helper.cpp drops
211
+ * any event whose path is missing from `g_knownPaths`, which only enumerate
212
+ * fills, and that cache is per-process. There the path must have been seen
213
+ * WHILE THE CAMERA WAS PRESENT — an enumerate during an absence cannot cache
214
+ * it. macOS has no such constraint: an arm primed during an absence received
215
+ * the arrival in the same millisecond as one primed while present.
216
+ *
217
+ * Both halves are now MEASURED, one platform each, same three-arm experiment:
218
+ *
219
+ * primed-present never-primed primed-during-absence
220
+ * macOS yes NO yes
221
+ * Windows yes NO NO
222
+ *
223
+ * The Windows run logged its own mechanism rather than inferring it — the
224
+ * absent-camera enumerate recorded "Tiny 2 in its list = false", so there was
225
+ * no path to cache and the arrival died at helper.cpp:1022.
226
+ *
227
+ * PRIMING REPEATS ON EVERY CALL, and that is Windows insurance specifically.
228
+ * macOS does NOT need it — measured across two full replug cycles, a watcher
229
+ * primed once at startup kept delivering (arrived=2, departed=2), identical
230
+ * to one that re-primed after every departure. The prime is a one-time
231
+ * activation there, not a lease. On Windows a watcher whose predecessor DIED
232
+ * during an absence gets replaced while the camera is gone, never caches the
233
+ * path, and never self-corrects, because the guard below would otherwise
234
+ * early-return on a watcher that is alive and useless. Hardware-confirmed on
235
+ * Windows: that scenario ends `available`, never `bound`, with no arrival ever
236
+ * seen — where the same code on macOS recovers.
237
+ *
238
+ * So the repeat only helps at a moment the camera is PRESENT. Re-priming
239
+ * during an absence is a no-op on Windows; only the next successful bind can
240
+ * cure a deaf watcher there.
241
+ *
242
+ * Costs one idle process once a camera has been bound. It opens nothing, so
243
+ * it holds no exclusive USB handle and the camera stays available to Zoom,
244
+ * OBS and OBSBOT Center — the constraint that shaped handleCameraArrived.
245
+ */
246
+ async ensureWatcher() {
247
+ if (this.everBound.size === 0)
248
+ return; // never ours — stay hands-off
249
+ try {
250
+ if (!this.watcher || this.watcher.isDead)
251
+ this.watcher = await this.makeHelper();
252
+ await this.watcher.enumerate(); // the touch that starts delivery — see above
253
+ }
254
+ catch (e) {
255
+ // NEVER fatal. This runs AFTER promote(), so throwing would escape bind()
256
+ // with the camera already in the registry: the caller sees an exception
257
+ // while listCameras() reports it bound. The watcher only buys a proactive
258
+ // re-bind — without it recovery falls back to the failure-driven path,
259
+ // which is one extra call, exactly where things stood before it existed.
260
+ //
261
+ // Self-healing by construction: a spawn failure leaves `watcher`
262
+ // undefined and a prime failure leaves it unprimed, and either way the
263
+ // next ensureWatcher() call retries. Logged because a self-heal that
264
+ // fails quietly is the failure mode that cost a day of debugging.
265
+ this.log(`obsbot-mcp: bus watcher unavailable (${errText(e)}); ` +
266
+ `replug recovery falls back to the next tool call`);
267
+ }
268
+ }
269
+ async getScanHelper() {
270
+ // A cached scan helper whose process has died must be discarded, not
271
+ // handed back. invalidate() only drops REGISTRY entries, so a helper that
272
+ // died mid-scan (before promote() moved it into the registry) would
273
+ // otherwise stay cached here and every later scan would talk to a corpse —
274
+ // the one path where failing fast on the transport isn't enough to recover.
275
+ if (this.scanHelper?.isDead)
276
+ this.scanHelper = undefined;
277
+ if (!this.scanHelper) {
278
+ this.scanHelper = await this.makeHelper();
279
+ }
280
+ return this.scanHelper;
281
+ }
282
+ /**
283
+ * Compat: raw enumerate() pass-through (unfiltered, no serials). Used by
284
+ * obsbot_capture_snapshot's virtual/ndi source lookup, not by obsbot_devices
285
+ * (which reports the identified fleet via listCameras() instead).
286
+ */
18
287
  async list() {
19
- return this.helper.enumerate();
288
+ const helper = await this.getScanHelper();
289
+ return helper.enumerate();
290
+ }
291
+ /**
292
+ * Scan attached candidates on the scratch helper for a camera to bind.
293
+ *
294
+ * - `wantSerial` given: stop at the first candidate whose serial matches;
295
+ * throws UnknownCameraError (listing every identifiable serial seen)
296
+ * if the scan exhausts without a match.
297
+ * - `wantSerial` omitted: every candidate must be probed so the full
298
+ * fleet is known — a partial scan could under-report ambiguity or
299
+ * silently pick the wrong "only" camera. Binds if exactly one distinct
300
+ * serial turns up; throws AmbiguousCameraError (all distinct serials)
301
+ * if more than one does; throws if none do.
302
+ */
303
+ async bind(wantSerial) {
304
+ const helper = await this.getScanHelper();
305
+ const devices = await helper.enumerate();
306
+ const candidates = devices.filter(isObsbotCamera);
307
+ const found = new Map();
308
+ let matched;
309
+ // Why each candidate was passed over. Every `continue` below is a silent
310
+ // rejection, and when they ALL reject the caller used to get a bare "no
311
+ // OBSBOT camera found" — the same message it gets with nothing plugged in.
312
+ // Carrying the reasons into the error is the difference between "no camera"
313
+ // and "the camera is right there but wouldn't answer".
314
+ const rejected = [];
315
+ for (const d of candidates) {
316
+ let xuNode;
317
+ try {
318
+ xuNode = await helper.open(d.path);
319
+ }
320
+ catch (e) {
321
+ // ANY open failure (exclusive access or otherwise) — skip, not
322
+ // fatal. The next candidate's open() releases this attempt.
323
+ rejected.push(`${d.path}: open failed: ${errText(e)}`);
324
+ continue;
325
+ }
326
+ if (xuNode < 0) {
327
+ // Opened, but this node has no XU unit (e.g. the metadata/ISP node
328
+ // the Tiny 2 also exposes) — not a usable candidate.
329
+ rejected.push(`${d.path}: opened but has no XU unit`);
330
+ continue;
331
+ }
332
+ const transport = this.createTransport(helper);
333
+ let serial;
334
+ try {
335
+ serial = await transport.readSerial();
336
+ }
337
+ catch (e) {
338
+ rejected.push(`${d.path}: ${errText(e)}`);
339
+ continue;
340
+ }
341
+ if (!found.has(serial))
342
+ found.set(serial, { locationId: d.locationId, name: d.name });
343
+ matched = { transport, serial, locationId: d.locationId, path: d.path, name: d.name };
344
+ if (wantSerial && serial === wantSerial)
345
+ break;
346
+ }
347
+ if (wantSerial) {
348
+ if (matched && matched.serial === wantSerial) {
349
+ this.promote(matched);
350
+ await this.ensureWatcher();
351
+ return { transport: matched.transport, serial: matched.serial };
352
+ }
353
+ throw new UnknownCameraError(wantSerial, [...found.keys()]);
354
+ }
355
+ if (found.size === 0) {
356
+ // A helper that SAW a camera and still could not bind it is suspect —
357
+ // drop it rather than cache it for the next attempt.
358
+ //
359
+ // Hardware, 2026-07-21 replug: the macOS helper derives `path` from
360
+ // AVFoundation, whose device list lags the USB bus and, in a long-lived
361
+ // process, did not refresh at all. A helper spawned while the camera was
362
+ // unplugged therefore kept enumerating it with vid/pid but an EMPTY path
363
+ // — unopenable — for over two minutes, while a freshly spawned process
364
+ // saw the device immediately. Retrying on the cached helper could never
365
+ // converge; only a new process could.
366
+ //
367
+ // Deliberately UNCONDITIONAL, including when the bus looks empty.
368
+ //
369
+ // It is tempting to narrow this to "only when a candidate was rejected",
370
+ // since a helper reporting an empty bus looks healthy and the churn is
371
+ // real — the verified run forked 16 processes in 15s. That narrowing was
372
+ // written, unit-tested, and then REVERTED: in the hardware run that
373
+ // actually recovered, every retry during the recovery window reported an
374
+ // empty bus with NO rejected candidate, and it recovered precisely
375
+ // because each attempt forked a fresh process with a fresh IOKit/
376
+ // AVFoundation view. Keeping the helper in that case would restore the
377
+ // bug. A stale process can under-report the bus as empty, so "empty" is
378
+ // not evidence the helper is healthy.
379
+ //
380
+ // Cost is bounded: one spawn (~60ms) per FAILED bind, nothing on the
381
+ // happy path, and it stops as soon as a camera binds.
382
+ await this.discardScanHelper();
383
+ // That discard just closed what may have been the only process left. If
384
+ // the watcher had already died and nothing was bound, this failing call
385
+ // is the ONLY signal that the camera went away — the unplug itself was
386
+ // heard by nobody. Restore the listener (never the scanner, which stays
387
+ // unconditionally fresh) before throwing, or the replug that follows is
388
+ // silent too and costs a second failed call.
389
+ await this.ensureWatcher();
390
+ // With nothing attached there is nothing to explain, so that message
391
+ // stays exactly as it was.
392
+ throw new Error(rejected.length === 0
393
+ ? "no OBSBOT camera found"
394
+ : `no OBSBOT camera found — ${rejected.length} candidate(s) rejected: ${rejected.join("; ")}`);
395
+ }
396
+ if (found.size > 1) {
397
+ throw new AmbiguousCameraError([...found.keys()]);
398
+ }
399
+ // Exactly one distinct serial: `matched` is guaranteed bound to it
400
+ // (every successful candidate shared that one serial).
401
+ this.promote(matched);
402
+ await this.ensureWatcher();
403
+ return { transport: matched.transport, serial: matched.serial };
404
+ }
405
+ /** Move the current scratch helper into the registry under `m.serial`. */
406
+ promote(m) {
407
+ // Reconnect bookkeeping BEFORE recording the bind: if we've bound this
408
+ // serial before, this promote is a re-bind (self-heal after a disconnect),
409
+ // so flag it reconnected. The very first bind of a serial is never a
410
+ // reconnect. everBound must NOT be gated on the registry (invalidate drops
411
+ // registry entries) — that's the whole point of tracking it separately.
412
+ if (this.everBound.has(m.serial))
413
+ this.reconnectedSerials.add(m.serial);
414
+ this.everBound.add(m.serial);
415
+ this.registry.set(m.serial, {
416
+ helper: this.scanHelper,
417
+ transport: m.transport,
418
+ locationId: m.locationId,
419
+ path: m.path,
420
+ name: m.name,
421
+ });
422
+ // This helper now belongs to the registry entry; the next scan needs
423
+ // its own (lazily spawned on next use).
424
+ this.scanHelper = undefined;
425
+ }
426
+ /**
427
+ * Whether the camera identified by `serial` (or, with no arg, the single
428
+ * bound camera) was RE-bound after a prior bind — i.e. self-healed across a
429
+ * mid-session disconnect — clearing that flag on read. Single-camera
430
+ * semantics (no serial): return true if ANY serial is pending-reconnected,
431
+ * draining them all. Preserves the exact contract the retired
432
+ * DeviceSession.takeReconnected() had.
433
+ */
434
+ takeReconnected(serial) {
435
+ if (serial !== undefined) {
436
+ const r = this.reconnectedSerials.has(serial);
437
+ this.reconnectedSerials.delete(serial);
438
+ return r;
439
+ }
440
+ if (this.reconnectedSerials.size === 0)
441
+ return false;
442
+ this.reconnectedSerials.clear();
443
+ return true;
20
444
  }
445
+ /**
446
+ * Drop bound camera(s) from the registry so the next get()/bind() spawns a
447
+ * fresh scratch helper and re-scans from scratch — the correct move after
448
+ * a device has re-enumerated (e.g. unplug/replug), since a stale cached
449
+ * transport talks to a helper that may no longer own a live handle.
450
+ * Closing each dropped helper is best-effort: a helper whose native
451
+ * session already died (the common case right after a disconnect) can
452
+ * throw on close(), and that must not stop the entry from being dropped.
453
+ *
454
+ * serial given -> drop just that camera (no-op if it isn't bound)
455
+ * no serial -> drop every bound camera
456
+ */
457
+ async invalidate(serial) {
458
+ const drop = async (s, entry) => {
459
+ try {
460
+ await entry.helper.close();
461
+ }
462
+ catch {
463
+ // best-effort — a dead/disconnected helper's close() may itself throw
464
+ }
465
+ this.registry.delete(s);
466
+ };
467
+ if (serial) {
468
+ const entry = this.registry.get(serial);
469
+ if (entry)
470
+ await drop(serial, entry);
471
+ return;
472
+ }
473
+ await Promise.all([...this.registry].map(([s, entry]) => drop(s, entry)));
474
+ }
475
+ /**
476
+ * Drop registry entries whose helper process has died, so the next resolve
477
+ * re-binds instead of handing back a transport that can only ever fail.
478
+ *
479
+ * "Once bound, stay bound" is the right default, but a bound entry whose
480
+ * helper is GONE isn't a binding — it's a corpse. Without this, only the
481
+ * tools that route through ensureReady() (which calls invalidate()) could
482
+ * recover; every other tool resolved straight through get() and returned the
483
+ * same dead transport forever. Recovery then required the caller to happen to
484
+ * invoke a different tool — and the caller here is an LLM, which will instead
485
+ * retry the failing tool or report broken hardware that is actually fine.
486
+ *
487
+ * Deliberately a `dead` check and not a liveness probe: it costs a boolean,
488
+ * adds no round trip to the hot path, and leaves a LIVE binding untouched (a
489
+ * guard that re-scanned every call would spawn a helper per tool call).
490
+ *
491
+ * A wedged-but-alive helper is intentionally NOT caught here — that is the
492
+ * per-request timeout's job, and condemning a session for one slow op would
493
+ * throw away a working binding.
494
+ */
495
+ async pruneDeadEntries() {
496
+ for (const [serial, entry] of this.registry) {
497
+ // `deviceLost` covers the unplug case, where the helper PROCESS is alive
498
+ // and healthy but the camera behind it is gone. Hardware-tested
499
+ // 2026-07-21: without this, a replug left every call failing forever —
500
+ // the handle was stranded and only killing the helper recovered it,
501
+ // because process death was the only condition anything watched for.
502
+ if (!entry.helper.isDead && !entry.helper.deviceLost)
503
+ continue;
504
+ // CLOSE, don't just forget. A device-lost helper is still RUNNING and
505
+ // still holding the USB device, so dropping the reference alone leaks the
506
+ // process and the replacement helper can never open the camera. The first
507
+ // cut of this fix did exactly that and turned one stranded handle into a
508
+ // helper leak — every retry spawned another one and recovery never
509
+ // happened. (A dead helper's close() is a harmless no-op, same as in
510
+ // invalidate(), which has always closed-then-deleted for this reason.)
511
+ try {
512
+ await entry.helper.close();
513
+ }
514
+ catch {
515
+ // best-effort — a disconnected helper's close() may itself throw
516
+ }
517
+ this.registry.delete(serial);
518
+ }
519
+ }
520
+ /**
521
+ * Resolve to a bound transport.
522
+ * no serial + one camera attached -> bind & return it
523
+ * no serial + several attached -> AmbiguousCameraError
524
+ * serial given + match -> bind (lazily) & return
525
+ * serial given + no match -> UnknownCameraError
526
+ * Already-bound cameras are returned directly from the registry without
527
+ * rescanning — "bind lazily" means once bound, stay bound.
528
+ */
529
+ async get(serial) {
530
+ await this.pruneDeadEntries();
531
+ if (serial) {
532
+ const existing = this.registry.get(serial);
533
+ if (existing)
534
+ return existing.transport;
535
+ const { transport } = await this.bind(serial);
536
+ return transport;
537
+ }
538
+ if (this.registry.size === 1) {
539
+ return [...this.registry.values()][0].transport;
540
+ }
541
+ if (this.registry.size > 1) {
542
+ throw new AmbiguousCameraError([...this.registry.keys()]);
543
+ }
544
+ const { transport } = await this.bind();
545
+ return transport;
546
+ }
547
+ /**
548
+ * The OS reported a camera detached. Drop any binding on that path now,
549
+ * instead of waiting for a tool call to fail against a dead handle.
550
+ *
551
+ * Without this the registry stays authoritative until something errors, so
552
+ * `obsbot_devices` reports a phantom `bound` entry — serial and all — for a
553
+ * camera physically sitting on the desk. The prune is otherwise reactive:
554
+ * `deviceLost` is only set when an op fails, and listCameras() deliberately
555
+ * does not re-open bound entries to check.
556
+ *
557
+ * Closes the helper rather than merely forgetting it, for the same reason
558
+ * pruneDeadEntries() does: a helper left running still holds the device, so
559
+ * the replacement could never open it.
560
+ */
561
+ async handleCameraDeparted(e) {
562
+ for (const [serial, entry] of this.registry) {
563
+ if (entry.path !== e.path)
564
+ continue;
565
+ try {
566
+ await entry.helper.close();
567
+ }
568
+ catch {
569
+ // best-effort — a disconnected helper's close() may itself throw
570
+ }
571
+ this.registry.delete(serial);
572
+ }
573
+ // The close above may have removed the last live subscriber, and the arrival
574
+ // this departure pairs with has not happened yet. Replacing the watcher here
575
+ // matters when it died unnoticed while the camera was bound: the registry
576
+ // helper was still alive to hear THIS event, but nothing would be alive to
577
+ // hear the next one.
578
+ //
579
+ // The replacement is necessarily spawned with the camera absent, which
580
+ // primes it on macOS but not on Windows — see ensureWatcher(). On Windows
581
+ // this degrades to the failure-driven path and the next successful bind
582
+ // re-primes it, so the call is worth making on both.
583
+ await this.ensureWatcher();
584
+ }
585
+ /**
586
+ * The OS reported a camera attached. Re-establish a binding this process
587
+ * ALREADY held — a genuine self-heal after a replug — and nothing more.
588
+ *
589
+ * Deliberately does not bind a camera we never had. The Tiny 2 is a device
590
+ * Zoom, OBS and OBSBOT Center also want, and a server that grabbed it the
591
+ * moment it appeared would make it busy for them even if nobody ever used
592
+ * this server. `everBound` is the record of what was ours; it survives
593
+ * invalidate() precisely so a re-bind can be recognised as a reconnect.
594
+ *
595
+ * Never throws: the caller is a stdout line handler, and an unhandled
596
+ * rejection there would take down the reader and wedge every in-flight
597
+ * request. A failed re-bind just leaves the next tool call to bind normally.
598
+ */
599
+ async handleCameraArrived(_e) {
600
+ if (this.everBound.size === 0)
601
+ return; // never ours — stay hands-off
602
+ // Something is already bound, so there is nothing to restore.
603
+ if (this.registry.size > 0)
604
+ return;
605
+ // One ladder at a time. Two would double the helper spawns and race two
606
+ // binds into promote(), which assumes the scratch helper is still there.
607
+ if (this.rebinding)
608
+ return;
609
+ this.rebinding = true;
610
+ const total = this.arrivalBackoffMs.length;
611
+ try {
612
+ for (let i = 0; i < total; i++) {
613
+ const delay = this.arrivalBackoffMs[i];
614
+ if (delay > 0)
615
+ await new Promise((r) => setTimeout(r, delay));
616
+ // A tool call may have bound it while we waited — it got there first.
617
+ if (this.registry.size > 0)
618
+ return;
619
+ try {
620
+ await this.bind();
621
+ // Silent on the happy path; every replug would otherwise print. A
622
+ // retried success is worth a line precisely because it is the only
623
+ // evidence the ladder ever does anything.
624
+ if (i > 0)
625
+ this.log(`obsbot-mcp: arrival re-bind succeeded on attempt ${i + 1}`);
626
+ return;
627
+ }
628
+ catch (e) {
629
+ // Keep trying: see the backoff's own comment for why one attempt is
630
+ // a coin flip. Arrival is still only a hint — if the ladder runs out
631
+ // the next tool call binds normally.
632
+ this.log(`obsbot-mcp: arrival re-bind attempt ${i + 1}/${total} failed: ${errText(e)}`);
633
+ }
634
+ }
635
+ this.log(`obsbot-mcp: arrival re-bind gave up after ${total} attempts; ` +
636
+ `the next tool call will bind normally`);
637
+ }
638
+ finally {
639
+ this.rebinding = false;
640
+ }
641
+ }
642
+ /**
643
+ * Close everything this manager is holding: registry helpers, the scratch
644
+ * scanner, and the watcher.
645
+ *
646
+ * Helpers already self-terminate when the parent goes away — they exit on
647
+ * stdin EOF — so this is not what stops them leaking on a crash. It exists
648
+ * because the watcher is the first helper deliberately built to outlive every
649
+ * operation: nothing else bounds its lifetime, so an orderly stop needs
650
+ * something explicit to call. Best-effort throughout; a shutdown path that can
651
+ * itself throw is worse than one that leaves a process for the OS to reap.
652
+ */
653
+ async shutdown() {
654
+ const close = async (h) => {
655
+ if (!h)
656
+ return;
657
+ try {
658
+ await h.close();
659
+ }
660
+ catch {
661
+ // best-effort — a disconnected helper's close() may itself throw
662
+ }
663
+ };
664
+ const watcher = this.watcher;
665
+ this.watcher = undefined;
666
+ await Promise.all([
667
+ ...[...this.registry.values()].map((e) => close(e.helper)),
668
+ this.discardScanHelper(),
669
+ close(watcher),
670
+ ]);
671
+ this.registry.clear();
672
+ }
673
+ /** Compat shim for the single-camera API B1 retires. */
21
674
  async openFirstObsbot() {
22
- const devices = await this.list();
23
- const obsbotDevices = devices.filter((d) => /obsbot/i.test(d.name));
24
- if (obsbotDevices.length === 0) {
25
- throw new Error("no OBSBOT Tiny 2 found");
26
- }
27
- // The OBSBOT exposes two /dev/videoN nodes: one is the video capture
28
- // interface (has the vendor XU extension unit), the other is metadata/ISP
29
- // (no XU). Try each in turn until we find one with an XU unit.
30
- const errors = [];
31
- for (const device of obsbotDevices) {
675
+ return this.get();
676
+ }
677
+ /**
678
+ * Per attached camera: serial (where obtainable), locationId, name, and
679
+ * status. A camera this process cannot open is reported `busy` WITHOUT a
680
+ * serial rather than omitted — it is enumerable but not identifiable.
681
+ * Already-bound cameras are reported from the registry without
682
+ * re-opening (avoids a pointless self-conflict against our own handle).
683
+ */
684
+ async listCameras() {
685
+ // Registry entries are reported as `bound` without re-opening them, so a
686
+ // stale one is indistinguishable from a healthy camera. Observed on
687
+ // hardware 2026-07-21: obsbot_devices kept reporting status:"bound" with a
688
+ // serial for a camera that was physically UNPLUGGED — a caller reads that
689
+ // as "present and ready", which is exactly backwards.
690
+ await this.pruneDeadEntries();
691
+ const results = [];
692
+ const seenSerials = new Set();
693
+ const boundLocationIds = new Set();
694
+ const boundPaths = new Set();
695
+ for (const [serial, entry] of this.registry) {
696
+ results.push({ serial, locationId: entry.locationId, name: entry.name, status: "bound" });
697
+ seenSerials.add(serial);
698
+ if (entry.locationId !== undefined)
699
+ boundLocationIds.add(entry.locationId);
700
+ boundPaths.add(entry.path);
701
+ }
702
+ const helper = await this.getScanHelper();
703
+ const devices = await helper.enumerate();
704
+ const candidates = devices.filter(isObsbotCamera);
705
+ for (const d of candidates) {
706
+ // locationId is macOS-only (undefined on Linux/Windows); `path` is
707
+ // populated on every platform, so it's the dedup key that actually
708
+ // works cross-platform. Without it, a bound camera off-macOS gets
709
+ // re-opened here, collides with the registry helper's own held-open
710
+ // handle, and is double-reported a second time as a serial-less
711
+ // "busy" entry alongside its correct "bound" one.
712
+ if (boundPaths.has(d.path)) {
713
+ continue; // already reported as bound above
714
+ }
715
+ if (d.locationId !== undefined && boundLocationIds.has(d.locationId)) {
716
+ continue; // already reported as bound above
717
+ }
32
718
  try {
33
- const xuNode = await this.helper.open(device.path);
34
- if (xuNode >= 0)
35
- return this.createTransport();
36
- errors.push(`${device.path}: no XU unit`);
719
+ const xuNode = await helper.open(d.path);
720
+ if (xuNode < 0)
721
+ continue;
722
+ const transport = this.createTransport(helper);
723
+ const serial = await transport.readSerial();
724
+ if (seenSerials.has(serial))
725
+ continue; // duplicate node of an already-listed camera
726
+ seenSerials.add(serial);
727
+ results.push({ serial, locationId: d.locationId, name: d.name, status: "available" });
37
728
  }
38
729
  catch (e) {
39
- errors.push(`${device.path}: ${e.message}`);
730
+ results.push({
731
+ locationId: d.locationId,
732
+ name: d.name,
733
+ status: "busy",
734
+ reason: errText(e),
735
+ });
40
736
  }
41
737
  }
42
- throw new Error(`could not open any OBSBOT device:\n ${errors.join("\n ")}`);
738
+ return results;
43
739
  }
44
740
  }
45
741
  //# sourceMappingURL=manager.js.map