@idl3/claude-control 1.1.0 → 1.4.3

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/tmux.js CHANGED
@@ -184,6 +184,9 @@ const FORMAT = [
184
184
  '#{pane_active}',
185
185
  '#{pane_id}',
186
186
  '#{@cc_shell}',
187
+ '#{@cc_agent}',
188
+ '#{@cc_transport}',
189
+ '#{@cc_endpoint}',
187
190
  ].join(SEP);
188
191
 
189
192
  /**
@@ -229,7 +232,23 @@ export async function listPanes() {
229
232
  const parts = trimmed.split(SEP);
230
233
  if (parts.length < 9) continue;
231
234
 
232
- const [sessionName, rawIndex, windowName, rawActive, rawPid, cwd, cmd, windowId, rawPane, rawPaneActive, paneId, ccShell] = parts;
235
+ const [
236
+ sessionName,
237
+ rawIndex,
238
+ windowName,
239
+ rawActive,
240
+ rawPid,
241
+ cwd,
242
+ cmd,
243
+ windowId,
244
+ rawPane,
245
+ rawPaneActive,
246
+ paneId,
247
+ ccShell,
248
+ ccAgent,
249
+ ccTransport,
250
+ ccEndpoint,
251
+ ] = parts;
233
252
  const windowIndex = Number(rawIndex);
234
253
  const panePid = Number(rawPid);
235
254
  const paneIndex = Number(rawPane) || 0;
@@ -248,6 +267,9 @@ export async function listPanes() {
248
267
  paneIndex,
249
268
  paneId: paneId ?? null, // stable tmux %N — joins to $TMUX_PANE from the hook
250
269
  ccShell: ccShell === '1', // sister shell pane created for the composer >_
270
+ ccAgent: ccAgent || null,
271
+ ccTransport: ccTransport || null,
272
+ ccEndpoint: ccEndpoint || null,
251
273
  });
252
274
  }
253
275
 
@@ -379,7 +401,7 @@ export async function createWindow({ cwd, name } = {}, { _run, _listPanes } = {}
379
401
 
380
402
  // A server exists — create the window in the first existing session and read
381
403
  // back its "session:window" target via the -P/-F print format.
382
- const targetSession = windows[0].sessionName;
404
+ const targetSession = `${windows[0].sessionName}:`;
383
405
  const args = [
384
406
  'new-window',
385
407
  '-t', targetSession,
@@ -482,20 +504,49 @@ export async function renameWindow(target, name) {
482
504
  // Send text (literal, with Enter)
483
505
  // ---------------------------------------------------------------------------
484
506
 
507
+ /** Monotonic suffix so concurrent sends never collide on a tmux buffer name. */
508
+ let _pasteBufferSeq = 0;
509
+
485
510
  /**
486
- * Send literal text to a tmux pane and then press Enter.
487
- * Uses `-l` so tmux does not interpret key names.
511
+ * Send text to a tmux pane as an atomic bracketed paste, then press Enter.
512
+ *
513
+ * Why not `send-keys -l text` + `send-keys Enter`: against a TUI (Claude/Codex,
514
+ * Ink-based) those two execs race — the Enter can land before the app has
515
+ * ingested the literal bytes, so the message sits unsent in the input box.
516
+ * Instead we stage the text in a tmux paste buffer and `paste-buffer -p`
517
+ * (bracketed paste), which the TUI ingests as one atomic unit, wait a short
518
+ * settle, THEN send a real Enter to submit. Bracketed paste also stops embedded
519
+ * newlines from prematurely submitting.
520
+ *
521
+ * Falls back to the old literal `send-keys` path if the buffer route errors, so
522
+ * behaviour never regresses below today's baseline.
488
523
  *
489
524
  * @param {string} target e.g. "0:3"
490
525
  * @param {string} text
526
+ * @param {{ _run?: Function, _delay?: Function }} [_injected] Test-only seam.
491
527
  * @returns {Promise<void>}
492
528
  */
493
- export async function sendText(target, text) {
529
+ export async function sendText(target, text, { _run, _delay } = {}) {
494
530
  assertTarget(target);
495
- // Step 1: literal text (no key interpretation)
496
- await runTmux(['send-keys', '-t', target, '-l', text]);
497
- // Step 2: press Enter
498
- await runTmux(['send-keys', '-t', target, 'Enter']);
531
+ const runner = _run ?? runTmux;
532
+ const delay = _delay ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
533
+ const SETTLE_MS = 90; // give the TUI time to commit the paste before Enter
534
+
535
+ const bufName = `cc-paste-${process.pid}-${_pasteBufferSeq++}`;
536
+ try {
537
+ // Stage the text in a named buffer (data passed as argv — no shell, no stdin).
538
+ await runner(['set-buffer', '-b', bufName, text]);
539
+ // Bracketed paste into the pane (-p), deleting the buffer after (-d).
540
+ await runner(['paste-buffer', '-d', '-p', '-b', bufName, '-t', target]);
541
+ // Let the paste land in the editable buffer before submitting.
542
+ await delay(SETTLE_MS);
543
+ await runner(['send-keys', '-t', target, 'Enter']);
544
+ } catch {
545
+ // Fallback to the legacy literal path (also clean up a possibly-orphaned buffer).
546
+ await runner(['delete-buffer', '-b', bufName]).catch(() => {});
547
+ await runner(['send-keys', '-t', target, '-l', text]);
548
+ await runner(['send-keys', '-t', target, 'Enter']);
549
+ }
499
550
  }
500
551
 
501
552
  /**
@@ -571,7 +622,7 @@ export async function sendRawKeysSequenced(target, keys, delayMs = 160) {
571
622
  * @param {{ _run?: Function }} [_injected] Test-only seam; omit in production.
572
623
  * @returns {Promise<string>}
573
624
  */
574
- export async function capturePane(target, lines = 40, escapes = false, join = false, { _run } = {}) {
625
+ export async function capturePane(target, lines = 40, escapes = false, join = false, { _run, visibleOnly = false } = {}) {
575
626
  assertTarget(target);
576
627
  const runner = _run ?? runTmux;
577
628
  const args = ['capture-pane', '-t', target, '-p'];
@@ -582,7 +633,13 @@ export async function capturePane(target, lines = 40, escapes = false, join = fa
582
633
  // `-J` rejoins soft-wrapped lines into logical lines so that a URL split
583
634
  // across narrow pane columns is reconstructed before the client linkifies it.
584
635
  if (join) args.push('-J');
585
- args.push('-S', `-${lines}`); // start N lines above the visible area
636
+ // visibleOnly: capture ONLY the on-screen pane (no `-S`), never scrollback.
637
+ // Prompt/question detection MUST use this — a `-S -N` window pulls in an
638
+ // already-answered picker that scrolled into history (frozen WITH its ❯ cursor
639
+ // + "esc to cancel" footer), which re-fires the prompt after it was answered
640
+ // and lets stray numbered prose in history look like a live menu. The active
641
+ // picker always renders on the visible screen, so visible-only is sufficient.
642
+ if (!visibleOnly) args.push('-S', `-${lines}`); // start N lines above the visible area
586
643
  const { stdout } = await runner(args);
587
644
  return stdout;
588
645
  }
package/lib/transcribe.js CHANGED
@@ -197,6 +197,6 @@ export async function transcribe(inputPath, { lang, _resolvers, _run } = {}) {
197
197
  ]);
198
198
  return cleanTranscript(stdout);
199
199
  } finally {
200
- fs.promises.unlink(wav).catch(() => {});
200
+ await fs.promises.unlink(wav).catch(() => {});
201
201
  }
202
202
  }
package/lib/tui.js CHANGED
@@ -29,10 +29,14 @@ const MODEL_RE = /\b(Opus|Sonnet|Haiku)\s+[\d.]+(?:\s*\([^)]*\))?/i;
29
29
  const THINKING_RE = /esc to interrupt/i;
30
30
  const WORKING_TIMER_RE = /…\s*\(\s*\d+\s*[smh]\b/;
31
31
  const THINKING_SCAN_LINES = 8;
32
+ // Auto/manual compaction renders a distinct working line ("Compacting
33
+ // conversation…"). It can run for many seconds with no other output, so without
34
+ // a dedicated signal the UI looks hung. Treated as a busy sub-state of thinking.
35
+ const COMPACTING_RE = /compacting\b/i;
32
36
 
33
37
  /**
34
38
  * @param {string} capture raw `tmux capture-pane -p` output (ANSI ok)
35
- * @returns {{ ctxPct: number|null, model: string|null, thinking: boolean }}
39
+ * @returns {{ ctxPct: number|null, model: string|null, thinking: boolean, compacting: boolean }}
36
40
  */
37
41
  export function parseTuiStatus(capture) {
38
42
  const text = String(capture || '').replace(ANSI_RE, '');
@@ -53,9 +57,12 @@ export function parseTuiStatus(capture) {
53
57
  // area) do not produce a false positive after generation ends.
54
58
  const lines = text.split('\n');
55
59
  const tail = lines.slice(-THINKING_SCAN_LINES).join('\n');
56
- const thinking = THINKING_RE.test(tail) || WORKING_TIMER_RE.test(tail);
60
+ const compacting = COMPACTING_RE.test(tail);
61
+ // Compaction IS a busy state — fold it into thinking so the rail still reads
62
+ // "working" even if the compaction line omits "esc to interrupt".
63
+ const thinking = compacting || THINKING_RE.test(tail) || WORKING_TIMER_RE.test(tail);
57
64
 
58
- return { ctxPct, model, thinking };
65
+ return { ctxPct, model, thinking, compacting };
59
66
  }
60
67
 
61
68
  /**
package/lib/version.js CHANGED
@@ -38,27 +38,63 @@ async function git(args) {
38
38
 
39
39
  let cache = { info: null, checkedAt: 0 };
40
40
 
41
+ async function checkoutIdentity() {
42
+ const identity = {
43
+ root: ROOT,
44
+ branch: null,
45
+ commit: null,
46
+ dirty: null,
47
+ };
48
+ try {
49
+ const branch = await git(['rev-parse', '--abbrev-ref', 'HEAD']);
50
+ identity.branch = branch && branch !== 'HEAD' ? branch : null;
51
+ } catch {
52
+ // not a git checkout
53
+ }
54
+ try {
55
+ identity.commit = await git(['rev-parse', '--short', 'HEAD']);
56
+ } catch {
57
+ // not a git checkout
58
+ }
59
+ try {
60
+ identity.dirty = (await git(['status', '--porcelain'])).length > 0;
61
+ } catch {
62
+ // not a git checkout
63
+ }
64
+ return identity;
65
+ }
66
+
41
67
  /**
42
- * { current, latest, behind, updateAvailable }.
68
+ * { current, root, branch, commit, dirty, latest, behind, updateAvailable }.
43
69
  * - behind: commits on origin/<branch> not in HEAD.
44
70
  * - latest: version field of origin's package.json (may equal current if the
45
71
  * upstream bumped commits without bumping the version).
46
72
  */
47
73
  export async function getVersionInfo({ force = false, now = Date.now() } = {}) {
48
74
  const current = currentVersion();
75
+ const identity = await checkoutIdentity();
49
76
  if (!force && cache.info && now - cache.checkedAt < REFRESH_MS) {
50
- return { current, ...cache.info };
77
+ return { current, ...identity, ...cache.info };
51
78
  }
52
79
 
53
80
  let behind = 0;
54
81
  let latest = null;
82
+ // "dev mode" — running from a working codebase, not a clean install. A normal
83
+ // user's checkout is pristine and only ever BEHIND origin; a developer's has
84
+ // local edits (dirty tree) or unpushed commits (ahead). In dev mode we never
85
+ // nag about updates. An explicit CLAUDE_CONTROL_DEV=1 forces it.
86
+ let dev = process.env.CLAUDE_CONTROL_DEV === '1';
55
87
  try {
56
- const branch = await git(['rev-parse', '--abbrev-ref', 'HEAD']);
57
- await git(['fetch', '--quiet', 'origin', branch]);
58
- behind = parseInt(await git(['rev-list', '--count', `HEAD..origin/${branch}`]), 10) || 0;
88
+ if (!identity.branch) throw new Error('detached HEAD');
89
+ await git(['fetch', '--quiet', 'origin', identity.branch]);
90
+ behind = parseInt(await git(['rev-list', '--count', `HEAD..origin/${identity.branch}`]), 10) || 0;
91
+ if (!dev) {
92
+ const ahead = parseInt(await git(['rev-list', '--count', `origin/${identity.branch}..HEAD`]), 10) || 0;
93
+ dev = !!identity.dirty || ahead > 0;
94
+ }
59
95
  if (behind > 0) {
60
96
  try {
61
- latest = JSON.parse(await git(['show', `origin/${branch}:package.json`])).version || null;
97
+ latest = JSON.parse(await git(['show', `origin/${identity.branch}:package.json`])).version || null;
62
98
  } catch {
63
99
  latest = null;
64
100
  }
@@ -67,7 +103,7 @@ export async function getVersionInfo({ force = false, now = Date.now() } = {}) {
67
103
  // not a git checkout / no origin / offline — treat as up to date.
68
104
  }
69
105
 
70
- const info = { latest, behind, updateAvailable: behind > 0 };
106
+ const info = { latest, behind, dev, updateAvailable: behind > 0 && !dev };
71
107
  cache = { info, checkedAt: now };
72
- return { current, ...info };
108
+ return { current, ...identity, ...info };
73
109
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@idl3/claude-control",
3
- "version": "1.1.0",
3
+ "version": "1.4.3",
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": [