@idl3/claude-control 0.1.16 → 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 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 = navigate Down to each chosen index, press Space, then Enter.
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: navigate Down to each chosen option (top-to-bottom, so a
16
- // monotonic run of Downs) pressing Space to toggle it, then press Enter to
17
- // confirm the question and advance/submit.
18
- // - There is no separate "Submit" step: the final question's Enter submits the
19
- // whole picker. (The old `Right`-to-Submit-tab + `'1'` model was stale.)
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: walk down through the chosen options in order, toggling each
66
- // with Space; the cursor starts at option 0, so move only the delta between
67
- // successive targets. A trailing Enter confirms the question.
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);