@idl3/claude-control 0.1.20 → 0.1.21
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/lib/answer.js +335 -9
- package/lib/claude-cli.js +170 -0
- package/lib/config.js +47 -3
- package/lib/match.js +13 -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 +288 -16
- package/web/dist/assets/{core-BP70UsO-.js → core-CyYMg33t.js} +1 -1
- package/web/dist/assets/index-BeJg6Cs1.js +85 -0
- package/web/dist/assets/index-Dn7NDGPq.css +1 -0
- package/web/dist/index.html +2 -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
package/lib/answer.js
CHANGED
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
// footer: "Enter to select · ↑/↓ to navigate · n to add notes · Tab to switch
|
|
5
5
|
// questions · Esc to cancel"
|
|
6
6
|
// spec: single-select = ['Down'*index, 'Enter'];
|
|
7
|
-
// multi-select =
|
|
7
|
+
// multi-select = Space-toggle each chosen index, then Down to the
|
|
8
|
+
// per-question action row ("Next"/"Submit") + Enter.
|
|
8
9
|
//
|
|
9
10
|
// - Each question lists its options vertically; a cursor starts on the FIRST
|
|
10
11
|
// option (index 0) and moves with Up/Down. There are NO number shortcuts —
|
|
@@ -12,11 +13,15 @@
|
|
|
12
13
|
// this UI (the cause of "answer sent but nothing happened").
|
|
13
14
|
// - SINGLE-select: navigate Down to the chosen option, then press Enter. Enter
|
|
14
15
|
// commits the answer and advances to the next question (or submits on the last).
|
|
15
|
-
// - MULTI-select:
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
16
|
+
// - MULTI-select: Space toggles a checkbox; Enter on a checkbox ONLY toggles it
|
|
17
|
+
// (footer reads "Enter to select") and does NOT advance. So: Space-toggle each
|
|
18
|
+
// chosen option, then navigate Down to the action row — "Next" (non-final) or
|
|
19
|
+
// "Submit" (final) — at navigable index options.length + 1 (after the real
|
|
20
|
+
// options and the always-present "Type something" free-text row), then Enter.
|
|
21
|
+
// Enter on "Next" advances to the next question (cursor resets to 0); Enter on
|
|
22
|
+
// "Submit" submits the whole picker. (Pressing Enter on the last toggled
|
|
23
|
+
// option — the old model — left the second question unanswered + never
|
|
24
|
+
// submitted: the exact reported bug.)
|
|
20
25
|
//
|
|
21
26
|
// We deliberately avoid the `n` (add notes) key: it opens a free-text input that
|
|
22
27
|
// would swallow every subsequent keystroke. Navigation is arrows + Space/Enter only.
|
|
@@ -24,6 +29,309 @@
|
|
|
24
29
|
// Keys are sent one at a time with a delay (see tmux.sendRawKeysSequenced) so the
|
|
25
30
|
// picker's re-render settles between keys and none are dropped.
|
|
26
31
|
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
// Picker capture parser — capture-driven answerer
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
//
|
|
36
|
+
// Empirical picker model (reverse-engineered from live renders):
|
|
37
|
+
//
|
|
38
|
+
// Navigable rows in order:
|
|
39
|
+
// 1. Each real option: "N. [ ]Label" or "N. [x]Label" or "N. [✓]Label"
|
|
40
|
+
// Below each option there may be DIMMED DESCRIPTION lines — these are
|
|
41
|
+
// NOT navigable and Up/Down skip them.
|
|
42
|
+
// 2. "Type something" — always present free-text row.
|
|
43
|
+
// 3. Action row — literal "Next" (non-final) or "Submit" (final).
|
|
44
|
+
// 4. "Chat about this" — always present last row.
|
|
45
|
+
//
|
|
46
|
+
// Cursor: row is marked at line start with "›" or "❯" (possibly with
|
|
47
|
+
// leading whitespace / ANSI stripped text before it).
|
|
48
|
+
//
|
|
49
|
+
// Review screen (multi-question only, appears after final Submit):
|
|
50
|
+
// "Review your answers … Ready to submit your answers?"
|
|
51
|
+
// "› 1. Submit answers"
|
|
52
|
+
// "2. Cancel"
|
|
53
|
+
//
|
|
54
|
+
// The parser strips ANSI escape sequences before analysis so it works on both
|
|
55
|
+
// plain and escape-laden captures.
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* @typedef {{
|
|
59
|
+
* kind: 'option'|'type-something'|'action'|'chat'|'review-submit'|'review-cancel',
|
|
60
|
+
* label: string,
|
|
61
|
+
* checked?: boolean,
|
|
62
|
+
* cursor: boolean
|
|
63
|
+
* }} PickerRow
|
|
64
|
+
*
|
|
65
|
+
* @typedef {{
|
|
66
|
+
* rows: PickerRow[],
|
|
67
|
+
* actionLabel: 'Next'|'Submit'|null,
|
|
68
|
+
* isReview: boolean,
|
|
69
|
+
* confidence: 'ok'|'low'
|
|
70
|
+
* }} ParsedPicker
|
|
71
|
+
*/
|
|
72
|
+
|
|
73
|
+
// Strip ANSI escape sequences from a string.
|
|
74
|
+
function stripAnsi(str) {
|
|
75
|
+
// eslint-disable-next-line no-control-regex
|
|
76
|
+
return str.replace(/\x1b\[[0-9;]*[A-Za-z]/g, '').replace(/\x1b\][^\x07]*\x07/g, '');
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Detect cursor marker at the start of a (stripped, trimmed) line.
|
|
80
|
+
function hasCursor(line) {
|
|
81
|
+
return /^[›❯]/.test(line.trim());
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Remove the cursor marker from a line and trim.
|
|
85
|
+
function removeCursor(line) {
|
|
86
|
+
return line.trim().replace(/^[›❯]\s*/, '');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Detect an option line: "N. [ ] Label" / "N. [x] Label" / "N. [✓] Label"
|
|
90
|
+
// Also handles "N. [✓]Label" without space after bracket.
|
|
91
|
+
const OPTION_RE = /^\d+\.\s+\[([✓x✗ ])\]\s*(.*)/;
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Parse the visible content of a tmux pane into a structured picker model.
|
|
95
|
+
*
|
|
96
|
+
* Returns a low-confidence result (confidence:'low', rows:[]) rather than
|
|
97
|
+
* throwing when the capture doesn't look like a picker at all.
|
|
98
|
+
*
|
|
99
|
+
* @param {string} capture Raw text from tmux capture-pane.
|
|
100
|
+
* @returns {ParsedPicker}
|
|
101
|
+
*/
|
|
102
|
+
export function parsePicker(capture) {
|
|
103
|
+
const EMPTY = { rows: [], actionLabel: null, isReview: false, confidence: 'low' };
|
|
104
|
+
|
|
105
|
+
if (!capture || typeof capture !== 'string') return EMPTY;
|
|
106
|
+
|
|
107
|
+
const raw = stripAnsi(capture);
|
|
108
|
+
const lines = raw.split('\n');
|
|
109
|
+
|
|
110
|
+
// Detect review screen first — it's structurally different.
|
|
111
|
+
const hasReviewHeader = lines.some((l) => /Review your answers/i.test(l));
|
|
112
|
+
const hasReadyLine = lines.some((l) => /Ready to submit your answers/i.test(l));
|
|
113
|
+
|
|
114
|
+
if (hasReviewHeader && hasReadyLine) {
|
|
115
|
+
// Parse the two review options.
|
|
116
|
+
const rows = [];
|
|
117
|
+
for (const rawLine of lines) {
|
|
118
|
+
const stripped = stripAnsi(rawLine);
|
|
119
|
+
const cursor = hasCursor(stripped);
|
|
120
|
+
const line = removeCursor(stripped);
|
|
121
|
+
if (/1\.\s+Submit answers/i.test(line)) {
|
|
122
|
+
rows.push({ kind: 'review-submit', label: 'Submit answers', cursor });
|
|
123
|
+
} else if (/2\.\s+Cancel/i.test(line)) {
|
|
124
|
+
rows.push({ kind: 'review-cancel', label: 'Cancel', cursor });
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
if (rows.length === 0) return EMPTY;
|
|
128
|
+
return { rows, actionLabel: null, isReview: true, confidence: 'ok' };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Normal question screen.
|
|
132
|
+
// Strategy: scan lines, classify each as option / description / special.
|
|
133
|
+
// Description lines follow an option and do NOT match the option pattern,
|
|
134
|
+
// are not cursor-marked, and don't match any other special marker.
|
|
135
|
+
|
|
136
|
+
/** @type {PickerRow[]} */
|
|
137
|
+
const rows = [];
|
|
138
|
+
/** @type {'Next'|'Submit'|null} */
|
|
139
|
+
let actionLabel = null;
|
|
140
|
+
|
|
141
|
+
// Track whether we've seen at least one option (to know if we're past options).
|
|
142
|
+
let seenOption = false;
|
|
143
|
+
// Track whether the most-recently-seen navigable row was an option, so the
|
|
144
|
+
// next plain text line can be classified as a description.
|
|
145
|
+
let lastNavWasOption = false;
|
|
146
|
+
|
|
147
|
+
for (const rawLine of lines) {
|
|
148
|
+
const stripped = stripAnsi(rawLine);
|
|
149
|
+
const cursor = hasCursor(stripped);
|
|
150
|
+
const line = removeCursor(stripped);
|
|
151
|
+
const trimmed = line.trim();
|
|
152
|
+
|
|
153
|
+
if (!trimmed) continue;
|
|
154
|
+
|
|
155
|
+
// Option line: "N. [x] Label" — match first, then check if it's a special
|
|
156
|
+
// known row (Type something, Chat about this) that happens to be numbered.
|
|
157
|
+
const optMatch = trimmed.match(OPTION_RE);
|
|
158
|
+
if (optMatch) {
|
|
159
|
+
const checkChar = optMatch[1]; // ' ', 'x', '✓', '✗'
|
|
160
|
+
const label = optMatch[2].trim();
|
|
161
|
+
|
|
162
|
+
// "Type something" can appear as a numbered checkbox row.
|
|
163
|
+
if (/^Type something$/i.test(label)) {
|
|
164
|
+
rows.push({ kind: 'type-something', label: 'Type something', cursor });
|
|
165
|
+
lastNavWasOption = false;
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// "Chat about this" can appear as a numbered checkbox row.
|
|
170
|
+
if (/^Chat about this$/i.test(label)) {
|
|
171
|
+
rows.push({ kind: 'chat', label: 'Chat about this', cursor });
|
|
172
|
+
lastNavWasOption = false;
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const checked = checkChar !== ' ';
|
|
177
|
+
rows.push({ kind: 'option', label, checked, cursor });
|
|
178
|
+
seenOption = true;
|
|
179
|
+
lastNavWasOption = true;
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// "Type something" row — the free-text row when not in option format.
|
|
184
|
+
if (/^Type something/i.test(trimmed)) {
|
|
185
|
+
rows.push({ kind: 'type-something', label: 'Type something', cursor });
|
|
186
|
+
lastNavWasOption = false;
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Action row — "Next" or "Submit" (bare word on its own line, appears AFTER
|
|
191
|
+
// "Type something"). Must appear at start of line content (after cursor strip).
|
|
192
|
+
if (/^Next$/i.test(trimmed)) {
|
|
193
|
+
rows.push({ kind: 'action', label: 'Next', cursor });
|
|
194
|
+
actionLabel = 'Next';
|
|
195
|
+
lastNavWasOption = false;
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
if (/^Submit$/i.test(trimmed)) {
|
|
199
|
+
rows.push({ kind: 'action', label: 'Submit', cursor });
|
|
200
|
+
actionLabel = 'Submit';
|
|
201
|
+
lastNavWasOption = false;
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// "Chat about this" row — may appear bare or as a numbered line "N. Chat about this".
|
|
206
|
+
{
|
|
207
|
+
const bareLabel = trimmed.replace(/^\d+\.\s+/, '');
|
|
208
|
+
if (/^Chat about this/i.test(bareLabel) || /^Chat about this/i.test(trimmed)) {
|
|
209
|
+
rows.push({ kind: 'chat', label: 'Chat about this', cursor });
|
|
210
|
+
lastNavWasOption = false;
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Footer line — skip (keyboard hint at the bottom).
|
|
216
|
+
if (/Enter to select|↑.↓ to navigate|Esc to cancel/i.test(trimmed)) continue;
|
|
217
|
+
|
|
218
|
+
// Tab-bar line (e.g. "← ⊠ Fruits □ Colors ✔ Submit →") — skip.
|
|
219
|
+
if (/←.*→/.test(trimmed)) continue;
|
|
220
|
+
|
|
221
|
+
// Question text / header — skip if we haven't seen any option yet.
|
|
222
|
+
if (!seenOption) continue;
|
|
223
|
+
|
|
224
|
+
// Otherwise: if the previous navigable row was an option, this is a
|
|
225
|
+
// description line below it — skip (not navigable).
|
|
226
|
+
if (lastNavWasOption) continue;
|
|
227
|
+
|
|
228
|
+
// Anything else after options: skip (question text bleed, etc.).
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Need at least one option or "Type something" to call it a picker.
|
|
232
|
+
if (rows.filter((r) => r.kind === 'option' || r.kind === 'type-something').length === 0) {
|
|
233
|
+
return EMPTY;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return { rows, actionLabel, isReview: false, confidence: 'ok' };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Given a parsed picker for ONE question and the desired selections, compute
|
|
241
|
+
* the keystroke sequence to toggle the right options and press the action row.
|
|
242
|
+
*
|
|
243
|
+
* Returns null when confidence is insufficient (unknown option label, no action
|
|
244
|
+
* row found, etc.) — caller must fall back to the static model.
|
|
245
|
+
*
|
|
246
|
+
* @param {ParsedPicker} parsed
|
|
247
|
+
* @param {{ multiSelect?: boolean, options: {label:string}[] }} question
|
|
248
|
+
* @param {string[]} selectedLabels
|
|
249
|
+
* @returns {string[]|null}
|
|
250
|
+
*/
|
|
251
|
+
export function planStep(parsed, question, selectedLabels) {
|
|
252
|
+
if (!parsed || parsed.confidence !== 'ok' || parsed.isReview) return null;
|
|
253
|
+
|
|
254
|
+
const { rows, actionLabel } = parsed;
|
|
255
|
+
|
|
256
|
+
// Navigable rows are everything EXCEPT descriptions (which we already excluded
|
|
257
|
+
// in parsePicker). All rows in the list are navigable.
|
|
258
|
+
const navRows = rows;
|
|
259
|
+
|
|
260
|
+
if (navRows.length === 0) return null;
|
|
261
|
+
|
|
262
|
+
if (!question.multiSelect) {
|
|
263
|
+
// Single-select: find the target option by label, Down to it, Enter.
|
|
264
|
+
if (!selectedLabels || selectedLabels.length === 0) return null;
|
|
265
|
+
const targetLabel = selectedLabels[0];
|
|
266
|
+
const targetIdx = navRows.findIndex(
|
|
267
|
+
(r) => r.kind === 'option' && r.label === targetLabel,
|
|
268
|
+
);
|
|
269
|
+
if (targetIdx < 0) return null;
|
|
270
|
+
|
|
271
|
+
// Cursor position from the parsed state.
|
|
272
|
+
const cursorIdx = navRows.findIndex((r) => r.cursor);
|
|
273
|
+
const fromIdx = cursorIdx >= 0 ? cursorIdx : 0;
|
|
274
|
+
|
|
275
|
+
const keys = [];
|
|
276
|
+
const delta = targetIdx - fromIdx;
|
|
277
|
+
if (delta > 0) {
|
|
278
|
+
for (let i = 0; i < delta; i += 1) keys.push('Down');
|
|
279
|
+
} else if (delta < 0) {
|
|
280
|
+
for (let i = 0; i < -delta; i += 1) keys.push('Up');
|
|
281
|
+
}
|
|
282
|
+
keys.push('Enter');
|
|
283
|
+
return keys;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Multi-select: for each label, verify it exists, then compute toggle plan.
|
|
287
|
+
if (!selectedLabels || selectedLabels.length === 0) return null;
|
|
288
|
+
|
|
289
|
+
// Resolve label → navigable index for all targets.
|
|
290
|
+
const targetIndices = selectedLabels.map((label) =>
|
|
291
|
+
navRows.findIndex((r) => r.kind === 'option' && r.label === label),
|
|
292
|
+
);
|
|
293
|
+
if (targetIndices.some((i) => i < 0)) return null; // unknown label — bail
|
|
294
|
+
|
|
295
|
+
// Sort ascending for top-to-bottom navigation.
|
|
296
|
+
targetIndices.sort((a, b) => a - b);
|
|
297
|
+
|
|
298
|
+
// Find action row index.
|
|
299
|
+
const actionIdx = navRows.findIndex((r) => r.kind === 'action');
|
|
300
|
+
if (actionIdx < 0) return null; // no action row visible — bail
|
|
301
|
+
|
|
302
|
+
const cursorIdx = navRows.findIndex((r) => r.cursor);
|
|
303
|
+
let cursor = cursorIdx >= 0 ? cursorIdx : 0;
|
|
304
|
+
|
|
305
|
+
const keys = [];
|
|
306
|
+
|
|
307
|
+
for (const target of targetIndices) {
|
|
308
|
+
// Navigate to the target option.
|
|
309
|
+
const delta = target - cursor;
|
|
310
|
+
if (delta > 0) {
|
|
311
|
+
for (let i = 0; i < delta; i += 1) keys.push('Down');
|
|
312
|
+
} else if (delta < 0) {
|
|
313
|
+
for (let i = 0; i < -delta; i += 1) keys.push('Up');
|
|
314
|
+
}
|
|
315
|
+
// Toggle: only Space if the current checked state ≠ desired (checked).
|
|
316
|
+
// The picker starts with all unchecked; we always want to check the targets.
|
|
317
|
+
// If it's already checked (pre-ticked), Space would UN-check — skip it.
|
|
318
|
+
const row = navRows[target];
|
|
319
|
+
if (!row.checked) keys.push('Space');
|
|
320
|
+
cursor = target;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// Navigate to the action row and Enter.
|
|
324
|
+
const actionDelta = actionIdx - cursor;
|
|
325
|
+
if (actionDelta > 0) {
|
|
326
|
+
for (let i = 0; i < actionDelta; i += 1) keys.push('Down');
|
|
327
|
+
} else if (actionDelta < 0) {
|
|
328
|
+
for (let i = 0; i < -actionDelta; i += 1) keys.push('Up');
|
|
329
|
+
}
|
|
330
|
+
keys.push('Enter');
|
|
331
|
+
|
|
332
|
+
return keys;
|
|
333
|
+
}
|
|
334
|
+
|
|
27
335
|
/**
|
|
28
336
|
* Resolve the selected labels to option indices, in top-to-bottom order.
|
|
29
337
|
* @param {{options: {label:string}[]}} question
|
|
@@ -62,15 +370,26 @@ export function buildAnswerKeys(question, selectedLabels) {
|
|
|
62
370
|
return keys;
|
|
63
371
|
}
|
|
64
372
|
|
|
65
|
-
// Multi-select:
|
|
66
|
-
//
|
|
67
|
-
//
|
|
373
|
+
// Multi-select: toggle each chosen option with Space (cursor starts at option
|
|
374
|
+
// 0; move only the delta between successive targets). Then navigate DOWN to the
|
|
375
|
+
// per-question action row — "Next" on a non-final question, "Submit" on the
|
|
376
|
+
// final one — and press Enter to activate it.
|
|
377
|
+
//
|
|
378
|
+
// CRITICAL (verified empirically against the live picker): the footer is
|
|
379
|
+
// "Enter to select", so Enter on a CHECKBOX only toggles it — it does NOT
|
|
380
|
+
// advance/submit. The action row sits at navigable index options.length + 1:
|
|
381
|
+
// the real options [0..N-1], then the always-present "Type something" free-text
|
|
382
|
+
// row [N], then "Next"/"Submit" [N+1] (then "Chat about this" [N+2]). The OLD
|
|
383
|
+
// model pressed Enter while still on the last option, so it never advanced —
|
|
384
|
+
// the second question went unanswered and the picker never submitted.
|
|
68
385
|
let cursor = 0;
|
|
69
386
|
for (const target of indices) {
|
|
70
387
|
for (let i = cursor; i < target; i += 1) keys.push('Down');
|
|
71
388
|
keys.push('Space');
|
|
72
389
|
cursor = target;
|
|
73
390
|
}
|
|
391
|
+
const actionRow = (question.options?.length ?? 0) + 1;
|
|
392
|
+
for (let i = cursor; i < actionRow; i += 1) keys.push('Down');
|
|
74
393
|
keys.push('Enter');
|
|
75
394
|
return keys;
|
|
76
395
|
}
|
|
@@ -91,5 +410,12 @@ export function buildAnswerProgram(pending, selections) {
|
|
|
91
410
|
for (let i = 0; i < questions.length; i += 1) {
|
|
92
411
|
program.push(...buildAnswerKeys(questions[i], selections?.[i] || []));
|
|
93
412
|
}
|
|
413
|
+
// Multi-question pickers carry a final "Submit" tab: after the last question's
|
|
414
|
+
// action-row Enter, the picker lands on a "Review your answers · Submit answers /
|
|
415
|
+
// Cancel" screen with "Submit answers" highlighted. One more Enter confirms +
|
|
416
|
+
// closes it. (Verified live: without this, every question was answered correctly
|
|
417
|
+
// but the picker sat on the review screen, unsubmitted.) Single-question pickers
|
|
418
|
+
// have no review step — the question's own Enter submits.
|
|
419
|
+
if (questions.length > 1) program.push('Enter');
|
|
94
420
|
return program;
|
|
95
421
|
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/claude-cli.js — LLM backend that spawns the host Claude CLI.
|
|
3
|
+
*
|
|
4
|
+
* No API key required. Uses the same `claude` binary that the operator already
|
|
5
|
+
* has installed. Lean flags cut cost ~28x by disabling MCP and tools.
|
|
6
|
+
*
|
|
7
|
+
* Exports:
|
|
8
|
+
* - resolveClaudeBin() → string | null (abs path or null; re-reads config each call)
|
|
9
|
+
* - parseResult(stdout) → string (pure; throws on bad envelope)
|
|
10
|
+
* - complete(prompt, { model }) → Promise<string>
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import fs from 'node:fs';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
import os from 'node:os';
|
|
16
|
+
import { execFileSync, spawn } from 'node:child_process';
|
|
17
|
+
|
|
18
|
+
import { readConfig } from './config.js';
|
|
19
|
+
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// Empty MCP config: written once at module init to a stable temp path so the
|
|
22
|
+
// --mcp-config flag always points at a valid (empty) file.
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
const EMPTY_MCP_PATH = path.join(os.tmpdir(), 'claude-control-empty-mcp.json');
|
|
25
|
+
|
|
26
|
+
function ensureEmptyMcpConfig() {
|
|
27
|
+
if (!fs.existsSync(EMPTY_MCP_PATH)) {
|
|
28
|
+
fs.writeFileSync(EMPTY_MCP_PATH, '{"mcpServers":{}}', { mode: 0o600 });
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
ensureEmptyMcpConfig();
|
|
34
|
+
} catch {
|
|
35
|
+
// Non-fatal: complete() will fail if the path is missing, which is fine.
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
// Binary resolution — re-reads config each call so tests can control it via
|
|
40
|
+
// writeConfig({ claudeBin: ... }) without module-level memoization.
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Resolve the absolute path of the claude CLI binary.
|
|
45
|
+
* Resolution order:
|
|
46
|
+
* 1. config.claudeBin if set and exists
|
|
47
|
+
* 2. `which claude` result if exists
|
|
48
|
+
* 3. Common installation paths, first that exists
|
|
49
|
+
*
|
|
50
|
+
* Re-reads config each call (cheap; avoids memoization that breaks tests).
|
|
51
|
+
*
|
|
52
|
+
* @returns {string | null}
|
|
53
|
+
*/
|
|
54
|
+
export function resolveClaudeBin() {
|
|
55
|
+
const config = readConfig();
|
|
56
|
+
|
|
57
|
+
// 1. Explicit config override
|
|
58
|
+
if (config.claudeBin && typeof config.claudeBin === 'string' && config.claudeBin.trim()) {
|
|
59
|
+
const p = config.claudeBin.trim();
|
|
60
|
+
if (fs.existsSync(p)) return p;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// 2. `which claude`
|
|
64
|
+
try {
|
|
65
|
+
const found = execFileSync('which', ['claude'], { encoding: 'utf8' }).trim();
|
|
66
|
+
if (found && fs.existsSync(found)) return found;
|
|
67
|
+
} catch {
|
|
68
|
+
// not on PATH
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// 3. Common paths
|
|
72
|
+
const candidates = [
|
|
73
|
+
path.join(os.homedir(), '.local', 'bin', 'claude'),
|
|
74
|
+
'/opt/homebrew/bin/claude',
|
|
75
|
+
'/usr/local/bin/claude',
|
|
76
|
+
'/usr/bin/claude',
|
|
77
|
+
];
|
|
78
|
+
for (const p of candidates) {
|
|
79
|
+
if (fs.existsSync(p)) return p;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
// Envelope parser — pure, no I/O, fully testable without spawning.
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Parse the JSON stdout envelope from `claude -p ... --output-format json`.
|
|
91
|
+
* Throws on malformed JSON, is_error:true, or missing .result.
|
|
92
|
+
*
|
|
93
|
+
* Expected envelope:
|
|
94
|
+
* { type: 'result', subtype: 'success', is_error: false, result: string, ... }
|
|
95
|
+
*
|
|
96
|
+
* @param {string} stdout
|
|
97
|
+
* @returns {string}
|
|
98
|
+
*/
|
|
99
|
+
export function parseResult(stdout) {
|
|
100
|
+
let parsed;
|
|
101
|
+
try {
|
|
102
|
+
parsed = JSON.parse(stdout);
|
|
103
|
+
} catch (err) {
|
|
104
|
+
throw new Error(`claude CLI: invalid JSON in stdout: ${err.message}`);
|
|
105
|
+
}
|
|
106
|
+
if (parsed && parsed.is_error === true) {
|
|
107
|
+
throw new Error(`claude CLI: is_error=true: ${parsed.result ?? '(no message)'}`);
|
|
108
|
+
}
|
|
109
|
+
if (!parsed || typeof parsed.result !== 'string') {
|
|
110
|
+
throw new Error('claude CLI: missing .result in envelope');
|
|
111
|
+
}
|
|
112
|
+
return parsed.result;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ---------------------------------------------------------------------------
|
|
116
|
+
// complete — spawn the CLI and return the result string.
|
|
117
|
+
// ---------------------------------------------------------------------------
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Run a prompt through the Claude CLI and return the text result.
|
|
121
|
+
*
|
|
122
|
+
* @param {string} prompt
|
|
123
|
+
* @param {{ model?: string }} [opts]
|
|
124
|
+
* @returns {Promise<string>}
|
|
125
|
+
*/
|
|
126
|
+
export function complete(prompt, { model } = {}) {
|
|
127
|
+
return new Promise((resolve, reject) => {
|
|
128
|
+
const bin = resolveClaudeBin();
|
|
129
|
+
if (!bin) {
|
|
130
|
+
return reject(new Error('claude CLI not found'));
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
ensureEmptyMcpConfig();
|
|
134
|
+
|
|
135
|
+
const resolvedModel = model ?? readConfig().optimizeModel ?? 'claude-haiku-4-5';
|
|
136
|
+
|
|
137
|
+
// Lean flags: -p (print mode), --output-format json, no tools, empty MCP.
|
|
138
|
+
// Prompt is passed as a direct argv element — never shell-interpolated.
|
|
139
|
+
const args = [
|
|
140
|
+
'-p', prompt,
|
|
141
|
+
'--model', resolvedModel,
|
|
142
|
+
'--output-format', 'json',
|
|
143
|
+
'--strict-mcp-config',
|
|
144
|
+
'--mcp-config', EMPTY_MCP_PATH,
|
|
145
|
+
'--allowed-tools', '',
|
|
146
|
+
];
|
|
147
|
+
|
|
148
|
+
const child = spawn(bin, args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
149
|
+
const stdoutChunks = [];
|
|
150
|
+
const stderrChunks = [];
|
|
151
|
+
|
|
152
|
+
child.stdout.on('data', (chunk) => stdoutChunks.push(chunk));
|
|
153
|
+
child.stderr.on('data', (chunk) => stderrChunks.push(chunk));
|
|
154
|
+
|
|
155
|
+
child.on('close', (code) => {
|
|
156
|
+
if (code !== 0) {
|
|
157
|
+
const stderrText = Buffer.concat(stderrChunks).toString('utf8').slice(0, 300);
|
|
158
|
+
return reject(new Error(`claude CLI exited ${code}: ${stderrText}`));
|
|
159
|
+
}
|
|
160
|
+
const stdout = Buffer.concat(stdoutChunks).toString('utf8');
|
|
161
|
+
try {
|
|
162
|
+
resolve(parseResult(stdout));
|
|
163
|
+
} catch (err) {
|
|
164
|
+
reject(err);
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
child.on('error', (err) => reject(err));
|
|
169
|
+
});
|
|
170
|
+
}
|
package/lib/config.js
CHANGED
|
@@ -6,6 +6,13 @@
|
|
|
6
6
|
* overridable to a shell alias like `yolo` or `claude --flags`) and the
|
|
7
7
|
* default cwd new sessions start in.
|
|
8
8
|
*
|
|
9
|
+
* Also holds prompt-optimiser settings:
|
|
10
|
+
* - optimizeModel: the Claude model used for LLM-based prompt optimisation
|
|
11
|
+
* (default 'claude-haiku-4-5').
|
|
12
|
+
* - claudeBin: optional absolute path to the claude CLI binary. Empty string
|
|
13
|
+
* means auto-resolve (resolveClaudeBin() in lib/claude-cli.js tries PATH,
|
|
14
|
+
* then common install locations).
|
|
15
|
+
*
|
|
9
16
|
* Persisted at ~/.claude-control/config.json (honour CLAUDE_CONTROL_DATA when
|
|
10
17
|
* set, matching server.js's env-override convention). Reads never throw —
|
|
11
18
|
* defaults are merged over whatever's on disk. Writes validate strictly and
|
|
@@ -32,12 +39,16 @@ function configPath() {
|
|
|
32
39
|
}
|
|
33
40
|
|
|
34
41
|
const LAUNCH_MAX = 500;
|
|
42
|
+
const OPTIMIZE_MODEL_MAX = 200;
|
|
43
|
+
const CLAUDE_BIN_MAX = 500;
|
|
35
44
|
|
|
36
45
|
/** Defaults, recomputed each call so a changed HOME/env is honoured. */
|
|
37
46
|
function defaults() {
|
|
38
47
|
return {
|
|
39
48
|
launchCommand: 'claude',
|
|
40
49
|
defaultCwd: os.homedir(),
|
|
50
|
+
optimizeModel: 'claude-haiku-4-5',
|
|
51
|
+
claudeBin: '',
|
|
41
52
|
};
|
|
42
53
|
}
|
|
43
54
|
|
|
@@ -45,7 +56,7 @@ function defaults() {
|
|
|
45
56
|
* Read the persisted config, merged over defaults. Never throws — a missing,
|
|
46
57
|
* empty, or corrupt file falls back to defaults. Only known keys are surfaced.
|
|
47
58
|
*
|
|
48
|
-
* @returns {{ launchCommand: string, defaultCwd: string }}
|
|
59
|
+
* @returns {{ launchCommand: string, defaultCwd: string, optimizeModel: string, claudeBin: string }}
|
|
49
60
|
*/
|
|
50
61
|
export function readConfig() {
|
|
51
62
|
const base = defaults();
|
|
@@ -65,6 +76,14 @@ export function readConfig() {
|
|
|
65
76
|
typeof parsed.defaultCwd === 'string' && parsed.defaultCwd.trim()
|
|
66
77
|
? parsed.defaultCwd
|
|
67
78
|
: base.defaultCwd,
|
|
79
|
+
optimizeModel:
|
|
80
|
+
typeof parsed.optimizeModel === 'string' && parsed.optimizeModel.trim()
|
|
81
|
+
? parsed.optimizeModel
|
|
82
|
+
: base.optimizeModel,
|
|
83
|
+
claudeBin:
|
|
84
|
+
typeof parsed.claudeBin === 'string'
|
|
85
|
+
? parsed.claudeBin
|
|
86
|
+
: base.claudeBin,
|
|
68
87
|
};
|
|
69
88
|
}
|
|
70
89
|
|
|
@@ -75,9 +94,12 @@ export function readConfig() {
|
|
|
75
94
|
* Validation:
|
|
76
95
|
* - launchCommand: non-empty string, ≤500 chars.
|
|
77
96
|
* - defaultCwd: a path that exists and is a directory.
|
|
97
|
+
* - optimizeModel: non-empty string, ≤200 chars.
|
|
98
|
+
* - claudeBin: string ≤500 chars; empty string is allowed (means auto-resolve).
|
|
99
|
+
* Existence is NOT verified at write time (path may differ across hosts).
|
|
78
100
|
*
|
|
79
|
-
* @param {{ launchCommand?: unknown, defaultCwd?: unknown }} partial
|
|
80
|
-
* @returns {{ launchCommand: string, defaultCwd: string }} the saved config
|
|
101
|
+
* @param {{ launchCommand?: unknown, defaultCwd?: unknown, optimizeModel?: unknown, claudeBin?: unknown }} partial
|
|
102
|
+
* @returns {{ launchCommand: string, defaultCwd: string, optimizeModel: string, claudeBin: string }} the saved config
|
|
81
103
|
*/
|
|
82
104
|
export function writeConfig(partial = {}) {
|
|
83
105
|
const current = readConfig();
|
|
@@ -111,6 +133,28 @@ export function writeConfig(partial = {}) {
|
|
|
111
133
|
next.defaultCwd = cwd;
|
|
112
134
|
}
|
|
113
135
|
|
|
136
|
+
if (partial.optimizeModel !== undefined) {
|
|
137
|
+
const model = partial.optimizeModel;
|
|
138
|
+
if (typeof model !== 'string' || !model.trim()) {
|
|
139
|
+
throw new Error('optimizeModel must be a non-empty string');
|
|
140
|
+
}
|
|
141
|
+
if (model.length > OPTIMIZE_MODEL_MAX) {
|
|
142
|
+
throw new Error(`optimizeModel must be ≤${OPTIMIZE_MODEL_MAX} characters`);
|
|
143
|
+
}
|
|
144
|
+
next.optimizeModel = model;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (partial.claudeBin !== undefined) {
|
|
148
|
+
const bin = partial.claudeBin;
|
|
149
|
+
if (typeof bin !== 'string') {
|
|
150
|
+
throw new Error('claudeBin must be a string');
|
|
151
|
+
}
|
|
152
|
+
if (bin.length > CLAUDE_BIN_MAX) {
|
|
153
|
+
throw new Error(`claudeBin must be ≤${CLAUDE_BIN_MAX} characters`);
|
|
154
|
+
}
|
|
155
|
+
next.claudeBin = bin;
|
|
156
|
+
}
|
|
157
|
+
|
|
114
158
|
const dir = dataDir();
|
|
115
159
|
fs.mkdirSync(dir, { recursive: true });
|
|
116
160
|
fs.writeFileSync(configPath(), JSON.stringify(next, null, 2), { mode: 0o600 });
|
package/lib/match.js
CHANGED
|
@@ -123,10 +123,23 @@ export function assignTranscripts(panes, candidates, opts = {}) {
|
|
|
123
123
|
}
|
|
124
124
|
|
|
125
125
|
// Pass 3 — most-recently-active remaining candidate.
|
|
126
|
+
// Gate: when the pane's process start time is known, only consider candidates
|
|
127
|
+
// whose last known activity (lastActivityMs, falling back to file mtime or
|
|
128
|
+
// birthtime) is at or after the pane started (minus startSlackMs). A transcript
|
|
129
|
+
// that was never touched after the pane launched cannot belong to it — that is
|
|
130
|
+
// the "fresh pane inherits old transcript" bug. When procStartMs is unknown,
|
|
131
|
+
// skip the gate so we don't regress panes with missing timing data.
|
|
132
|
+
// NOTE: --resume is safe: Claude appends a record to the old transcript on
|
|
133
|
+
// resume, bumping its mtime/lastActivityMs above the pane's start time.
|
|
126
134
|
for (const pane of ordered) {
|
|
127
135
|
if (result.has(pane.target)) continue;
|
|
128
136
|
let best = null;
|
|
129
137
|
for (const c of available(pane)) {
|
|
138
|
+
// Apply temporal gate only when pane start time is known.
|
|
139
|
+
if (pane.procStartMs != null) {
|
|
140
|
+
const candActive = c.lastActivityMs ?? c.mtime ?? c.birthtimeMs ?? null;
|
|
141
|
+
if (candActive != null && candActive < pane.procStartMs - startSlackMs) continue;
|
|
142
|
+
}
|
|
130
143
|
if (!best || (c.lastActivityMs ?? 0) > (best.lastActivityMs ?? 0)) best = c;
|
|
131
144
|
}
|
|
132
145
|
if (best) claim(pane, best);
|