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,10 +1,334 @@
1
1
  import { DeviceInfo } from "../codec/types.js";
2
2
  import { HelperProcess } from "../transport/helper-process.js";
3
3
  import { ObsbotTransport } from "../transport/transport.js";
4
+ /** Thrown by get() with no selector when more than one camera is attached. */
5
+ export declare class AmbiguousCameraError extends Error {
6
+ readonly available: string[];
7
+ constructor(available: string[]);
8
+ }
9
+ /** Thrown by get(serial) when no attached, bindable camera has that serial. */
10
+ export declare class UnknownCameraError extends Error {
11
+ readonly available: string[];
12
+ constructor(serial: string, available: string[]);
13
+ }
14
+ export interface CameraInfo {
15
+ /** Present only for cameras this process could open and identify. */
16
+ serial?: string;
17
+ /** macOS: USB locationID. A handle for correlation/display only — never identity. */
18
+ locationId?: number;
19
+ name: string;
20
+ status: "available" | "bound" | "busy";
21
+ /**
22
+ * Why a `busy` camera could not be identified — the underlying open or
23
+ * readSerial error. Absent on cameras that bound fine. "busy" covers two very
24
+ * different situations (another process holds the device vs. the vendor
25
+ * mailbox went quiet) and without this they are indistinguishable from
26
+ * outside.
27
+ */
28
+ reason?: string;
29
+ }
30
+ /**
31
+ * Serial-keyed multi-camera registry. Identity is the serial (read via
32
+ * ObsbotTransport.readSerial() on a freshly-opened device); locationId is
33
+ * only ever a display/correlation hint, never persisted as truth for
34
+ * binding.
35
+ *
36
+ * Steady state is one HelperProcess per bound camera (Map<serial, {helper,
37
+ * transport, locationId}>), spawned lazily — nothing is spawned until a
38
+ * camera is actually needed (get() or listCameras()). While scanning for a
39
+ * camera to bind, candidates are tried on a single scratch helper reused
40
+ * across attempts, mirroring the native helper's own behaviour: `doOpen`
41
+ * unconditionally releases whatever device it previously held before
42
+ * opening the next one, so re-opening a different candidate on the same
43
+ * helper is exactly what one physical device-swap looks like to it. A
44
+ * scratch helper that turns out to be the winning candidate is promoted
45
+ * directly into the registry (no extra spawn); one that never opens a
46
+ * usable camera is kept around for the next scan instead of being spawned
47
+ * again. Consequently DeviceManager never closes a helper it did not spawn
48
+ * itself for a *losing* attempt — ownership of a bound camera's helper
49
+ * transfers to the registry, and callers that hand in an already-started,
50
+ * externally-owned helper (see the .mjs scripts) keep owning its shutdown.
51
+ *
52
+ * USB open is exclusive: a camera another process holds fails `open` with
53
+ * an exclusive-access error. Per multi-camera spec §4.3, ANY open failure
54
+ * during a scan is treated as "not mine, skip" — not fatal — because the
55
+ * helper does not currently surface a clean, stable way to distinguish
56
+ * exclusive-access from other open failures at this layer.
57
+ *
58
+ * invalidate() is the escape hatch out of "once bound, stay bound": it
59
+ * drops a registry entry (best-effort closing its helper first) so the
60
+ * next get()/bind() re-scans through a fresh helper instead of handing back
61
+ * a transport that may be talking to a device that already re-enumerated.
62
+ */
4
63
  export declare class DeviceManager {
5
- private helper;
6
- constructor(helper: HelperProcess);
64
+ private makeHelper;
65
+ private registry;
66
+ /** Lazily spawned, reused across scans until promoted into the registry. */
67
+ private scanHelper?;
68
+ /**
69
+ * Long-lived listener. Holds no device and never scans for one — its only job
70
+ * is to still be alive, and still subscribed, when the cable moves.
71
+ */
72
+ private watcher?;
73
+ /**
74
+ * Reconnect tracking, folded in from the retired DeviceSession. `everBound`
75
+ * records every serial this manager has bound at least once — deliberately
76
+ * SEPARATE from the registry Map so it survives invalidate() dropping a
77
+ * registry entry (the registry says "bound right now", this says "bound at
78
+ * some point"). A promote() of a serial already in `everBound` is therefore a
79
+ * RE-bind — a self-heal after a mid-session disconnect — and lands the serial
80
+ * in `reconnectedSerials`, which the readiness gate drains via
81
+ * takeReconnected() to surface `reconnected: true` on the next command.
82
+ */
83
+ private everBound;
84
+ private reconnectedSerials;
85
+ /**
86
+ * @param makeHelper Factory for a HelperProcess, invoked whenever a fresh
87
+ * scratch helper is needed (each scan, and each rebind after
88
+ * invalidate()). CONTRACT: it MUST return a freshly-started,
89
+ * exclusively-owned HelperProcess on every call — DeviceManager may call
90
+ * it repeatedly across a session and always expects a clean handle, not
91
+ * a shared one. A factory that instead returns the SAME already-started
92
+ * helper on every call (as the single-camera `.mjs` scripts under
93
+ * scripts/ do, to keep one native session alive for their whole run)
94
+ * MUST NOT bind more than one serial through this manager: a second
95
+ * scan would hand that same shared helper back and silently steal the
96
+ * first bound camera's native session out from under it. No current
97
+ * caller does this (all are single-camera); this is latent and not
98
+ * guarded in code — the proper fix belongs to multi-camera .mjs/CLI
99
+ * wiring, not to DeviceManager itself.
100
+ */
101
+ /** True while an arrival re-bind ladder is running (see handleCameraArrived). */
102
+ private rebinding;
103
+ /**
104
+ * Delays before each arrival re-bind attempt, in order — so four attempts
105
+ * spanning ~4.5s, the first immediate.
106
+ *
107
+ * Sized from hardware, 2026-07-21: for many seconds after a USB
108
+ * re-enumeration the Tiny 2's vendor mailbox is intermittently not-ready
109
+ * (the reply slot reads back with its magic byte zeroed), and readSerial's
110
+ * own 8 x 30ms poll is too short to ride it out. Measured 22 failures in 80
111
+ * attempts across the first 14s after a replug, against 0 in 120 in steady
112
+ * state — so a single attempt on arrival is roughly a 1-in-4 coin flip, and
113
+ * losing it silently leaves the camera unbound until the user's next call.
114
+ * Four spread attempts make a lost re-bind unlikely without retrying forever
115
+ * at a camera that is simply not answering.
116
+ */
117
+ private arrivalBackoffMs;
118
+ /**
119
+ * Where a failed or retried arrival re-bind is reported. STDERR by default —
120
+ * never stdout, which is the JSON-RPC channel.
121
+ *
122
+ * Exists because the ladder was silent: a ladder that fired and a clean first
123
+ * attempt looked identical from outside the process, so the retry could not
124
+ * be observed on hardware even once it existed. A self-heal that fails
125
+ * quietly is the failure mode that cost a day of debugging.
126
+ */
127
+ private log;
128
+ constructor(makeHelper: () => Promise<HelperProcess>, opts?: {
129
+ arrivalBackoffMs?: number[];
130
+ log?: (msg: string) => void;
131
+ });
7
132
  private createTransport;
133
+ /**
134
+ * Close and forget the scratch helper. Closing matters as much as forgetting:
135
+ * the process is alive and holding whatever it opened, so dropping only the
136
+ * reference leaks it and leaves the replacement unable to open the camera —
137
+ * the same mistake pruneDeadEntries() makes if it merely deletes an entry.
138
+ */
139
+ private discardScanHelper;
140
+ /**
141
+ * Keep one helper alive purely to receive bus events.
142
+ *
143
+ * Bus events are delivered per PROCESS (helperFactory subscribes each one it
144
+ * spawns), and every process that could be listening gets closed: a departure
145
+ * closes the registry helper, and a failed bind closes the scratch scanner.
146
+ * Since promote() clears `scanHelper`, the bound steady state is exactly ONE
147
+ * live helper — the registry's — so handleCameraDeparted() alone drops the
148
+ * subscriber count to zero. The arrival that follows is then delivered to
149
+ * nobody and the camera stays unbound until a tool call fails against it.
150
+ *
151
+ * PRIMING IS NOT OPTIONAL. A helper that has never enumerated receives
152
+ * nothing. Measured on hardware (darwin-arm64, Tiny 2, 2026-07-21) across one
153
+ * same-port replug, three processes, none of which opened the device:
154
+ *
155
+ * enumerated while camera present -> departure YES, arrival YES
156
+ * never enumerated -> departure NO, arrival NO
157
+ * enumerated while camera absent -> arrival YES
158
+ *
159
+ * The silent one stayed alive the whole run and answered a later enumerate
160
+ * correctly, so that was genuine non-delivery. Its first enumerate took 71ms
161
+ * against ~1ms for the primed pair — AVFoundation's discovery subsystem
162
+ * starting up for the first time. Registering the observers
163
+ * (helper.m:registerCameraNotifications) is NOT what starts delivery;
164
+ * touching the device list is.
165
+ *
166
+ * Windows enforces the same rule for an unrelated reason: helper.cpp drops
167
+ * any event whose path is missing from `g_knownPaths`, which only enumerate
168
+ * fills, and that cache is per-process. There the path must have been seen
169
+ * WHILE THE CAMERA WAS PRESENT — an enumerate during an absence cannot cache
170
+ * it. macOS has no such constraint: an arm primed during an absence received
171
+ * the arrival in the same millisecond as one primed while present.
172
+ *
173
+ * Both halves are now MEASURED, one platform each, same three-arm experiment:
174
+ *
175
+ * primed-present never-primed primed-during-absence
176
+ * macOS yes NO yes
177
+ * Windows yes NO NO
178
+ *
179
+ * The Windows run logged its own mechanism rather than inferring it — the
180
+ * absent-camera enumerate recorded "Tiny 2 in its list = false", so there was
181
+ * no path to cache and the arrival died at helper.cpp:1022.
182
+ *
183
+ * PRIMING REPEATS ON EVERY CALL, and that is Windows insurance specifically.
184
+ * macOS does NOT need it — measured across two full replug cycles, a watcher
185
+ * primed once at startup kept delivering (arrived=2, departed=2), identical
186
+ * to one that re-primed after every departure. The prime is a one-time
187
+ * activation there, not a lease. On Windows a watcher whose predecessor DIED
188
+ * during an absence gets replaced while the camera is gone, never caches the
189
+ * path, and never self-corrects, because the guard below would otherwise
190
+ * early-return on a watcher that is alive and useless. Hardware-confirmed on
191
+ * Windows: that scenario ends `available`, never `bound`, with no arrival ever
192
+ * seen — where the same code on macOS recovers.
193
+ *
194
+ * So the repeat only helps at a moment the camera is PRESENT. Re-priming
195
+ * during an absence is a no-op on Windows; only the next successful bind can
196
+ * cure a deaf watcher there.
197
+ *
198
+ * Costs one idle process once a camera has been bound. It opens nothing, so
199
+ * it holds no exclusive USB handle and the camera stays available to Zoom,
200
+ * OBS and OBSBOT Center — the constraint that shaped handleCameraArrived.
201
+ */
202
+ private ensureWatcher;
203
+ private getScanHelper;
204
+ /**
205
+ * Compat: raw enumerate() pass-through (unfiltered, no serials). Used by
206
+ * obsbot_capture_snapshot's virtual/ndi source lookup, not by obsbot_devices
207
+ * (which reports the identified fleet via listCameras() instead).
208
+ */
8
209
  list(): Promise<DeviceInfo[]>;
210
+ /**
211
+ * Scan attached candidates on the scratch helper for a camera to bind.
212
+ *
213
+ * - `wantSerial` given: stop at the first candidate whose serial matches;
214
+ * throws UnknownCameraError (listing every identifiable serial seen)
215
+ * if the scan exhausts without a match.
216
+ * - `wantSerial` omitted: every candidate must be probed so the full
217
+ * fleet is known — a partial scan could under-report ambiguity or
218
+ * silently pick the wrong "only" camera. Binds if exactly one distinct
219
+ * serial turns up; throws AmbiguousCameraError (all distinct serials)
220
+ * if more than one does; throws if none do.
221
+ */
222
+ private bind;
223
+ /** Move the current scratch helper into the registry under `m.serial`. */
224
+ private promote;
225
+ /**
226
+ * Whether the camera identified by `serial` (or, with no arg, the single
227
+ * bound camera) was RE-bound after a prior bind — i.e. self-healed across a
228
+ * mid-session disconnect — clearing that flag on read. Single-camera
229
+ * semantics (no serial): return true if ANY serial is pending-reconnected,
230
+ * draining them all. Preserves the exact contract the retired
231
+ * DeviceSession.takeReconnected() had.
232
+ */
233
+ takeReconnected(serial?: string): boolean;
234
+ /**
235
+ * Drop bound camera(s) from the registry so the next get()/bind() spawns a
236
+ * fresh scratch helper and re-scans from scratch — the correct move after
237
+ * a device has re-enumerated (e.g. unplug/replug), since a stale cached
238
+ * transport talks to a helper that may no longer own a live handle.
239
+ * Closing each dropped helper is best-effort: a helper whose native
240
+ * session already died (the common case right after a disconnect) can
241
+ * throw on close(), and that must not stop the entry from being dropped.
242
+ *
243
+ * serial given -> drop just that camera (no-op if it isn't bound)
244
+ * no serial -> drop every bound camera
245
+ */
246
+ invalidate(serial?: string): Promise<void>;
247
+ /**
248
+ * Drop registry entries whose helper process has died, so the next resolve
249
+ * re-binds instead of handing back a transport that can only ever fail.
250
+ *
251
+ * "Once bound, stay bound" is the right default, but a bound entry whose
252
+ * helper is GONE isn't a binding — it's a corpse. Without this, only the
253
+ * tools that route through ensureReady() (which calls invalidate()) could
254
+ * recover; every other tool resolved straight through get() and returned the
255
+ * same dead transport forever. Recovery then required the caller to happen to
256
+ * invoke a different tool — and the caller here is an LLM, which will instead
257
+ * retry the failing tool or report broken hardware that is actually fine.
258
+ *
259
+ * Deliberately a `dead` check and not a liveness probe: it costs a boolean,
260
+ * adds no round trip to the hot path, and leaves a LIVE binding untouched (a
261
+ * guard that re-scanned every call would spawn a helper per tool call).
262
+ *
263
+ * A wedged-but-alive helper is intentionally NOT caught here — that is the
264
+ * per-request timeout's job, and condemning a session for one slow op would
265
+ * throw away a working binding.
266
+ */
267
+ private pruneDeadEntries;
268
+ /**
269
+ * Resolve to a bound transport.
270
+ * no serial + one camera attached -> bind & return it
271
+ * no serial + several attached -> AmbiguousCameraError
272
+ * serial given + match -> bind (lazily) & return
273
+ * serial given + no match -> UnknownCameraError
274
+ * Already-bound cameras are returned directly from the registry without
275
+ * rescanning — "bind lazily" means once bound, stay bound.
276
+ */
277
+ get(serial?: string): Promise<ObsbotTransport>;
278
+ /**
279
+ * The OS reported a camera detached. Drop any binding on that path now,
280
+ * instead of waiting for a tool call to fail against a dead handle.
281
+ *
282
+ * Without this the registry stays authoritative until something errors, so
283
+ * `obsbot_devices` reports a phantom `bound` entry — serial and all — for a
284
+ * camera physically sitting on the desk. The prune is otherwise reactive:
285
+ * `deviceLost` is only set when an op fails, and listCameras() deliberately
286
+ * does not re-open bound entries to check.
287
+ *
288
+ * Closes the helper rather than merely forgetting it, for the same reason
289
+ * pruneDeadEntries() does: a helper left running still holds the device, so
290
+ * the replacement could never open it.
291
+ */
292
+ handleCameraDeparted(e: {
293
+ path: string;
294
+ }): Promise<void>;
295
+ /**
296
+ * The OS reported a camera attached. Re-establish a binding this process
297
+ * ALREADY held — a genuine self-heal after a replug — and nothing more.
298
+ *
299
+ * Deliberately does not bind a camera we never had. The Tiny 2 is a device
300
+ * Zoom, OBS and OBSBOT Center also want, and a server that grabbed it the
301
+ * moment it appeared would make it busy for them even if nobody ever used
302
+ * this server. `everBound` is the record of what was ours; it survives
303
+ * invalidate() precisely so a re-bind can be recognised as a reconnect.
304
+ *
305
+ * Never throws: the caller is a stdout line handler, and an unhandled
306
+ * rejection there would take down the reader and wedge every in-flight
307
+ * request. A failed re-bind just leaves the next tool call to bind normally.
308
+ */
309
+ handleCameraArrived(_e: {
310
+ path: string;
311
+ }): Promise<void>;
312
+ /**
313
+ * Close everything this manager is holding: registry helpers, the scratch
314
+ * scanner, and the watcher.
315
+ *
316
+ * Helpers already self-terminate when the parent goes away — they exit on
317
+ * stdin EOF — so this is not what stops them leaking on a crash. It exists
318
+ * because the watcher is the first helper deliberately built to outlive every
319
+ * operation: nothing else bounds its lifetime, so an orderly stop needs
320
+ * something explicit to call. Best-effort throughout; a shutdown path that can
321
+ * itself throw is worse than one that leaves a process for the OS to reap.
322
+ */
323
+ shutdown(): Promise<void>;
324
+ /** Compat shim for the single-camera API B1 retires. */
9
325
  openFirstObsbot(): Promise<ObsbotTransport>;
326
+ /**
327
+ * Per attached camera: serial (where obtainable), locationId, name, and
328
+ * status. A camera this process cannot open is reported `busy` WITHOUT a
329
+ * serial rather than omitted — it is enumerable but not identifiable.
330
+ * Already-bound cameras are reported from the registry without
331
+ * re-opening (avoids a pointless self-conflict against our own handle).
332
+ */
333
+ listCameras(): Promise<CameraInfo[]>;
10
334
  }