@agentdeck/bridge 0.1.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 (93) hide show
  1. package/dist/adapters/claude-code.d.ts +38 -0
  2. package/dist/adapters/claude-code.d.ts.map +1 -0
  3. package/dist/adapters/claude-code.js +184 -0
  4. package/dist/adapters/claude-code.js.map +1 -0
  5. package/dist/adapters/index.d.ts +8 -0
  6. package/dist/adapters/index.d.ts.map +1 -0
  7. package/dist/adapters/index.js +18 -0
  8. package/dist/adapters/index.js.map +1 -0
  9. package/dist/adapters/openclaw.d.ts +70 -0
  10. package/dist/adapters/openclaw.d.ts.map +1 -0
  11. package/dist/adapters/openclaw.js +664 -0
  12. package/dist/adapters/openclaw.js.map +1 -0
  13. package/dist/auth.d.ts +9 -0
  14. package/dist/auth.d.ts.map +1 -0
  15. package/dist/auth.js +64 -0
  16. package/dist/auth.js.map +1 -0
  17. package/dist/check-deps.d.ts +5 -0
  18. package/dist/check-deps.d.ts.map +1 -0
  19. package/dist/check-deps.js +47 -0
  20. package/dist/check-deps.js.map +1 -0
  21. package/dist/diag-analyzer.d.ts +29 -0
  22. package/dist/diag-analyzer.d.ts.map +1 -0
  23. package/dist/diag-analyzer.js +145 -0
  24. package/dist/diag-analyzer.js.map +1 -0
  25. package/dist/event-journal.d.ts +22 -0
  26. package/dist/event-journal.d.ts.map +1 -0
  27. package/dist/event-journal.js +117 -0
  28. package/dist/event-journal.js.map +1 -0
  29. package/dist/hook-server.d.ts +31 -0
  30. package/dist/hook-server.d.ts.map +1 -0
  31. package/dist/hook-server.js +260 -0
  32. package/dist/hook-server.js.map +1 -0
  33. package/dist/index.d.ts +3 -0
  34. package/dist/index.d.ts.map +1 -0
  35. package/dist/index.js +796 -0
  36. package/dist/index.js.map +1 -0
  37. package/dist/logger.d.ts +11 -0
  38. package/dist/logger.d.ts.map +1 -0
  39. package/dist/logger.js +27 -0
  40. package/dist/logger.js.map +1 -0
  41. package/dist/mdns.d.ts +9 -0
  42. package/dist/mdns.d.ts.map +1 -0
  43. package/dist/mdns.js +44 -0
  44. package/dist/mdns.js.map +1 -0
  45. package/dist/model-catalog.d.ts +29 -0
  46. package/dist/model-catalog.d.ts.map +1 -0
  47. package/dist/model-catalog.js +91 -0
  48. package/dist/model-catalog.js.map +1 -0
  49. package/dist/output-parser.d.ts +65 -0
  50. package/dist/output-parser.d.ts.map +1 -0
  51. package/dist/output-parser.js +1089 -0
  52. package/dist/output-parser.js.map +1 -0
  53. package/dist/pty-manager.d.ts +14 -0
  54. package/dist/pty-manager.d.ts.map +1 -0
  55. package/dist/pty-manager.js +93 -0
  56. package/dist/pty-manager.js.map +1 -0
  57. package/dist/pty-ringbuffer.d.ts +23 -0
  58. package/dist/pty-ringbuffer.d.ts.map +1 -0
  59. package/dist/pty-ringbuffer.js +65 -0
  60. package/dist/pty-ringbuffer.js.map +1 -0
  61. package/dist/session-registry.d.ts +17 -0
  62. package/dist/session-registry.d.ts.map +1 -0
  63. package/dist/session-registry.js +107 -0
  64. package/dist/session-registry.js.map +1 -0
  65. package/dist/state-machine.d.ts +48 -0
  66. package/dist/state-machine.d.ts.map +1 -0
  67. package/dist/state-machine.js +470 -0
  68. package/dist/state-machine.js.map +1 -0
  69. package/dist/types.d.ts +28 -0
  70. package/dist/types.d.ts.map +1 -0
  71. package/dist/types.js +4 -0
  72. package/dist/types.js.map +1 -0
  73. package/dist/usage-api.d.ts +16 -0
  74. package/dist/usage-api.d.ts.map +1 -0
  75. package/dist/usage-api.js +84 -0
  76. package/dist/usage-api.js.map +1 -0
  77. package/dist/usage-tracker.d.ts +24 -0
  78. package/dist/usage-tracker.d.ts.map +1 -0
  79. package/dist/usage-tracker.js +89 -0
  80. package/dist/usage-tracker.js.map +1 -0
  81. package/dist/voice.d.ts +27 -0
  82. package/dist/voice.d.ts.map +1 -0
  83. package/dist/voice.js +412 -0
  84. package/dist/voice.js.map +1 -0
  85. package/dist/whisper-server-manager.d.ts +19 -0
  86. package/dist/whisper-server-manager.d.ts.map +1 -0
  87. package/dist/whisper-server-manager.js +171 -0
  88. package/dist/whisper-server-manager.js.map +1 -0
  89. package/dist/ws-server.d.ts +18 -0
  90. package/dist/ws-server.d.ts.map +1 -0
  91. package/dist/ws-server.js +86 -0
  92. package/dist/ws-server.js.map +1 -0
  93. package/package.json +43 -0
package/dist/index.js ADDED
@@ -0,0 +1,796 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from 'commander';
3
+ import { UsageTracker } from './usage-tracker.js';
4
+ import { StateMachine } from './state-machine.js';
5
+ import { WsServer } from './ws-server.js';
6
+ import { VoiceManager } from './voice.js';
7
+ import { checkDependencies } from './check-deps.js';
8
+ import { enableDebugLog, debug } from './logger.js';
9
+ import { EventJournal } from './event-journal.js';
10
+ import { PtyRingBuffer } from './pty-ringbuffer.js';
11
+ import { createDiagDump } from './diag-analyzer.js';
12
+ import { createAdapter } from './adapters/index.js';
13
+ import { ClaudeCodeAdapter } from './adapters/claude-code.js';
14
+ import { BRIDGE_WS_PORT, State, } from './types.js';
15
+ import { execSync } from 'child_process';
16
+ import { readFileSync, writeFileSync, existsSync } from 'fs';
17
+ import { resolve, dirname, join } from 'path';
18
+ import { fileURLToPath } from 'url';
19
+ import { homedir } from 'os';
20
+ import { randomUUID } from 'crypto';
21
+ import { register as registerSession, deregister as deregisterSession, listActive as listActiveSessions, findAvailablePort, detectTmuxSession, } from './session-registry.js';
22
+ import { fetchUsageFromApi } from './usage-api.js';
23
+ import { advertiseBridge } from './mdns.js';
24
+ import { getOrCreateToken, getWsUrl } from './auth.js';
25
+ function loadTemplates() {
26
+ try {
27
+ // Try multiple locations: relative to bridge, project root, etc.
28
+ const candidates = [
29
+ resolve(dirname(fileURLToPath(import.meta.url)), '../../config/prompt-templates.json'),
30
+ resolve(process.cwd(), 'config/prompt-templates.json'),
31
+ ];
32
+ for (const p of candidates) {
33
+ try {
34
+ const data = JSON.parse(readFileSync(p, 'utf-8'));
35
+ if (Array.isArray(data?.templates)) {
36
+ debug('sdc', `Loaded ${data.templates.length} templates from ${p}`);
37
+ return data.templates;
38
+ }
39
+ }
40
+ catch {
41
+ // try next
42
+ }
43
+ }
44
+ }
45
+ catch {
46
+ // ignore
47
+ }
48
+ return [];
49
+ }
50
+ const promptTemplates = loadTemplates();
51
+ // All bridge logging goes to stderr so it doesn't interfere with PTY stdout
52
+ function log(msg) {
53
+ process.stderr.write(msg + '\n');
54
+ }
55
+ const program = new Command();
56
+ program
57
+ .name('sdc')
58
+ .description('AgentDeck bridge server')
59
+ .version('0.1.0');
60
+ // Default command: start bridge + spawn claude + attach terminal
61
+ program
62
+ .command('start', { isDefault: true })
63
+ .description('Start bridge server and spawn agent CLI')
64
+ .option('-p, --port <port>', 'Bridge server port', String(BRIDGE_WS_PORT))
65
+ .option('-c, --command <cmd>', 'Command to spawn', 'claude')
66
+ .option('-a, --agent <type>', 'Agent type (claude-code|openclaw)', 'claude-code')
67
+ .option('-g, --gateway <url>', 'OpenClaw gateway WebSocket URL')
68
+ .option('-d, --debug', 'Enable debug logging to /tmp/sdc-debug.log')
69
+ .action(async (opts) => {
70
+ if (opts.debug) {
71
+ enableDebugLog();
72
+ log('[sdc] Debug logging enabled → /tmp/sdc-debug.log');
73
+ }
74
+ const port = parseInt(opts.port, 10);
75
+ const agentType = opts.agent;
76
+ await startBridge(port, opts.command, agentType, opts.gateway);
77
+ });
78
+ program
79
+ .command('attach')
80
+ .description('Attach to an existing bridge session')
81
+ .option('-p, --port <port>', 'Bridge server port', String(BRIDGE_WS_PORT))
82
+ .action(async (opts) => {
83
+ const port = parseInt(opts.port, 10);
84
+ log(`Attaching to bridge on port ${port}...`);
85
+ log('Attach mode not yet implemented');
86
+ process.exit(1);
87
+ });
88
+ program
89
+ .command('status')
90
+ .description('Show bridge and session status')
91
+ .option('-p, --port <port>', 'Bridge server port', String(BRIDGE_WS_PORT))
92
+ .action(async (opts) => {
93
+ const port = parseInt(opts.port, 10);
94
+ try {
95
+ const res = await fetch(`http://127.0.0.1:${port}/health`);
96
+ const data = await res.json();
97
+ log(`Bridge status: ${JSON.stringify(data, null, 2)}`);
98
+ }
99
+ catch {
100
+ log('Bridge is not running');
101
+ process.exit(1);
102
+ }
103
+ });
104
+ program
105
+ .command('stop')
106
+ .description('Stop the bridge and session')
107
+ .option('-p, --port <port>', 'Bridge server port', String(BRIDGE_WS_PORT))
108
+ .action(async (opts) => {
109
+ const port = parseInt(opts.port, 10);
110
+ try {
111
+ await fetch(`http://127.0.0.1:${port}/hooks/shutdown`, { method: 'POST' });
112
+ log('Shutdown signal sent');
113
+ }
114
+ catch {
115
+ log('Bridge is not running');
116
+ }
117
+ });
118
+ program
119
+ .command('qr')
120
+ .description('Show pairing URL and QR code for remote clients')
121
+ .option('-p, --port <port>', 'Bridge server port (auto-detects from running sessions)')
122
+ .action(async (opts) => {
123
+ const { getOrCreateToken, getWsUrl } = await import('./auth.js');
124
+ const { listActive } = await import('./session-registry.js');
125
+ // Determine port: explicit flag > running session > default
126
+ let port;
127
+ if (opts.port) {
128
+ port = parseInt(opts.port, 10);
129
+ }
130
+ else {
131
+ const sessions = listActive();
132
+ if (sessions.length > 0) {
133
+ port = sessions[0].port;
134
+ if (sessions.length > 1) {
135
+ log(`Multiple sessions running. Using port ${port} (${sessions[0].projectName}).`);
136
+ log(`Specify --port to target a different session.`);
137
+ }
138
+ }
139
+ else {
140
+ port = BRIDGE_WS_PORT;
141
+ }
142
+ }
143
+ getOrCreateToken();
144
+ const url = getWsUrl(port);
145
+ log(`\nPairing URL:\n ${url}\n`);
146
+ // Generate text QR in terminal using qrcode lib (if available)
147
+ try {
148
+ const { default: QRCode } = await import('qrcode');
149
+ const text = await QRCode.toString(url, { type: 'terminal', small: true });
150
+ log(text);
151
+ }
152
+ catch {
153
+ // qrcode not available in bridge — URL is sufficient
154
+ }
155
+ });
156
+ program
157
+ .command('diag')
158
+ .description('Generate diagnostic dump and optionally run AI analysis')
159
+ .option('-p, --port <port>', 'Bridge server port', String(BRIDGE_WS_PORT))
160
+ .option('-a, --analyze', 'Run AI analysis on the dump')
161
+ .option('-t, --tail <lines>', 'Number of journal entries to include', '200')
162
+ .action(async (opts) => {
163
+ const port = parseInt(opts.port, 10);
164
+ const tail = parseInt(opts.tail, 10);
165
+ try {
166
+ const res = await fetch(`http://127.0.0.1:${port}/diag?tail=${tail}`);
167
+ if (!res.ok) {
168
+ log(`Diag endpoint error: ${res.status} ${res.statusText}`);
169
+ process.exit(1);
170
+ }
171
+ const dump = await res.json();
172
+ // Save dump to disk
173
+ const { saveDiagDump, analyzeDump } = await import('./diag-analyzer.js');
174
+ const dumpPath = saveDiagDump(dump);
175
+ log(`Diagnostic dump saved: ${dumpPath}`);
176
+ if (opts.analyze) {
177
+ log('Running AI analysis...');
178
+ const analysis = await analyzeDump(dumpPath);
179
+ if (analysis) {
180
+ log('\n--- AI Analysis ---\n');
181
+ log(analysis);
182
+ }
183
+ else {
184
+ log('AI analysis failed (is `claude` CLI available?)');
185
+ }
186
+ }
187
+ }
188
+ catch {
189
+ log('Bridge is not running. Cannot generate live diagnostic dump.');
190
+ process.exit(1);
191
+ }
192
+ });
193
+ program.parse();
194
+ async function startBridge(port, command, agentType, gatewayUrl) {
195
+ const deps = checkDependencies();
196
+ if (!deps.ok) {
197
+ process.exit(1);
198
+ }
199
+ for (const w of deps.warnings) {
200
+ log(`[sdc] WARNING: ${w}`);
201
+ }
202
+ // Multi-session: find available port if default is taken
203
+ const actualPort = port === BRIDGE_WS_PORT ? await findAvailablePort() : port;
204
+ if (actualPort !== port) {
205
+ log(`[sdc] Port ${port} in use, using ${actualPort}`);
206
+ }
207
+ port = actualPort;
208
+ // Auto-migrate old-format hooks (hardcoded port → env var)
209
+ migrateHooksIfNeeded();
210
+ const sessionId = randomUUID();
211
+ const tmuxSession = detectTmuxSession();
212
+ const parentTty = (() => {
213
+ try {
214
+ return execSync('tty', { stdio: ['inherit', 'pipe', 'pipe'] }).toString().trim();
215
+ }
216
+ catch {
217
+ return undefined;
218
+ }
219
+ })();
220
+ const projectName = process.cwd().split('/').pop() || 'unknown';
221
+ // Warn if same project is already running in another session
222
+ const existingSessions = listActiveSessions();
223
+ const sameProject = existingSessions.filter((s) => s.projectName === projectName);
224
+ if (sameProject.length > 0) {
225
+ const ports = sameProject.map((s) => s.port).join(', ');
226
+ log(`[sdc] ⚠ Session "${projectName}" already running on port ${ports}. Starting new session on port ${port}.`);
227
+ }
228
+ log(`[sdc] Starting AgentDeck bridge on port ${port} (agent: ${agentType})...`);
229
+ // API usage data (fetched from Anthropic, not from PTY)
230
+ let cachedApiUsage = null;
231
+ let lastApiFetchTime = 0;
232
+ // Model catalog (OpenClaw: from CLI)
233
+ let cachedModelCatalog = null;
234
+ // 1. Initialize components
235
+ const adapter = createAdapter(agentType, gatewayUrl);
236
+ const usageTracker = new UsageTracker();
237
+ const stateMachine = new StateMachine(usageTracker);
238
+ const voiceManager = new VoiceManager();
239
+ const journal = new EventJournal();
240
+ const ptyRingBuffer = new PtyRingBuffer();
241
+ // 1b. Connect to singleton whisper-server (non-blocking — don't delay bridge startup)
242
+ voiceManager.connectToServer().catch((err) => {
243
+ debug('sdc', `whisper-server connection failed (will use whisper-cli): ${err}`);
244
+ });
245
+ // 2. Start adapter (creates HTTP server, spawns agent process)
246
+ try {
247
+ await adapter.start({ port, command, gatewayUrl });
248
+ log(`[sdc] Adapter started: ${adapter.capabilities.displayName}`);
249
+ }
250
+ catch (err) {
251
+ log(`[sdc] Failed to start adapter: ${err}`);
252
+ process.exit(1);
253
+ }
254
+ // 2b. mDNS service advertising (LAN discovery for Android/browser clients)
255
+ const mdnsCleanup = advertiseBridge(port, projectName, agentType);
256
+ // 2c. Auth token initialization + QR URL for pairing
257
+ getOrCreateToken(); // Ensure token exists on disk
258
+ const wsUrl = getWsUrl(port);
259
+ log(`[sdc] Auth token ready. Pairing URL: ${wsUrl}`);
260
+ // 2d. SSE broadcasting helper (only for ClaudeCode adapter which has HookServer)
261
+ let hookServer = null;
262
+ if (adapter instanceof ClaudeCodeAdapter) {
263
+ hookServer = adapter.hookServer;
264
+ hookServer.setMeta({ agentType, projectName });
265
+ }
266
+ const broadcastSse = (event) => hookServer?.broadcastSse(event);
267
+ // 3. Attach WebSocket server to adapter's HTTP server
268
+ const wsServer = new WsServer(adapter.getHttpServer());
269
+ log(`[sdc] WebSocket server ready on port ${port}`);
270
+ // 3b. Register diag handler
271
+ adapter.onDiag((tail) => createDiagDump(stateMachine, wsServer, journal, ptyRingBuffer, tail));
272
+ // 3c. Register raw agent data handler for diagnostics
273
+ adapter.onRawData((data) => {
274
+ ptyRingBuffer.push(data);
275
+ const preview = data.replace(/[\x00-\x1f\x1b]/g, '').slice(0, 200);
276
+ journal.write('pty_chunk', 'pty', { size: data.length, preview });
277
+ });
278
+ // 3d. Handle VoiceManager errors (prevent uncaught exception crash)
279
+ voiceManager.on('error', (err) => {
280
+ debug('sdc', `Voice error: ${err.message}`);
281
+ wsServer.broadcast({ type: 'voice_state', state: 'error', error: err.message });
282
+ });
283
+ // 4. Wire adapter events → StateMachine + journal
284
+ adapter.on('event', (evt) => {
285
+ switch (evt.source) {
286
+ case 'hook':
287
+ journal.write('hook', 'hook', { event: evt.event, data: evt.data });
288
+ if (evt.event === 'shutdown') {
289
+ shutdown();
290
+ return;
291
+ }
292
+ stateMachine.handleHookEvent(evt.event, evt.data);
293
+ break;
294
+ case 'parser':
295
+ journal.write('parser_emit', 'pty', { event: evt.event, ...evt.data });
296
+ stateMachine.handleParserEvent(evt.event, evt.data);
297
+ break;
298
+ case 'metadata':
299
+ switch (evt.event) {
300
+ case 'cursor_update': {
301
+ const idx = evt.data?.cursorIndex ?? 0;
302
+ stateMachine.updateCursorIndex(idx, 'pty');
303
+ break;
304
+ }
305
+ case 'usage_info':
306
+ usageTracker.setUsageInfo(evt.data);
307
+ // Immediately broadcast updated usage
308
+ wsServer.broadcast(buildUsageEvent(stateMachine.getSnapshot(), cachedApiUsage));
309
+ break;
310
+ case 'user_prompt': {
311
+ const text = evt.data?.text;
312
+ if (text) {
313
+ wsServer.broadcast({ type: 'user_prompt', text });
314
+ }
315
+ break;
316
+ }
317
+ case 'model_catalog': {
318
+ const models = evt.data?.models;
319
+ if (models) {
320
+ cachedModelCatalog = models;
321
+ debug('sdc', `Model catalog updated: ${models.length} models`);
322
+ // Broadcast updated state with model catalog
323
+ const snap = stateMachine.getSnapshot();
324
+ const stateEvt = {
325
+ type: 'state_update',
326
+ state: snap.state,
327
+ permissionMode: snap.permissionMode,
328
+ agentType: adapter.capabilities.type,
329
+ modelCatalog: cachedModelCatalog ?? undefined,
330
+ };
331
+ wsServer.broadcast(stateEvt);
332
+ }
333
+ break;
334
+ }
335
+ }
336
+ break;
337
+ case 'activity':
338
+ stateMachine.onPtyActivity();
339
+ break;
340
+ case 'connection': {
341
+ const connEvt = { type: 'connection', status: evt.status };
342
+ wsServer.broadcast(connEvt);
343
+ broadcastSse(connEvt);
344
+ break;
345
+ }
346
+ }
347
+ });
348
+ // 4b. Handle adapter exit (agent process died)
349
+ adapter.on('exit', (_code, _signal) => {
350
+ shutdown();
351
+ });
352
+ // 5. Wire StateMachine state changes → WsServer broadcast
353
+ stateMachine.on('state_changed', (snapshot) => {
354
+ journal.write('state_change', 'internal', { state: snapshot.state, permissionMode: snapshot.permissionMode, suggestedPrompt: snapshot.suggestedPrompt });
355
+ // Compute promptType if options are present
356
+ let promptType;
357
+ if (snapshot.options.length > 0) {
358
+ promptType = 'multi_select';
359
+ if (snapshot.state === State.AWAITING_PERMISSION) {
360
+ promptType = snapshot.options.length > 2 ? 'yes_no_always' : 'yes_no';
361
+ }
362
+ else if (snapshot.state === State.AWAITING_DIFF) {
363
+ promptType = 'diff_review';
364
+ }
365
+ }
366
+ // Include options atomically in state_update to avoid race conditions
367
+ // Note: agentCapabilities sent only on client connect (static), not on every broadcast
368
+ const stateEvent = {
369
+ type: 'state_update',
370
+ state: snapshot.state,
371
+ permissionMode: snapshot.permissionMode,
372
+ agentType: adapter.capabilities.type,
373
+ currentTool: snapshot.currentTool ?? undefined,
374
+ toolInput: snapshot.toolInput ?? undefined,
375
+ toolProgress: snapshot.toolProgress ?? undefined,
376
+ projectName: snapshot.projectName ?? undefined,
377
+ modelName: snapshot.modelName ?? undefined,
378
+ billingType: snapshot.billingType,
379
+ options: snapshot.options.length > 0 ? snapshot.options : undefined,
380
+ promptType,
381
+ question: snapshot.question ?? undefined,
382
+ navigable: snapshot.navigable || undefined,
383
+ cursorIndex: (snapshot.state === State.AWAITING_OPTION ||
384
+ snapshot.state === State.AWAITING_PERMISSION ||
385
+ snapshot.state === State.AWAITING_DIFF)
386
+ ? snapshot.cursorIndex : undefined,
387
+ suggestedPrompt: snapshot.suggestedPrompt ?? undefined,
388
+ modelCatalog: cachedModelCatalog ?? undefined,
389
+ remoteUrl: snapshot.remoteUrl ?? undefined,
390
+ pairingUrl: wsUrl,
391
+ };
392
+ wsServer.broadcast(stateEvent);
393
+ broadcastSse(stateEvent);
394
+ // Also send separate prompt_options for backward compatibility
395
+ if (snapshot.options.length > 0) {
396
+ const promptEvent = {
397
+ type: 'prompt_options',
398
+ promptType: promptType,
399
+ question: snapshot.question ?? undefined,
400
+ options: snapshot.options,
401
+ };
402
+ wsServer.broadcast(promptEvent);
403
+ }
404
+ const usageEvt = buildUsageEvent(snapshot, cachedApiUsage);
405
+ wsServer.broadcast(usageEvt);
406
+ broadcastSse(usageEvt);
407
+ });
408
+ // 6. Handle PluginCommands from WsServer
409
+ wsServer.onCommand((cmd) => {
410
+ debug('sdc', `pluginCmd: ${cmd.type}`);
411
+ // Let adapter handle commands it owns.
412
+ // ClaudeCode: switch_mode, interrupt, escape, respond
413
+ // OpenClaw: also select_option, navigate_option, send_prompt (via RPC)
414
+ if (adapter.handleCommand(cmd)) {
415
+ // Adapter handled the transport side; update StateMachine as needed
416
+ switch (cmd.type) {
417
+ case 'respond':
418
+ stateMachine.handleUserAction('respond');
419
+ break;
420
+ case 'interrupt':
421
+ stateMachine.handleUserAction('interrupt');
422
+ break;
423
+ case 'escape':
424
+ stateMachine.handleUserAction('interrupt');
425
+ break;
426
+ case 'select_option':
427
+ stateMachine.handleUserAction('select_option');
428
+ break;
429
+ case 'send_prompt':
430
+ stateMachine.handleUserAction('send_prompt');
431
+ break;
432
+ }
433
+ return;
434
+ }
435
+ // Commands that need bridge coordination
436
+ switch (cmd.type) {
437
+ case 'select_option': {
438
+ const snapshot = stateMachine.getSnapshot();
439
+ debug('sdc', `select_option: idx=${cmd.index} navigable=${snapshot.navigable} cursor=${snapshot.cursorIndex}`);
440
+ if (snapshot.navigable) {
441
+ // Arrow-key mode: navigate to desired option then press Enter
442
+ const delta = cmd.index - snapshot.cursorIndex;
443
+ if (delta !== 0) {
444
+ const arrow = delta > 0 ? '\x1b[B' : '\x1b[A';
445
+ const steps = Math.abs(delta);
446
+ debug('sdc', `select_option: navigating ${steps} steps ${delta > 0 ? 'down' : 'up'}`);
447
+ adapter.writeInput(arrow.repeat(steps));
448
+ }
449
+ // Proportional delay for PTY to process arrow keys, then confirm with Enter
450
+ const delay = 50 + Math.abs(delta) * 20;
451
+ setTimeout(() => {
452
+ adapter.writeInput('\r');
453
+ }, delay);
454
+ }
455
+ else {
456
+ // Number input mode: type the 1-based index
457
+ adapter.writeInput(String(cmd.index + 1) + '\r');
458
+ }
459
+ stateMachine.handleUserAction('select_option');
460
+ break;
461
+ }
462
+ case 'navigate_option': {
463
+ const total = stateMachine.getOptionsCount();
464
+ const cur = stateMachine.getCursorIndex();
465
+ const newIdx = total > 0
466
+ ? (cmd.direction === 'up'
467
+ ? Math.max(cur - 1, 0)
468
+ : Math.min(cur + 1, total - 1))
469
+ : cur;
470
+ stateMachine.updateCursorIndex(newIdx, 'optimistic');
471
+ debug('sdc', `navigate_option: ${cmd.direction} cursor=${cur}->${newIdx}`);
472
+ adapter.prepareForNavigation?.();
473
+ adapter.writeInput(cmd.direction === 'up' ? '\x1b[A' : '\x1b[B');
474
+ // Don't call handleUserAction — cursor movement is not a selection
475
+ break;
476
+ }
477
+ case 'send_prompt': {
478
+ let text = cmd.text;
479
+ // Expand template references
480
+ const templateMatch = text.match(/^__template:(\d+)$/);
481
+ if (templateMatch) {
482
+ const idx = parseInt(templateMatch[1], 10);
483
+ if (idx >= 0 && idx < promptTemplates.length) {
484
+ text = promptTemplates[idx].prompt;
485
+ debug('sdc', `Template ${idx} → "${text.slice(0, 50)}"`);
486
+ }
487
+ else {
488
+ debug('sdc', `Template ${idx} out of range (${promptTemplates.length} available)`);
489
+ break;
490
+ }
491
+ }
492
+ if (text) {
493
+ adapter.writeInput(text);
494
+ setTimeout(() => adapter.writeInput('\r'), 50);
495
+ stateMachine.handleUserAction('send_prompt');
496
+ }
497
+ break;
498
+ }
499
+ case 'voice':
500
+ handleVoiceCommand(cmd.action, voiceManager, wsServer);
501
+ break;
502
+ case 'query_usage': {
503
+ // Fetch fresh usage from Anthropic API (no PTY echo)
504
+ debug('sdc', 'Fetching usage from API (on demand)');
505
+ fetchUsageFromApi().then((apiUsage) => {
506
+ if (apiUsage) {
507
+ cachedApiUsage = apiUsage;
508
+ lastApiFetchTime = Date.now();
509
+ if (apiUsage.inferredBillingType) {
510
+ stateMachine.inferBillingType(apiUsage.inferredBillingType);
511
+ }
512
+ }
513
+ const snapshot = stateMachine.getSnapshot();
514
+ wsServer.broadcast(buildUsageEvent(snapshot, cachedApiUsage));
515
+ });
516
+ break;
517
+ }
518
+ }
519
+ });
520
+ // 6b. Wire WS connect/disconnect to journal + update SSE metadata
521
+ wsServer.onClientDisconnect(() => {
522
+ journal.write('ws_event', 'ws', { action: 'disconnect', clients: wsServer.getClientCount() });
523
+ hookServer?.setMeta({ clientCount: wsServer.getClientCount() });
524
+ });
525
+ // Kick initial state: synthetic SessionStart in adapter.start() was emitted before
526
+ // the event listener was wired, so fire it explicitly now.
527
+ if (adapter.isAlive()) {
528
+ stateMachine.handleHookEvent('SessionStart', {});
529
+ }
530
+ // Register with session registry for multi-session support
531
+ registerSession({
532
+ id: sessionId,
533
+ port,
534
+ pid: process.pid,
535
+ projectName: adapter.getProjectName() || projectName,
536
+ tmuxSession,
537
+ parentTty,
538
+ tty: adapter.getTtyPath(),
539
+ startedAt: new Date().toISOString(),
540
+ });
541
+ // 7. Send current state to newly connected WebSocket clients
542
+ wsServer.onClientConnect((ws) => {
543
+ journal.write('ws_event', 'ws', { action: 'connect', clients: wsServer.getClientCount() });
544
+ hookServer?.setMeta({ clientCount: wsServer.getClientCount() });
545
+ const snapshot = stateMachine.getSnapshot();
546
+ // Compute promptType for initial state
547
+ let initPromptType;
548
+ if (snapshot.options.length > 0) {
549
+ initPromptType = 'multi_select';
550
+ if (snapshot.state === State.AWAITING_PERMISSION) {
551
+ initPromptType = snapshot.options.length > 2 ? 'yes_no_always' : 'yes_no';
552
+ }
553
+ else if (snapshot.state === State.AWAITING_DIFF) {
554
+ initPromptType = 'diff_review';
555
+ }
556
+ }
557
+ // Restore last valid suggestion on reconnect when IDLE (current suggestedPrompt may already be null)
558
+ let reconnectSuggestion = snapshot.suggestedPrompt;
559
+ if (!reconnectSuggestion && snapshot.state === State.IDLE) {
560
+ reconnectSuggestion = stateMachine.getLastValidSuggestedPrompt();
561
+ if (reconnectSuggestion) {
562
+ debug('sdc', `Restoring lastValidSuggestedPrompt on reconnect: "${reconnectSuggestion.slice(0, 40)}"`);
563
+ }
564
+ }
565
+ const stateEvent = {
566
+ type: 'state_update',
567
+ state: snapshot.state,
568
+ permissionMode: snapshot.permissionMode,
569
+ agentType: adapter.capabilities.type,
570
+ agentCapabilities: adapter.capabilities,
571
+ currentTool: snapshot.currentTool ?? undefined,
572
+ toolInput: snapshot.toolInput ?? undefined,
573
+ toolProgress: snapshot.toolProgress ?? undefined,
574
+ projectName: snapshot.projectName ?? undefined,
575
+ modelName: snapshot.modelName ?? undefined,
576
+ billingType: snapshot.billingType,
577
+ options: snapshot.options.length > 0 ? snapshot.options : undefined,
578
+ promptType: initPromptType,
579
+ question: snapshot.question ?? undefined,
580
+ navigable: snapshot.navigable || undefined,
581
+ cursorIndex: (snapshot.state === State.AWAITING_OPTION ||
582
+ snapshot.state === State.AWAITING_PERMISSION ||
583
+ snapshot.state === State.AWAITING_DIFF)
584
+ ? snapshot.cursorIndex : undefined,
585
+ suggestedPrompt: reconnectSuggestion ?? undefined,
586
+ modelCatalog: cachedModelCatalog ?? undefined,
587
+ pairingUrl: wsUrl,
588
+ };
589
+ wsServer.sendTo(ws, stateEvent);
590
+ // Also send separate prompt_options for backward compatibility
591
+ if (snapshot.options.length > 0) {
592
+ wsServer.sendTo(ws, {
593
+ type: 'prompt_options',
594
+ promptType: initPromptType,
595
+ question: snapshot.question ?? undefined,
596
+ options: snapshot.options,
597
+ });
598
+ }
599
+ wsServer.sendTo(ws, buildUsageEvent(snapshot, cachedApiUsage));
600
+ const connectEvt = {
601
+ type: 'connection',
602
+ status: adapter.isAlive() ? 'connected' : 'disconnected',
603
+ };
604
+ wsServer.sendTo(ws, connectEvt);
605
+ // Fetch API usage on client connect:
606
+ // - Always fetch if no cache yet
607
+ // - Re-fetch if cache is stale (>5 min, e.g. after sleep/wake)
608
+ const cacheAge = Date.now() - lastApiFetchTime;
609
+ const cacheStale = lastApiFetchTime > 0 && cacheAge > 5 * 60 * 1000;
610
+ if (!cachedApiUsage || cacheStale) {
611
+ fetchUsageFromApi().then((apiUsage) => {
612
+ if (apiUsage) {
613
+ cachedApiUsage = apiUsage;
614
+ lastApiFetchTime = Date.now();
615
+ if (apiUsage.inferredBillingType) {
616
+ stateMachine.inferBillingType(apiUsage.inferredBillingType);
617
+ }
618
+ const snap2 = stateMachine.getSnapshot();
619
+ wsServer.broadcast(buildUsageEvent(snap2, cachedApiUsage));
620
+ }
621
+ });
622
+ }
623
+ });
624
+ // 8. Attach user's terminal to adapter (PTY agents proxy stdin/stdout)
625
+ if (adapter.capabilities.hasTerminal) {
626
+ if (process.stdin.isTTY) {
627
+ process.stdin.setRawMode(true);
628
+ }
629
+ process.stdin.resume();
630
+ adapter.attachTerminal(process.stdin, process.stdout);
631
+ }
632
+ // 9. Periodic usage update (so session timer ticks on Stream Deck)
633
+ const usageInterval = setInterval(() => {
634
+ if (wsServer.getClientCount() > 0) {
635
+ const snapshot = stateMachine.getSnapshot();
636
+ wsServer.broadcast(buildUsageEvent(snapshot, cachedApiUsage));
637
+ }
638
+ }, 5000);
639
+ // 9b. Periodic API usage refresh (silent — no PTY echo)
640
+ const apiUsageInterval = setInterval(() => {
641
+ if (wsServer.getClientCount() > 0) {
642
+ fetchUsageFromApi().then((apiUsage) => {
643
+ if (apiUsage) {
644
+ cachedApiUsage = apiUsage;
645
+ lastApiFetchTime = Date.now();
646
+ if (apiUsage.inferredBillingType) {
647
+ stateMachine.inferBillingType(apiUsage.inferredBillingType);
648
+ }
649
+ // Broadcast updated usage so clients see fresh rate-limit data
650
+ const snapshot = stateMachine.getSnapshot();
651
+ wsServer.broadcast(buildUsageEvent(snapshot, cachedApiUsage));
652
+ }
653
+ });
654
+ }
655
+ }, 60_000);
656
+ // 10. Graceful shutdown
657
+ let shutdownInProgress = false;
658
+ function shutdown() {
659
+ if (shutdownInProgress)
660
+ return;
661
+ shutdownInProgress = true;
662
+ log('[sdc] Shutting down...');
663
+ clearInterval(usageInterval);
664
+ clearInterval(apiUsageInterval);
665
+ deregisterSession(sessionId);
666
+ if (process.stdin.isTTY) {
667
+ process.stdin.setRawMode(false);
668
+ }
669
+ mdnsCleanup();
670
+ voiceManager.disconnectFromServer();
671
+ journal.close();
672
+ wsServer.close();
673
+ // Adapter handles killing the agent process and closing its HTTP server
674
+ adapter.shutdown().then(() => {
675
+ process.exit(0);
676
+ });
677
+ // Force exit if adapter shutdown hangs
678
+ setTimeout(() => {
679
+ process.exit(1);
680
+ }, 3000);
681
+ }
682
+ process.on('SIGINT', shutdown);
683
+ process.on('SIGTERM', shutdown);
684
+ process.on('uncaughtException', (err) => {
685
+ log(`[sdc] Uncaught exception: ${err}`);
686
+ shutdown();
687
+ });
688
+ process.on('unhandledRejection', (reason) => {
689
+ log(`[sdc] Unhandled rejection: ${reason}`);
690
+ shutdown();
691
+ });
692
+ }
693
+ function buildUsageEvent(snapshot, apiUsage) {
694
+ return {
695
+ type: 'usage_update',
696
+ sessionDurationSec: snapshot.sessionDurationSec,
697
+ inputTokens: snapshot.inputTokens,
698
+ outputTokens: snapshot.outputTokens,
699
+ toolCalls: snapshot.toolCalls,
700
+ estimatedCostUsd: snapshot.estimatedCostUsd ?? undefined,
701
+ sessionPercent: snapshot.sessionPercent ?? undefined,
702
+ costSpent: snapshot.costSpent ?? undefined,
703
+ costLimit: snapshot.costLimit ?? undefined,
704
+ resetTime: snapshot.resetTime ?? undefined,
705
+ resetDate: snapshot.resetDate ?? undefined,
706
+ fiveHourPercent: apiUsage?.fiveHourPercent ?? undefined,
707
+ fiveHourResetsAt: apiUsage?.fiveHourResetsAt ?? undefined,
708
+ sevenDayPercent: apiUsage?.sevenDayPercent ?? undefined,
709
+ sevenDayResetsAt: apiUsage?.sevenDayResetsAt ?? undefined,
710
+ extraUsageEnabled: apiUsage?.extraUsageEnabled ?? undefined,
711
+ extraUsageMonthlyLimit: apiUsage?.extraUsageMonthlyLimit ?? undefined,
712
+ extraUsageUsedCredits: apiUsage?.extraUsageUsedCredits ?? undefined,
713
+ extraUsageUtilization: apiUsage?.extraUsageUtilization ?? undefined,
714
+ };
715
+ }
716
+ function handleVoiceCommand(action, voiceManager, wsServer) {
717
+ switch (action) {
718
+ case 'start':
719
+ voiceManager.startRecording();
720
+ wsServer.broadcast({ type: 'voice_state', state: 'recording' });
721
+ break;
722
+ case 'stop':
723
+ wsServer.broadcast({ type: 'voice_state', state: 'transcribing' });
724
+ voiceManager.stopRecording().then((text) => {
725
+ debug('sdc', `Voice result: "${text?.slice(0, 60) || '(empty)'}"`);
726
+ // Don't auto-send — plugin shows review UI; user confirms via send_prompt
727
+ wsServer.broadcast({ type: 'voice_state', state: 'idle', text: text || '' });
728
+ }).catch((err) => {
729
+ debug('sdc', `Voice transcription error: ${err}`);
730
+ wsServer.broadcast({ type: 'voice_state', state: 'error', error: String(err) });
731
+ });
732
+ break;
733
+ case 'cancel':
734
+ voiceManager.cancel();
735
+ wsServer.broadcast({ type: 'voice_state', state: 'idle' });
736
+ break;
737
+ }
738
+ }
739
+ /**
740
+ * Auto-migrate hooks:
741
+ * 1. Hardcoded localhost:9120 → $AGENTDECK_PORT env var
742
+ * 2. Old flat format → new matcher-group format (Claude Code v2.1+)
743
+ * Old: { type: "command", command: "curl ..." }
744
+ * New: { matcher: "", hooks: [{ type: "command", command: "curl ..." }] }
745
+ */
746
+ function migrateHooksIfNeeded() {
747
+ const settingsPath = join(homedir(), '.claude', 'settings.local.json');
748
+ try {
749
+ if (!existsSync(settingsPath))
750
+ return;
751
+ const raw = readFileSync(settingsPath, 'utf-8');
752
+ if (!raw.includes('AGENTDECK_PORT') && !raw.includes('localhost:9120'))
753
+ return;
754
+ const settings = JSON.parse(raw);
755
+ if (!settings.hooks)
756
+ return;
757
+ let migrated = false;
758
+ for (const event of Object.keys(settings.hooks)) {
759
+ const hooks = settings.hooks[event];
760
+ if (!Array.isArray(hooks))
761
+ continue;
762
+ for (let i = 0; i < hooks.length; i++) {
763
+ const hook = hooks[i];
764
+ // Migration 1: hardcoded port → env var
765
+ if (hook.command?.includes('localhost:9120') && !hook.command?.includes('AGENTDECK_PORT')) {
766
+ hook.command = hook.command.replace(/localhost:9120/g, 'localhost:${AGENTDECK_PORT:-9120}');
767
+ migrated = true;
768
+ }
769
+ // Migration 2: flat format → matcher-group format
770
+ // Detect flat format: has "type" + "command" at top level, no "hooks" array
771
+ if (hook.type === 'command' && hook.command?.includes('AGENTDECK_PORT') && !hook.hooks) {
772
+ const handler = { type: hook.type, command: hook.command };
773
+ hooks[i] = { matcher: '', hooks: [handler] };
774
+ migrated = true;
775
+ }
776
+ // Also migrate matcher-group entries with hardcoded port inside
777
+ if (Array.isArray(hook.hooks)) {
778
+ for (const inner of hook.hooks) {
779
+ if (inner.command?.includes('localhost:9120') && !inner.command?.includes('AGENTDECK_PORT')) {
780
+ inner.command = inner.command.replace(/localhost:9120/g, 'localhost:${AGENTDECK_PORT:-9120}');
781
+ migrated = true;
782
+ }
783
+ }
784
+ }
785
+ }
786
+ }
787
+ if (migrated) {
788
+ writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
789
+ log('[sdc] Auto-migrated hooks to v2.1 matcher-group format');
790
+ }
791
+ }
792
+ catch (err) {
793
+ debug('sdc', `Hook migration check failed: ${err}`);
794
+ }
795
+ }
796
+ //# sourceMappingURL=index.js.map