@agentdeck/bridge 1.0.12 → 1.0.14

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 (67) hide show
  1. package/README.md +49 -0
  2. package/dist/apme/adapters/codex-turn-manager.d.ts +13 -0
  3. package/dist/apme/adapters/codex-turn-manager.d.ts.map +1 -1
  4. package/dist/apme/adapters/codex-turn-manager.js +61 -15
  5. package/dist/apme/adapters/codex-turn-manager.js.map +1 -1
  6. package/dist/cli.d.ts.map +1 -1
  7. package/dist/cli.js +34 -19
  8. package/dist/cli.js.map +1 -1
  9. package/dist/d200h/hid-protocol.d.ts +62 -0
  10. package/dist/d200h/hid-protocol.d.ts.map +1 -0
  11. package/dist/d200h/hid-protocol.js +124 -0
  12. package/dist/d200h/hid-protocol.js.map +1 -0
  13. package/dist/d200h/image-renderer.d.ts +24 -0
  14. package/dist/d200h/image-renderer.d.ts.map +1 -0
  15. package/dist/d200h/image-renderer.js +321 -0
  16. package/dist/d200h/image-renderer.js.map +1 -0
  17. package/dist/daemon-server.d.ts.map +1 -1
  18. package/dist/daemon-server.js +47 -1
  19. package/dist/daemon-server.js.map +1 -1
  20. package/dist/daemon.js +1 -1
  21. package/dist/foundation-models-helper.d.ts +10 -0
  22. package/dist/foundation-models-helper.d.ts.map +1 -1
  23. package/dist/foundation-models-helper.js +59 -6
  24. package/dist/foundation-models-helper.js.map +1 -1
  25. package/dist/hook-migration.d.ts +2 -0
  26. package/dist/hook-migration.d.ts.map +1 -0
  27. package/dist/hook-migration.js +116 -0
  28. package/dist/hook-migration.js.map +1 -0
  29. package/dist/modules/d200h-module.d.ts +65 -0
  30. package/dist/modules/d200h-module.d.ts.map +1 -0
  31. package/dist/modules/d200h-module.js +495 -0
  32. package/dist/modules/d200h-module.js.map +1 -0
  33. package/dist/modules/d200h-renderer.d.ts +7 -0
  34. package/dist/modules/d200h-renderer.d.ts.map +1 -0
  35. package/dist/modules/d200h-renderer.js +321 -0
  36. package/dist/modules/d200h-renderer.js.map +1 -0
  37. package/dist/modules/trmnl-module.d.ts +38 -0
  38. package/dist/modules/trmnl-module.d.ts.map +1 -0
  39. package/dist/modules/trmnl-module.js +111 -0
  40. package/dist/modules/trmnl-module.js.map +1 -0
  41. package/dist/trmnl/byos-server.d.ts +39 -0
  42. package/dist/trmnl/byos-server.d.ts.map +1 -0
  43. package/dist/trmnl/byos-server.js +301 -0
  44. package/dist/trmnl/byos-server.js.map +1 -0
  45. package/dist/trmnl/frame-cache.d.ts +37 -0
  46. package/dist/trmnl/frame-cache.d.ts.map +1 -0
  47. package/dist/trmnl/frame-cache.js +157 -0
  48. package/dist/trmnl/frame-cache.js.map +1 -0
  49. package/dist/trmnl/image-renderer.d.ts +27 -0
  50. package/dist/trmnl/image-renderer.d.ts.map +1 -0
  51. package/dist/trmnl/image-renderer.js +235 -0
  52. package/dist/trmnl/image-renderer.js.map +1 -0
  53. package/dist/trmnl/trmnl-settings.d.ts +96 -0
  54. package/dist/trmnl/trmnl-settings.d.ts.map +1 -0
  55. package/dist/trmnl/trmnl-settings.js +177 -0
  56. package/dist/trmnl/trmnl-settings.js.map +1 -0
  57. package/dist/trmnl/trmnl-telemetry.d.ts +47 -0
  58. package/dist/trmnl/trmnl-telemetry.d.ts.map +1 -0
  59. package/dist/trmnl/trmnl-telemetry.js +56 -0
  60. package/dist/trmnl/trmnl-telemetry.js.map +1 -0
  61. package/dist/whisper-server-manager.d.ts +19 -0
  62. package/dist/whisper-server-manager.d.ts.map +1 -0
  63. package/dist/whisper-server-manager.js +171 -0
  64. package/dist/whisper-server-manager.js.map +1 -0
  65. package/fm-helper/AgentDeckFMHelper.swift +111 -10
  66. package/package.json +20 -4
  67. package/assets/fm-helper/agentdeck-fm-helper +0 -0
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Whisper-server singleton manager.
3
+ *
4
+ * Fixed port 9100 — one server shared across all agentdeck sessions.
5
+ * Info file at ~/.agentdeck/whisper-server.json for discovery.
6
+ * Last session to exit kills the server.
7
+ */
8
+ export declare const WHISPER_SERVER_PORT = 9100;
9
+ /**
10
+ * Ensure a whisper-server is running on the singleton port.
11
+ * Returns port on success, null if unavailable (binary missing, model missing, etc).
12
+ */
13
+ export declare function ensureWhisperServer(serverBin: string, modelPath: string): Promise<number | null>;
14
+ /**
15
+ * Release the whisper-server if no other sessions remain.
16
+ * Call AFTER deregisterSession() so the count is already decremented.
17
+ */
18
+ export declare function releaseWhisperServer(): void;
19
+ //# sourceMappingURL=whisper-server-manager.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"whisper-server-manager.d.ts","sourceRoot":"","sources":["../src/whisper-server-manager.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAaH,eAAO,MAAM,mBAAmB,OAAO,CAAC;AA2DxC;;;GAGG;AACH,wBAAsB,mBAAmB,CACvC,SAAS,EAAE,MAAM,EACjB,SAAS,EAAE,MAAM,GAChB,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CA6ExB;AAED;;;GAGG;AACH,wBAAgB,oBAAoB,IAAI,IAAI,CAiB3C"}
@@ -0,0 +1,171 @@
1
+ /**
2
+ * Whisper-server singleton manager.
3
+ *
4
+ * Fixed port 9100 — one server shared across all agentdeck sessions.
5
+ * Info file at ~/.agentdeck/whisper-server.json for discovery.
6
+ * Last session to exit kills the server.
7
+ */
8
+ import { spawn } from 'child_process';
9
+ import { readFileSync, writeFileSync, renameSync, unlinkSync, mkdirSync, existsSync, } from 'fs';
10
+ import { join } from 'path';
11
+ import { homedir } from 'os';
12
+ import { randomUUID } from 'crypto';
13
+ import { listActive } from './session-registry.js';
14
+ import { debug } from './logger.js';
15
+ export const WHISPER_SERVER_PORT = 9100;
16
+ const INFO_DIR = join(homedir(), '.agentdeck');
17
+ const INFO_FILE = join(INFO_DIR, 'whisper-server.json');
18
+ function ensureDir() {
19
+ if (!existsSync(INFO_DIR)) {
20
+ mkdirSync(INFO_DIR, { recursive: true });
21
+ }
22
+ }
23
+ function readInfo() {
24
+ try {
25
+ return JSON.parse(readFileSync(INFO_FILE, 'utf-8'));
26
+ }
27
+ catch {
28
+ return null;
29
+ }
30
+ }
31
+ function writeInfo(info) {
32
+ ensureDir();
33
+ const tmp = join(INFO_DIR, `.whisper-server.${randomUUID()}.tmp`);
34
+ writeFileSync(tmp, JSON.stringify(info, null, 2), 'utf-8');
35
+ renameSync(tmp, INFO_FILE);
36
+ }
37
+ function removeInfo() {
38
+ try {
39
+ unlinkSync(INFO_FILE);
40
+ }
41
+ catch { /* already gone */ }
42
+ }
43
+ function isProcessAlive(pid) {
44
+ try {
45
+ process.kill(pid, 0);
46
+ return true;
47
+ }
48
+ catch {
49
+ return false;
50
+ }
51
+ }
52
+ async function isServerResponding(port) {
53
+ try {
54
+ await fetch(`http://127.0.0.1:${port}`, { signal: AbortSignal.timeout(1000) });
55
+ return true;
56
+ }
57
+ catch {
58
+ return false;
59
+ }
60
+ }
61
+ async function pollUntilReady(port, maxMs) {
62
+ const start = Date.now();
63
+ while (Date.now() - start < maxMs) {
64
+ if (await isServerResponding(port))
65
+ return true;
66
+ await new Promise((r) => setTimeout(r, 500));
67
+ }
68
+ return false;
69
+ }
70
+ /**
71
+ * Ensure a whisper-server is running on the singleton port.
72
+ * Returns port on success, null if unavailable (binary missing, model missing, etc).
73
+ */
74
+ export async function ensureWhisperServer(serverBin, modelPath) {
75
+ if (!existsSync(serverBin)) {
76
+ debug('WhisperMgr', `whisper-server not found at ${serverBin}, skipping`);
77
+ return null;
78
+ }
79
+ // 1. Check existing info file
80
+ const info = readInfo();
81
+ if (info) {
82
+ if (isProcessAlive(info.pid)) {
83
+ // PID alive — check if server is responding
84
+ if (await isServerResponding(info.port)) {
85
+ debug('WhisperMgr', `Reusing existing server (pid=${info.pid}, port=${info.port})`);
86
+ return info.port;
87
+ }
88
+ // PID alive but not responding — model may still be loading, poll 30s
89
+ debug('WhisperMgr', `Server pid=${info.pid} alive but not responding, waiting for model load...`);
90
+ if (await pollUntilReady(info.port, 30_000)) {
91
+ debug('WhisperMgr', `Server became ready (pid=${info.pid}, port=${info.port})`);
92
+ return info.port;
93
+ }
94
+ // Still not responding after 30s — stale
95
+ debug('WhisperMgr', `Server pid=${info.pid} never responded, treating as stale`);
96
+ }
97
+ // PID dead or stale — clean up
98
+ removeInfo();
99
+ }
100
+ // 2. Spawn new server (detached so it survives parent exit)
101
+ debug('WhisperMgr', `Starting whisper-server on port ${WHISPER_SERVER_PORT}...`);
102
+ const args = [
103
+ '--model', modelPath,
104
+ '--port', String(WHISPER_SERVER_PORT),
105
+ '--host', '127.0.0.1',
106
+ '-l', 'auto',
107
+ '--no-timestamps',
108
+ '--convert',
109
+ '--prompt', 'coding, programming, Claude, terminal, git, function, component, API',
110
+ ];
111
+ const proc = spawn(serverBin, args, {
112
+ stdio: 'ignore',
113
+ detached: true,
114
+ });
115
+ proc.unref();
116
+ const pid = proc.pid;
117
+ if (!pid) {
118
+ debug('WhisperMgr', 'Failed to spawn whisper-server (no pid)');
119
+ return null;
120
+ }
121
+ // 3. Brief wait to detect immediate crash (e.g. port conflict)
122
+ await new Promise((r) => setTimeout(r, 200));
123
+ if (!isProcessAlive(pid)) {
124
+ debug('WhisperMgr', `whisper-server died immediately (pid=${pid}), checking for existing server...`);
125
+ // Another process may have won the port race — re-check info file
126
+ const otherInfo = readInfo();
127
+ if (otherInfo && isProcessAlive(otherInfo.pid) && await isServerResponding(otherInfo.port)) {
128
+ debug('WhisperMgr', `Found winner server (pid=${otherInfo.pid}, port=${otherInfo.port})`);
129
+ return otherInfo.port;
130
+ }
131
+ removeInfo();
132
+ return null;
133
+ }
134
+ // 4. Write info file
135
+ writeInfo({ pid, port: WHISPER_SERVER_PORT, modelPath, startedAt: new Date().toISOString() });
136
+ // 5. Poll until ready (up to 30s for model loading)
137
+ if (await pollUntilReady(WHISPER_SERVER_PORT, 30_000)) {
138
+ debug('WhisperMgr', `whisper-server ready on port ${WHISPER_SERVER_PORT} (pid=${pid})`);
139
+ return WHISPER_SERVER_PORT;
140
+ }
141
+ debug('WhisperMgr', 'whisper-server failed to become ready within 30s');
142
+ return null;
143
+ }
144
+ /**
145
+ * Release the whisper-server if no other sessions remain.
146
+ * Call AFTER deregisterSession() so the count is already decremented.
147
+ */
148
+ export function releaseWhisperServer() {
149
+ const activeSessions = listActive();
150
+ if (activeSessions.length > 0) {
151
+ debug('WhisperMgr', `Keeping whisper-server alive (${activeSessions.length} session(s) remain)`);
152
+ return;
153
+ }
154
+ const info = readInfo();
155
+ if (!info)
156
+ return;
157
+ debug('WhisperMgr', `Killing whisper-server (pid=${info.pid})`);
158
+ try {
159
+ process.kill(info.pid, 'SIGTERM');
160
+ }
161
+ catch { /* already dead */ }
162
+ // Force kill after 3s
163
+ setTimeout(() => {
164
+ try {
165
+ process.kill(info.pid, 'SIGKILL');
166
+ }
167
+ catch { /* already dead */ }
168
+ }, 3000);
169
+ removeInfo();
170
+ }
171
+ //# sourceMappingURL=whisper-server-manager.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"whisper-server-manager.js","sourceRoot":"","sources":["../src/whisper-server-manager.ts"],"names":[],"mappings":"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"}
@@ -48,17 +48,17 @@ struct AgentDeckFMHelper {
48
48
  }
49
49
 
50
50
  if request.type == "transcribe" {
51
- await handleTranscribe(request)
51
+ dispatchLongWork { await handleTranscribe(request) }
52
52
  return
53
53
  }
54
54
 
55
55
  if request.type == "speak" {
56
- await handleSpeak(request)
56
+ dispatchLongWork { await handleSpeak(request) }
57
57
  return
58
58
  }
59
59
 
60
60
  if request.type == "synthesize" {
61
- await handleSynthesize(request)
61
+ dispatchLongWork { await handleSynthesize(request) }
62
62
  return
63
63
  }
64
64
 
@@ -76,6 +76,26 @@ struct AgentDeckFMHelper {
76
76
  return
77
77
  }
78
78
 
79
+ dispatchLongWork { await handleGenerate(request) }
80
+ }
81
+
82
+ /// Long work must never run on the stdin read loop.
83
+ ///
84
+ /// Measured 2026-08-08 on a D200H: one in-flight `generate` kept the loop
85
+ /// busy for 25 s, so the `record_stop` queued behind it was not even READ
86
+ /// until it finished. The push-to-talk capture ran to its 30 s cap and
87
+ /// transcribed 30 s of mostly silence, which reached the user as "voice
88
+ /// does nothing". `record` was already detached for exactly this reason;
89
+ /// every other slow request needed the same treatment.
90
+ ///
91
+ /// The work stays serialized, in read order, exactly as it was when the
92
+ /// loop ran it — only the READING of the next line is freed, which is all a
93
+ /// control line like `record_stop` needs.
94
+ private static func dispatchLongWork(_ body: @escaping @Sendable () async -> Void) {
95
+ workChain.enqueue(body)
96
+ }
97
+
98
+ private static func handleGenerate(_ request: HelperRequest) async {
79
99
  guard let prompt = request.prompt, !prompt.isEmpty else {
80
100
  write(["id": request.id, "error": "bad_request", "reason": "missing prompt"])
81
101
  return
@@ -198,8 +218,15 @@ struct AgentDeckFMHelper {
198
218
  write(["id": request.id, "error": "bad_request", "reason": "missing wav output path"])
199
219
  return
200
220
  }
221
+ // Reserve before the slow setup below, so a stop arriving mid-setup has
222
+ // somewhere to land (see RecordingBox.pendingStop).
223
+ guard activeRecording.reserve(requestId: request.id) else {
224
+ write(["id": request.id, "error": "busy", "reason": "a recording is already in progress"])
225
+ return
226
+ }
201
227
  let granted = await ensureMicAuthorization()
202
228
  guard granted else {
229
+ activeRecording.release()
203
230
  write([
204
231
  "id": request.id,
205
232
  "error": "unauthorized",
@@ -212,6 +239,7 @@ struct AgentDeckFMHelper {
212
239
  sampleRate: sampleRate,
213
240
  channels: 1,
214
241
  interleaved: true) else {
242
+ activeRecording.release()
215
243
  write(["id": request.id, "error": "record_failed", "reason": "output format unavailable"])
216
244
  return
217
245
  }
@@ -220,12 +248,19 @@ struct AgentDeckFMHelper {
220
248
  let input = engine.inputNode
221
249
  let inFormat = input.outputFormat(forBus: 0)
222
250
  guard inFormat.sampleRate > 0, inFormat.channelCount > 0 else {
251
+ activeRecording.release()
223
252
  write(["id": request.id, "error": "record_failed", "reason": "no audio input device"])
224
253
  return
225
254
  }
226
255
  let sink = PCMSink()
227
- guard activeRecording.begin(requestId: request.id, engine: engine, sink: sink) else {
228
- write(["id": request.id, "error": "busy", "reason": "a recording is already in progress"])
256
+ if let stopped = activeRecording.begin(engine: engine, sink: sink) {
257
+ // The stop beat the setup. Answer it as the capture it was meant
258
+ // for instead of starting an engine nobody is waiting on.
259
+ if stopped.cancelled {
260
+ write(["id": request.id, "cancelled": true])
261
+ } else {
262
+ write(["id": request.id, "error": "record_failed", "reason": "stopped before capture started"])
263
+ }
229
264
  return
230
265
  }
231
266
  input.installTap(onBus: 0, bufferSize: 4096, format: inFormat) { buffer, _ in
@@ -437,6 +472,9 @@ struct AgentDeckFMHelper {
437
472
  /// task; the lock keeps two replies from interleaving inside one line.
438
473
  private static let writeLock = NSLock()
439
474
 
475
+ /// See `dispatchLongWork`.
476
+ private static let workChain = WorkChain()
477
+
440
478
  private static func write(_ object: [String: Any]) {
441
479
  guard JSONSerialization.isValidJSONObject(object),
442
480
  let data = try? JSONSerialization.data(withJSONObject: object),
@@ -449,6 +487,27 @@ struct AgentDeckFMHelper {
449
487
  }
450
488
  }
451
489
 
490
+ /// Serializes the helper's slow requests (LLM generation, speech recognition,
491
+ /// TTS) off the stdin read loop, while the loop stays free to read control
492
+ /// lines such as `record_stop`.
493
+ ///
494
+ /// A chain rather than an actor: actor executors guarantee mutual exclusion but
495
+ /// NOT FIFO, and `Task.detached` does not even preserve the order in which the
496
+ /// tasks were created — so two queued `speak` requests could have been voiced
497
+ /// out of order. Linking each unit of work to its predecessor's completion
498
+ /// keeps the read order the loop used to provide. The enqueue itself is O(1)
499
+ /// and happens on the read loop, which is the only mutator.
500
+ private final class WorkChain: @unchecked Sendable {
501
+ private let lock = NSLock()
502
+ private var tail: Task<Void, Never> = Task {}
503
+
504
+ func enqueue(_ body: @escaping @Sendable () async -> Void) {
505
+ lock.lock(); defer { lock.unlock() }
506
+ let previous = tail
507
+ tail = Task { await previous.value; await body() }
508
+ }
509
+ }
510
+
452
511
  /// State for the single in-flight microphone capture. One at a time by design:
453
512
  /// the daemon models push-to-talk, and two overlapping engine taps on the same
454
513
  /// input device produce garbage anyway.
@@ -463,17 +522,43 @@ private final class RecordingBox: @unchecked Sendable {
463
522
  /// Bumped per recording so a stale max-duration timer from a finished
464
523
  /// capture cannot terminate the one that started after it.
465
524
  private var generation = 0
466
-
467
- func begin(requestId: Int, engine: AVAudioEngine, sink: PCMSink) -> Bool {
525
+ /// A stop that arrived while the capture was still being set up.
526
+ ///
527
+ /// `handleRecord` reserves the slot, then spends real time on mic
528
+ /// authorization, format negotiation and engine construction before the
529
+ /// tap is live. Hold-to-talk routinely beats that: a short hold puts
530
+ /// `record_stop` on stdin while `requestId` was set but no continuation
531
+ /// exists yet. Without this latch the stop was dropped, the capture ran to
532
+ /// its 30s cap, and 30s of room noise was transcribed and injected into
533
+ /// the user's session — worst of all for the sub-250ms tap, whose whole
534
+ /// meaning is "never mind".
535
+ private var pendingStop: Outcome?
536
+
537
+ /// Claim the slot before the slow setup. Returns false when a capture is
538
+ /// already in flight.
539
+ func reserve(requestId: Int) -> Bool {
468
540
  lock.lock(); defer { lock.unlock() }
469
541
  guard self.requestId == nil else { return false }
470
542
  self.requestId = requestId
471
- self.engine = engine
472
- self.sink = sink
543
+ self.pendingStop = nil
473
544
  generation += 1
474
545
  return true
475
546
  }
476
547
 
548
+ /// Arm the reserved slot with the live engine. Returns the stop that
549
+ /// arrived during setup, if any — the caller must then not start at all.
550
+ func begin(engine: AVAudioEngine, sink: PCMSink) -> Outcome? {
551
+ lock.lock(); defer { lock.unlock() }
552
+ if let stop = pendingStop {
553
+ pendingStop = nil
554
+ requestId = nil
555
+ return stop
556
+ }
557
+ self.engine = engine
558
+ self.sink = sink
559
+ return nil
560
+ }
561
+
477
562
  func wait(maxMs: Int) async -> Outcome {
478
563
  let gen = currentGeneration()
479
564
  let outcome: Outcome = await withCheckedContinuation { cont in
@@ -507,16 +592,32 @@ private final class RecordingBox: @unchecked Sendable {
507
592
  lock.lock()
508
593
  let cont = continuation
509
594
  let active = requestId != nil
595
+ let outcome = Outcome(cancelled: cancelled, reason: cancelled ? "cancelled" : reason)
596
+ if active && cont == nil {
597
+ // Reserved but not yet armed: latch the stop for `begin` rather
598
+ // than dropping it on the floor. Keep the reservation so the
599
+ // in-flight setup still owns the slot.
600
+ pendingStop = outcome
601
+ lock.unlock()
602
+ return true
603
+ }
510
604
  continuation = nil
511
605
  requestId = nil
512
606
  engine = nil
513
607
  sink = nil
514
608
  lock.unlock()
515
609
  guard active, let cont else { return false }
516
- cont.resume(Outcome(cancelled: cancelled, reason: cancelled ? "cancelled" : reason))
610
+ cont.resume(outcome)
517
611
  return true
518
612
  }
519
613
 
614
+ /// Drop a reservation whose setup failed before the engine existed.
615
+ func release() {
616
+ lock.lock(); defer { lock.unlock() }
617
+ requestId = nil
618
+ pendingStop = nil
619
+ }
620
+
520
621
  func clear() {
521
622
  _ = finish(cancelled: true, reason: "cleared")
522
623
  }
package/package.json CHANGED
@@ -1,8 +1,25 @@
1
1
  {
2
2
  "name": "@agentdeck/bridge",
3
- "version": "1.0.12",
3
+ "version": "1.0.14",
4
+ "engines": {
5
+ "node": ">=22"
6
+ },
7
+ "description": "AgentDeck CLI and daemon — steer Claude Code, Codex, and OpenCode sessions from Stream Deck, tablets, e-ink, ESP32 panels, and your terminal",
4
8
  "type": "module",
5
9
  "license": "MIT",
10
+ "homepage": "https://puritysb.github.io/AgentDeck/",
11
+ "bugs": "https://github.com/puritysb/AgentDeck/issues",
12
+ "keywords": [
13
+ "agentdeck",
14
+ "claude-code",
15
+ "codex",
16
+ "opencode",
17
+ "stream-deck",
18
+ "ai-agents",
19
+ "daemon",
20
+ "cli",
21
+ "dashboard"
22
+ ],
6
23
  "repository": {
7
24
  "type": "git",
8
25
  "url": "https://github.com/puritysb/AgentDeck.git",
@@ -12,7 +29,6 @@
12
29
  "dist",
13
30
  "!dist/__tests__",
14
31
  "assets/fonts",
15
- "assets/fm-helper",
16
32
  "fm-helper",
17
33
  "scripts/build-fm-helper.mjs",
18
34
  "python",
@@ -33,8 +49,8 @@
33
49
  "sharp": "^0.34.5",
34
50
  "strip-ansi": "^7.2.0",
35
51
  "ws": "^8.20.0",
36
- "@agentdeck/shared": "1.0.12",
37
- "@agentdeck/hooks": "1.0.12"
52
+ "@agentdeck/hooks": "1.0.14",
53
+ "@agentdeck/shared": "1.0.14"
38
54
  },
39
55
  "optionalDependencies": {
40
56
  "@picovoice/porcupine-node": "^4.0.2",