@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.
- package/README.md +10 -0
- package/lib/answer.js +335 -9
- package/lib/claude-cli.js +170 -0
- package/lib/config.js +83 -3
- package/lib/match.js +13 -0
- package/lib/mlx.js +260 -0
- package/lib/models.js +66 -0
- package/lib/optimize.js +222 -0
- package/lib/push.js +14 -1
- package/lib/skills.js +147 -0
- package/lib/subagents.js +153 -2
- package/lib/transcribe.js +156 -0
- package/package.json +1 -1
- package/server.js +350 -16
- package/web/dist/assets/{core-BP70UsO-.js → core-CZTz1vMx.js} +1 -1
- package/web/dist/assets/index-Bup-kzmD.js +85 -0
- package/web/dist/assets/index-D21GSqEK.css +1 -0
- package/web/dist/index.html +4 -2
- package/web/dist/sw.js +4 -1
- package/web/dist/assets/index-D2hrAUsb.js +0 -78
- package/web/dist/assets/index-DM_QgpOD.css +0 -1
|
@@ -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.
|
|
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
|
-
|
|
1048
|
-
//
|
|
1049
|
-
//
|
|
1050
|
-
|
|
1051
|
-
|
|
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
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
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]
|
|
1363
|
+
`[answer] dynamic path failed AFTER sending keys; NOT running static fallback (picker dirty) toolUseId=${msg.toolUseId}`,
|
|
1062
1364
|
);
|
|
1063
|
-
|
|
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
|
-
|
|
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
|
|