@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.
- package/dist/adapters/claude-code.d.ts +38 -0
- package/dist/adapters/claude-code.d.ts.map +1 -0
- package/dist/adapters/claude-code.js +184 -0
- package/dist/adapters/claude-code.js.map +1 -0
- package/dist/adapters/index.d.ts +8 -0
- package/dist/adapters/index.d.ts.map +1 -0
- package/dist/adapters/index.js +18 -0
- package/dist/adapters/index.js.map +1 -0
- package/dist/adapters/openclaw.d.ts +70 -0
- package/dist/adapters/openclaw.d.ts.map +1 -0
- package/dist/adapters/openclaw.js +664 -0
- package/dist/adapters/openclaw.js.map +1 -0
- package/dist/auth.d.ts +9 -0
- package/dist/auth.d.ts.map +1 -0
- package/dist/auth.js +64 -0
- package/dist/auth.js.map +1 -0
- package/dist/check-deps.d.ts +5 -0
- package/dist/check-deps.d.ts.map +1 -0
- package/dist/check-deps.js +47 -0
- package/dist/check-deps.js.map +1 -0
- package/dist/diag-analyzer.d.ts +29 -0
- package/dist/diag-analyzer.d.ts.map +1 -0
- package/dist/diag-analyzer.js +145 -0
- package/dist/diag-analyzer.js.map +1 -0
- package/dist/event-journal.d.ts +22 -0
- package/dist/event-journal.d.ts.map +1 -0
- package/dist/event-journal.js +117 -0
- package/dist/event-journal.js.map +1 -0
- package/dist/hook-server.d.ts +31 -0
- package/dist/hook-server.d.ts.map +1 -0
- package/dist/hook-server.js +260 -0
- package/dist/hook-server.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +796 -0
- package/dist/index.js.map +1 -0
- package/dist/logger.d.ts +11 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/logger.js +27 -0
- package/dist/logger.js.map +1 -0
- package/dist/mdns.d.ts +9 -0
- package/dist/mdns.d.ts.map +1 -0
- package/dist/mdns.js +44 -0
- package/dist/mdns.js.map +1 -0
- package/dist/model-catalog.d.ts +29 -0
- package/dist/model-catalog.d.ts.map +1 -0
- package/dist/model-catalog.js +91 -0
- package/dist/model-catalog.js.map +1 -0
- package/dist/output-parser.d.ts +65 -0
- package/dist/output-parser.d.ts.map +1 -0
- package/dist/output-parser.js +1089 -0
- package/dist/output-parser.js.map +1 -0
- package/dist/pty-manager.d.ts +14 -0
- package/dist/pty-manager.d.ts.map +1 -0
- package/dist/pty-manager.js +93 -0
- package/dist/pty-manager.js.map +1 -0
- package/dist/pty-ringbuffer.d.ts +23 -0
- package/dist/pty-ringbuffer.d.ts.map +1 -0
- package/dist/pty-ringbuffer.js +65 -0
- package/dist/pty-ringbuffer.js.map +1 -0
- package/dist/session-registry.d.ts +17 -0
- package/dist/session-registry.d.ts.map +1 -0
- package/dist/session-registry.js +107 -0
- package/dist/session-registry.js.map +1 -0
- package/dist/state-machine.d.ts +48 -0
- package/dist/state-machine.d.ts.map +1 -0
- package/dist/state-machine.js +470 -0
- package/dist/state-machine.js.map +1 -0
- package/dist/types.d.ts +28 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +4 -0
- package/dist/types.js.map +1 -0
- package/dist/usage-api.d.ts +16 -0
- package/dist/usage-api.d.ts.map +1 -0
- package/dist/usage-api.js +84 -0
- package/dist/usage-api.js.map +1 -0
- package/dist/usage-tracker.d.ts +24 -0
- package/dist/usage-tracker.d.ts.map +1 -0
- package/dist/usage-tracker.js +89 -0
- package/dist/usage-tracker.js.map +1 -0
- package/dist/voice.d.ts +27 -0
- package/dist/voice.d.ts.map +1 -0
- package/dist/voice.js +412 -0
- package/dist/voice.js.map +1 -0
- package/dist/whisper-server-manager.d.ts +19 -0
- package/dist/whisper-server-manager.d.ts.map +1 -0
- package/dist/whisper-server-manager.js +171 -0
- package/dist/whisper-server-manager.js.map +1 -0
- package/dist/ws-server.d.ts +18 -0
- package/dist/ws-server.d.ts.map +1 -0
- package/dist/ws-server.js +86 -0
- package/dist/ws-server.js.map +1 -0
- 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
|