@idl3/claude-control 0.1.20 → 0.1.22

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.
@@ -0,0 +1,156 @@
1
+ /**
2
+ * lib/transcribe.js — local speech-to-text via ffmpeg + whisper.cpp.
3
+ *
4
+ * No API key, no cloud: transcodes the uploaded audio to 16kHz mono WAV with
5
+ * ffmpeg, then runs the whisper-cli binary (brew install whisper-cpp) against a
6
+ * local ggml model. Works for any browser that can record audio (incl. iOS
7
+ * Safari), which the Web Speech API does not.
8
+ *
9
+ * Exports:
10
+ * - resolveFfmpeg() / resolveWhisperBin() / resolveWhisperModel() → string | null
11
+ * - cleanTranscript(raw) → string (pure; strips timestamps/blank markers)
12
+ * - transcribe(inputPath, { lang }) → Promise<string>
13
+ *
14
+ * Binary/model resolution is overridable via env (FFMPEG_BIN, WHISPER_BIN,
15
+ * WHISPER_MODEL) and defaults to Homebrew + ~/.claude-control/models.
16
+ */
17
+
18
+ import fs from 'node:fs';
19
+ import path from 'node:path';
20
+ import os from 'node:os';
21
+ import { spawn, execFileSync } from 'node:child_process';
22
+
23
+ const MODELS_DIR = path.join(os.homedir(), '.claude-control', 'models');
24
+
25
+ /** Resolve a binary: env override → `which` → known fallbacks. */
26
+ function resolveBin(name, envVar, fallbacks) {
27
+ const e = process.env[envVar];
28
+ if (e && e.trim() && fs.existsSync(e.trim())) return e.trim();
29
+ try {
30
+ const w = execFileSync('which', [name], { encoding: 'utf8' }).trim();
31
+ if (w && fs.existsSync(w)) return w;
32
+ } catch {
33
+ /* not on PATH */
34
+ }
35
+ for (const f of fallbacks) if (fs.existsSync(f)) return f;
36
+ return null;
37
+ }
38
+
39
+ /** @returns {string | null} */
40
+ export function resolveFfmpeg() {
41
+ return resolveBin('ffmpeg', 'FFMPEG_BIN', [
42
+ '/opt/homebrew/bin/ffmpeg',
43
+ '/usr/local/bin/ffmpeg',
44
+ '/usr/bin/ffmpeg',
45
+ ]);
46
+ }
47
+
48
+ /** @returns {string | null} */
49
+ export function resolveWhisperBin() {
50
+ return resolveBin('whisper-cli', 'WHISPER_BIN', [
51
+ '/opt/homebrew/bin/whisper-cli',
52
+ '/usr/local/bin/whisper-cli',
53
+ ]);
54
+ }
55
+
56
+ /**
57
+ * Resolve the ggml model: WHISPER_MODEL env → preferred names in the models
58
+ * dir → any `ggml-*.bin` there.
59
+ * @returns {string | null}
60
+ */
61
+ export function resolveWhisperModel() {
62
+ const e = process.env.WHISPER_MODEL;
63
+ if (e && e.trim() && fs.existsSync(e.trim())) return e.trim();
64
+ const prefs = [
65
+ 'ggml-base.en.bin',
66
+ 'ggml-small.en.bin',
67
+ 'ggml-base.bin',
68
+ 'ggml-small.bin',
69
+ 'ggml-tiny.en.bin',
70
+ ];
71
+ for (const m of prefs) {
72
+ const p = path.join(MODELS_DIR, m);
73
+ if (fs.existsSync(p)) return p;
74
+ }
75
+ try {
76
+ const found = fs.readdirSync(MODELS_DIR).find((n) => /^ggml-.*\.bin$/.test(n));
77
+ if (found) return path.join(MODELS_DIR, found);
78
+ } catch {
79
+ /* dir missing */
80
+ }
81
+ return null;
82
+ }
83
+
84
+ /**
85
+ * Clean whisper-cli stdout into a single line: drop blank lines, drop
86
+ * bracketed-only markers ([BLANK_AUDIO], (silence)), collapse whitespace.
87
+ *
88
+ * @param {string} raw
89
+ * @returns {string}
90
+ */
91
+ export function cleanTranscript(raw) {
92
+ return String(raw)
93
+ .split('\n')
94
+ .map((l) => l.trim())
95
+ .filter(Boolean)
96
+ .filter((l) => !/^[[(][^\])]*[\])]$/.test(l))
97
+ .join(' ')
98
+ .replace(/\s+/g, ' ')
99
+ .trim();
100
+ }
101
+
102
+ /** Spawn a binary, capture stdout/stderr, resolve on exit 0. */
103
+ function run(bin, args) {
104
+ return new Promise((resolve, reject) => {
105
+ const p = spawn(bin, args, { stdio: ['ignore', 'pipe', 'pipe'] });
106
+ let out = '';
107
+ let err = '';
108
+ p.stdout.on('data', (d) => {
109
+ out += d;
110
+ });
111
+ p.stderr.on('data', (d) => {
112
+ err += d;
113
+ });
114
+ p.on('error', reject);
115
+ p.on('close', (code) =>
116
+ code === 0
117
+ ? resolve({ stdout: out, stderr: err })
118
+ : reject(new Error(`${path.basename(bin)} exited ${code}: ${err.slice(0, 500)}`)),
119
+ );
120
+ });
121
+ }
122
+
123
+ /**
124
+ * Transcribe an audio file (any ffmpeg-readable format) to text.
125
+ *
126
+ * @param {string} inputPath - path to the recorded audio file.
127
+ * @param {{ lang?: string }} [opts]
128
+ * @returns {Promise<string>}
129
+ */
130
+ export async function transcribe(inputPath, { lang = 'en' } = {}) {
131
+ const ffmpeg = resolveFfmpeg();
132
+ const whisper = resolveWhisperBin();
133
+ const model = resolveWhisperModel();
134
+ if (!ffmpeg) throw new Error('ffmpeg not found (brew install ffmpeg)');
135
+ if (!whisper) throw new Error('whisper-cli not found (brew install whisper-cpp)');
136
+ if (!model) throw new Error(`no whisper model found in ${MODELS_DIR}`);
137
+
138
+ const wav = path.join(
139
+ os.tmpdir(),
140
+ `cc-stt-${Date.now()}-${process.pid}.wav`,
141
+ );
142
+ try {
143
+ await run(ffmpeg, [
144
+ '-nostdin', '-y',
145
+ '-i', inputPath,
146
+ '-ar', '16000', '-ac', '1', '-c:a', 'pcm_s16le',
147
+ '-f', 'wav', wav,
148
+ ]);
149
+ const { stdout } = await run(whisper, [
150
+ '-m', model, '-f', wav, '-np', '-nt', '-l', lang,
151
+ ]);
152
+ return cleanTranscript(stdout);
153
+ } finally {
154
+ fs.promises.unlink(wav).catch(() => {});
155
+ }
156
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@idl3/claude-control",
3
- "version": "0.1.20",
3
+ "version": "0.1.22",
4
4
  "type": "module",
5
5
  "description": "Local web UI to watch and drive your Claude Code sessions running in tmux — live transcripts, reply, answer AskUserQuestion, attach files, from a browser or phone.",
6
6
  "keywords": [
package/server.js CHANGED
@@ -20,11 +20,23 @@ import { parsePanePrompt } from './lib/prompt.js';
20
20
  import { SessionRegistry, listRecentTranscripts } from './lib/sessions.js';
21
21
  import { loadPins, savePins, validateTranscriptPath, pinKey } from './lib/pins.js';
22
22
  import { ResourceMonitor } from './lib/resources.js';
23
- import { buildAnswerProgram } from './lib/answer.js';
23
+ import { buildAnswerProgram, parsePicker, planStep } from './lib/answer.js';
24
24
  import { sweepUploads, resolveUploadPath } from './lib/uploads.js';
25
25
  import { getVersionInfo, currentVersion } from './lib/version.js';
26
26
  import * as push from './lib/push.js';
27
27
  import { readConfig, writeConfig } from './lib/config.js';
28
+ import { optimizePrompt, rulesOptimize } from './lib/optimize.js';
29
+ import { complete as claudeCliComplete } from './lib/claude-cli.js';
30
+ import * as mlx from './lib/mlx.js';
31
+ import {
32
+ MLX_MODELS,
33
+ CLAUDE_MODELS,
34
+ detectMachine,
35
+ recommendMlxModel,
36
+ recommendClaudeModel,
37
+ } from './lib/models.js';
38
+ import { transcribe } from './lib/transcribe.js';
39
+ import { listSkills } from './lib/skills.js';
28
40
  // Note: the client offers [WS_PROTOCOL, token] as subprotocols; the `ws`
29
41
  // library auto-selects the FIRST offered one (the non-secret WS_PROTOCOL label)
30
42
  // and echoes it, so we never reflect the raw token back and need no custom
@@ -172,6 +184,10 @@ const server = http.createServer((req, res) => {
172
184
  if (!checkToken(req)) return endJson(res, 401, { error: 'unauthorized' });
173
185
  return endJson(res, 200, { sessions: registry.getSessions() });
174
186
  }
187
+ if (u.pathname === '/api/skills') {
188
+ if (!checkToken(req)) return endJson(res, 401, { error: 'unauthorized' });
189
+ return endJson(res, 200, { skills: listSkills() });
190
+ }
175
191
  if (u.pathname === '/api/health') {
176
192
  if (!checkToken(req)) return endJson(res, 401, { error: 'unauthorized' });
177
193
  return endJson(res, 200, { ok: true, snapshot: resources.snapshot() });
@@ -242,6 +258,29 @@ const server = http.createServer((req, res) => {
242
258
  if (req.method === 'POST') return handleConfigSave(req, res);
243
259
  return endJson(res, 405, { error: 'method not allowed' });
244
260
  }
261
+ if (u.pathname === '/api/optimize') {
262
+ if (req.method !== 'POST') return endJson(res, 405, { error: 'method not allowed' });
263
+ if (!checkToken(req)) return endJson(res, 401, { error: 'unauthorized' });
264
+ return handleOptimize(req, res);
265
+ }
266
+ if (u.pathname === '/api/models') {
267
+ if (!checkToken(req)) return endJson(res, 401, { error: 'unauthorized' });
268
+ const machine = detectMachine();
269
+ return endJson(res, 200, {
270
+ machine,
271
+ // Mark which MLX models are already in the local HF cache so the UI can
272
+ // show downloaded vs. will-download (avoids a surprise multi-GB fetch).
273
+ mlxModels: MLX_MODELS.map((m) => ({ ...m, installed: mlx.isModelCached(m.id) })),
274
+ claudeModels: CLAUDE_MODELS,
275
+ recommendedMlxModel: recommendMlxModel(machine.ramGB),
276
+ recommendedClaudeModel: recommendClaudeModel(),
277
+ });
278
+ }
279
+ if (u.pathname === '/api/transcribe') {
280
+ if (req.method !== 'POST') return endJson(res, 405, { error: 'method not allowed' });
281
+ if (!checkToken(req)) return endJson(res, 401, { error: 'unauthorized' });
282
+ return handleTranscribe(req, res, u);
283
+ }
245
284
  if (u.pathname === '/api/session/new') {
246
285
  if (req.method !== 'POST') return endJson(res, 405, { error: 'method not allowed' });
247
286
  if (!checkToken(req)) return endJson(res, 401, { error: 'unauthorized' });
@@ -456,12 +495,110 @@ async function handleConfigSave(req, res) {
456
495
  }
457
496
  try {
458
497
  const saved = writeConfig(body);
498
+ // If the MLX backend is active, (re)warm the selected model now — this
499
+ // restarts the local server with the new model and starts any needed
500
+ // download in the background, so the user doesn't hit a cold stall (or a
501
+ // wrong-model hang) on their next ✨ enhance.
502
+ if (saved.optimizeBackend === 'mlx' && mlx.resolveMlxPython()) {
503
+ mlx.warm();
504
+ }
459
505
  return endJson(res, 200, saved);
460
506
  } catch (err) {
461
507
  return endJson(res, 400, { error: String(err?.message || err) });
462
508
  }
463
509
  }
464
510
 
511
+ // POST /api/optimize — token-gated prompt optimiser. Accepts { text, intent }
512
+ // and returns { optimized, rationale, changes, mode } from optimizePrompt.
513
+ // Falls back to rules-based optimization when the Claude CLI is unavailable.
514
+ async function handleOptimize(req, res) {
515
+ let body;
516
+ try {
517
+ body = await readJsonBody(req);
518
+ } catch (err) {
519
+ return endJson(res, 400, { error: 'invalid JSON body' });
520
+ }
521
+ const text = typeof body.text === 'string' ? body.text : '';
522
+ if (!text.trim()) return endJson(res, 400, { error: 'text required' });
523
+ if (text.length > 8000) return endJson(res, 400, { error: 'text exceeds 8000 character limit' });
524
+ const intent = typeof body.intent === 'string' ? body.intent : undefined;
525
+ try {
526
+ const result = await runOptimize(text, intent);
527
+ return endJson(res, 200, result);
528
+ } catch (err) {
529
+ return endJson(res, 500, { error: String(err?.message || err) });
530
+ }
531
+ }
532
+
533
+ // Run the enhancer through the configured backend chain, recording WHICH backend
534
+ // actually produced the result so the UI can label it accurately:
535
+ // - 'mlx' → try local MLX, then claude -p, then rules.
536
+ // - 'claude' → try claude -p, then rules.
537
+ // - 'rules' → deterministic rules optimiser only.
538
+ // optimizePrompt returns mode:'rules' when its injected complete() fails, so a
539
+ // non-'llm' mode means that backend fell through → try the next.
540
+ async function runOptimize(text, intent) {
541
+ const cfg = readConfig();
542
+ const backend = cfg.optimizeBackend;
543
+ if (backend === 'rules') {
544
+ return { ...rulesOptimize(text), backend: 'rules' };
545
+ }
546
+ const order = backend === 'claude' ? ['claude'] : ['mlx', 'claude'];
547
+ for (const b of order) {
548
+ const complete = b === 'mlx' ? (p) => mlx.complete(p) : claudeCliComplete;
549
+ const r = await optimizePrompt(text, { complete, intent });
550
+ if (r.mode === 'llm') {
551
+ return { ...r, backend: b, model: b === 'mlx' ? cfg.mlxModel : cfg.optimizeModel };
552
+ }
553
+ }
554
+ return { ...rulesOptimize(text), backend: 'rules' };
555
+ }
556
+
557
+ // POST /api/transcribe — local speech-to-text. Accepts a raw audio body (the
558
+ // MediaRecorder blob from the voice dialog; ?ext=webm|mp4|wav names the format),
559
+ // caps the size, writes it to a temp file, and runs ffmpeg→whisper.cpp via
560
+ // lib/transcribe. Returns { ok, text }. No key, no cloud — fully local.
561
+ function handleTranscribe(req, res, u) {
562
+ const maxBytes = CONFIG.maxUploadMB * 1024 * 1024;
563
+ const ext =
564
+ (u.searchParams.get('ext') || 'webm').toLowerCase().replace(/[^a-z0-9]/g, '').slice(0, 5) ||
565
+ 'webm';
566
+ const chunks = [];
567
+ let size = 0;
568
+ let aborted = false;
569
+
570
+ req.on('data', (c) => {
571
+ if (aborted) return;
572
+ size += c.length;
573
+ if (size > maxBytes) {
574
+ aborted = true;
575
+ endJson(res, 413, { error: `audio exceeds ${CONFIG.maxUploadMB} MB limit` });
576
+ req.destroy();
577
+ return;
578
+ }
579
+ chunks.push(c);
580
+ });
581
+
582
+ req.on('end', async () => {
583
+ if (aborted) return;
584
+ if (size === 0) return endJson(res, 400, { error: 'empty audio' });
585
+ const tmp = path.join(os.tmpdir(), `cc-stt-in-${Date.now()}-${process.pid}.${ext}`);
586
+ try {
587
+ await fs.promises.writeFile(tmp, Buffer.concat(chunks), { mode: 0o600 });
588
+ const text = await transcribe(tmp);
589
+ endJson(res, 200, { ok: true, text });
590
+ } catch (err) {
591
+ endJson(res, 500, { error: String(err?.message || err) });
592
+ } finally {
593
+ fs.promises.unlink(tmp).catch(() => {});
594
+ }
595
+ });
596
+
597
+ req.on('error', () => {
598
+ if (!aborted) endJson(res, 400, { error: 'audio stream error' });
599
+ });
600
+ }
601
+
465
602
  // POST /api/session/new — create a new tmux window in the configured (or
466
603
  // body-overridden) cwd, then type the launch command into it via send-keys so
467
604
  // the interactive shell resolves aliases. Security: the command is operator
@@ -1044,25 +1181,212 @@ async function handleClientMessage(ws, msg) {
1044
1181
  if (msg.toolUseId !== pending.toolUseId) {
1045
1182
  throw new Error('stale question (already answered or changed)');
1046
1183
  }
1047
- const keys = buildAnswerProgram(pending, msg.selections || []);
1048
- // Log the resolved key program so a failure to drive the picker is
1049
- // diagnosable from ~/.claude-control/logs/out.log (no logging existed before).
1050
- console.log(
1051
- `[answer] toolUseId=${msg.toolUseId} target=${session.target} keys=${JSON.stringify(keys)}`,
1052
- );
1184
+
1185
+ // ── Capture-driven path ──────────────────────────────────────────────
1186
+ // Attempt to navigate by parsing the live picker render. Falls back to
1187
+ // the static buildAnswerProgram on ANY parse failure, unknown label, or
1188
+ // post-send verification mismatch — so it can NEVER regress the working path.
1189
+ //
1190
+ // Constants:
1191
+ const SETTLE_MS = 300; // ms to wait after sending keys before re-capture
1192
+ const MAX_RETRIES = 1; // retry attempts per question on verification failure
1193
+
1194
+ let usedDynamic = false;
1195
+ // Tracks whether the dynamic path has injected ANY keystroke. Once true,
1196
+ // the picker is in a partial/unknown state and the from-scratch static
1197
+ // fallback would corrupt it — so a later failure must fail loud, not retry.
1198
+ let sentAny = false;
1053
1199
  try {
1054
- // Sequenced (with delays) so the picker's re-render settles between keys.
1055
- await tmux.sendRawKeysSequenced(session.target, keys);
1056
- } catch (err) {
1057
- // Surface the failure to the log and re-throw so the outer handler nacks
1058
- // (ok:false) never let an "answer sent" ack imply success when the keys
1059
- // never landed in the pane.
1200
+ const questions = pending?.questions || [];
1201
+ const selections = msg.selections || [];
1202
+
1203
+ if (questions.length > 0) {
1204
+ let dynamicOk = true; // will be set false to fall back
1205
+
1206
+ for (let qi = 0; qi < questions.length && dynamicOk; qi += 1) {
1207
+ const question = questions[qi];
1208
+ const selectedLabels = selections[qi] || [];
1209
+
1210
+ let attempt = 0;
1211
+ let stepOk = false;
1212
+
1213
+ while (attempt <= MAX_RETRIES && !stepOk) {
1214
+ // 1. Capture current picker state.
1215
+ let capture;
1216
+ try {
1217
+ capture = await tmux.capturePane(session.target);
1218
+ } catch (captureErr) {
1219
+ console.log(`[answer/dynamic] capture failed q${qi}: ${captureErr?.message}`);
1220
+ dynamicOk = false;
1221
+ break;
1222
+ }
1223
+
1224
+ // 2. Parse.
1225
+ const parsed = parsePicker(capture);
1226
+ if (parsed.confidence !== 'ok') {
1227
+ console.log(`[answer/dynamic] low confidence on q${qi} — falling back`);
1228
+ dynamicOk = false;
1229
+ break;
1230
+ }
1231
+
1232
+ // 3. Handle the review screen (multi-question final step).
1233
+ if (parsed.isReview) {
1234
+ // We expect to be here only after the last question's action Enter.
1235
+ // Send Enter to confirm "Submit answers".
1236
+ console.log(`[answer/dynamic] review screen — sending Enter`);
1237
+ sentAny = true;
1238
+ await tmux.sendRawKeysSequenced(session.target, ['Enter'], SETTLE_MS);
1239
+ await new Promise((r) => setTimeout(r, SETTLE_MS));
1240
+ // Verify: the review screen should be gone.
1241
+ const afterReview = await tmux.capturePane(session.target);
1242
+ const reparse = parsePicker(afterReview);
1243
+ if (reparse.isReview) {
1244
+ console.log(`[answer/dynamic] review screen still up after Enter — falling back`);
1245
+ dynamicOk = false;
1246
+ }
1247
+ // Whether verified or not, we break out of the question loop —
1248
+ // we've processed all questions.
1249
+ break;
1250
+ }
1251
+
1252
+ // 4. Plan keystrokes for this question.
1253
+ const keys = planStep(parsed, question, selectedLabels);
1254
+ if (!keys) {
1255
+ console.log(`[answer/dynamic] planStep null on q${qi} — falling back`);
1256
+ dynamicOk = false;
1257
+ break;
1258
+ }
1259
+
1260
+ console.log(
1261
+ `[answer/dynamic] q${qi} attempt=${attempt} keys=${JSON.stringify(keys)}`,
1262
+ );
1263
+
1264
+ // 5. Send keys.
1265
+ sentAny = true;
1266
+ await tmux.sendRawKeysSequenced(session.target, keys, SETTLE_MS);
1267
+
1268
+ // 6. Settle then verify.
1269
+ await new Promise((r) => setTimeout(r, SETTLE_MS));
1270
+ let afterCapture;
1271
+ try {
1272
+ afterCapture = await tmux.capturePane(session.target);
1273
+ } catch (captureErr) {
1274
+ console.log(`[answer/dynamic] post-send capture failed q${qi}: ${captureErr?.message}`);
1275
+ dynamicOk = false;
1276
+ break;
1277
+ }
1278
+
1279
+ const afterParsed = parsePicker(afterCapture);
1280
+
1281
+ if (question.multiSelect) {
1282
+ // Verify: all intended labels are now checked in the re-parsed picker.
1283
+ // If we advanced (Next/Submit pressed), the screen changes — that's
1284
+ // also acceptable (confidence goes low = we moved on).
1285
+ if (afterParsed.confidence === 'ok' && !afterParsed.isReview) {
1286
+ const uncheckedTargets = selectedLabels.filter((label) =>
1287
+ afterParsed.rows.some(
1288
+ (r) => r.kind === 'option' && r.label === label && !r.checked,
1289
+ ),
1290
+ );
1291
+ if (uncheckedTargets.length > 0) {
1292
+ console.log(
1293
+ `[answer/dynamic] verify failed q${qi}: still unchecked=${JSON.stringify(uncheckedTargets)} attempt=${attempt}`,
1294
+ );
1295
+ attempt += 1;
1296
+ continue; // retry
1297
+ }
1298
+ }
1299
+ // Either confidence is low (screen advanced) or all checked — either
1300
+ // way, treat the step as done and move to the next question.
1301
+ stepOk = true;
1302
+ } else {
1303
+ // Single-select: after Enter, picker should advance (screen changes).
1304
+ // If the exact same option is still shown as selected (cursor on it),
1305
+ // something went wrong. Accept any screen change as advancement.
1306
+ if (
1307
+ afterParsed.confidence === 'ok' &&
1308
+ !afterParsed.isReview &&
1309
+ afterParsed.rows.some(
1310
+ (r) => r.cursor && r.kind === 'option' && r.label === selectedLabels[0],
1311
+ )
1312
+ ) {
1313
+ console.log(`[answer/dynamic] single-select stuck on q${qi} attempt=${attempt}`);
1314
+ attempt += 1;
1315
+ continue;
1316
+ }
1317
+ stepOk = true;
1318
+ }
1319
+ }
1320
+
1321
+ if (!stepOk && attempt > MAX_RETRIES) {
1322
+ console.log(`[answer/dynamic] max retries exceeded on q${qi} — falling back`);
1323
+ dynamicOk = false;
1324
+ }
1325
+ }
1326
+
1327
+ // After processing all questions via dynamic path, check if we need to
1328
+ // handle the review screen (multi-question pickers).
1329
+ if (dynamicOk && questions.length > 1) {
1330
+ // Capture and check: we may already be on the review screen (handled
1331
+ // in the loop above) or may need to check.
1332
+ try {
1333
+ const finalCapture = await tmux.capturePane(session.target);
1334
+ const finalParsed = parsePicker(finalCapture);
1335
+ if (finalParsed.isReview && finalParsed.confidence === 'ok') {
1336
+ // Submit the review screen.
1337
+ console.log(`[answer/dynamic] post-loop review screen — sending Enter`);
1338
+ sentAny = true;
1339
+ await tmux.sendRawKeysSequenced(session.target, ['Enter'], SETTLE_MS);
1340
+ }
1341
+ } catch (captureErr) {
1342
+ // Non-fatal: we already sent the question answers; review Enter is best-effort.
1343
+ console.log(`[answer/dynamic] final review capture failed: ${captureErr?.message}`);
1344
+ }
1345
+ }
1346
+
1347
+ if (dynamicOk) {
1348
+ usedDynamic = true;
1349
+ }
1350
+ }
1351
+ } catch (dynamicErr) {
1352
+ // Any unexpected error in the dynamic path — log and fall back.
1353
+ console.log(`[answer/dynamic] unexpected error: ${dynamicErr?.message} — falling back`);
1354
+ }
1355
+
1356
+ // ── Static fallback ──────────────────────────────────────────────────
1357
+ // Only safe when the dynamic path sent NOTHING (picker still pristine). If
1358
+ // dynamic already injected keys then failed, the picker is in a partial
1359
+ // state — replaying the from-scratch static program would mis-navigate a
1360
+ // dirty picker and corrupt the answer. Fail loud so the user can retry.
1361
+ if (!usedDynamic && sentAny) {
1060
1362
  console.error(
1061
- `[answer] FAILED toolUseId=${msg.toolUseId} target=${session.target}: ${String(err?.message || err)}`,
1363
+ `[answer] dynamic path failed AFTER sending keys; NOT running static fallback (picker dirty) toolUseId=${msg.toolUseId}`,
1062
1364
  );
1063
- throw err;
1365
+ return send(ws, {
1366
+ type: 'ack',
1367
+ op: 'answer',
1368
+ ok: false,
1369
+ error: 'answer injection failed mid-picker — please retry',
1370
+ });
1064
1371
  }
1065
- console.log(`[answer] sent toolUseId=${msg.toolUseId} (${keys.length} keys)`);
1372
+ if (!usedDynamic) {
1373
+ const keys = buildAnswerProgram(pending, msg.selections || []);
1374
+ console.log(
1375
+ `[answer] toolUseId=${msg.toolUseId} target=${session.target} keys=${JSON.stringify(keys)} (static fallback)`,
1376
+ );
1377
+ try {
1378
+ await tmux.sendRawKeysSequenced(session.target, keys);
1379
+ } catch (err) {
1380
+ console.error(
1381
+ `[answer] FAILED toolUseId=${msg.toolUseId} target=${session.target}: ${String(err?.message || err)}`,
1382
+ );
1383
+ throw err;
1384
+ }
1385
+ console.log(`[answer] sent toolUseId=${msg.toolUseId} (${keys.length} keys)`);
1386
+ } else {
1387
+ console.log(`[answer] sent toolUseId=${msg.toolUseId} via dynamic path`);
1388
+ }
1389
+
1066
1390
  return send(ws, { type: 'ack', op: 'answer', ok: true });
1067
1391
  }
1068
1392
  case 'capture': {
@@ -1182,6 +1506,16 @@ async function main() {
1182
1506
  } else {
1183
1507
  console.log(' (no COCKPIT_TOKEN set — relying on 127.0.0.1 bind. This UI can type into your sessions.)');
1184
1508
  }
1509
+ // Pre-warm the local MLX enhancer so the first ✨ enhance is fast (best-effort;
1510
+ // only when that backend is selected and an mlx python is available).
1511
+ try {
1512
+ if (readConfig().optimizeBackend === 'mlx' && mlx.resolveMlxPython()) {
1513
+ mlx.warm();
1514
+ console.log(' (pre-warming local MLX enhancer model…)');
1515
+ }
1516
+ } catch {
1517
+ /* best-effort */
1518
+ }
1185
1519
  });
1186
1520
  }
1187
1521