@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/bin/claude-print-bridge.mjs +247 -0
- package/lib/claude-print.js +352 -0
- package/lib/codex-rpc.js +719 -0
- package/lib/codex.js +639 -74
- package/lib/pane-registry.js +58 -10
- package/lib/prompt.js +60 -21
- package/lib/sessions.js +300 -63
- package/lib/subagents.js +113 -0
- package/lib/tmux.js +68 -11
- package/lib/transcribe.js +1 -1
- package/lib/tui.js +10 -3
- package/lib/version.js +44 -8
- package/package.json +1 -1
- package/server.js +561 -36
- package/web/dist/assets/{core-CpT6tRRG.js → core-BPDebW1g.js} +1 -1
- package/web/dist/assets/index-B3rIEzoc.css +1 -0
- package/web/dist/assets/index-DIwGyVZ7.js +104 -0
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-CjOcrKRX.css +0 -1
- package/web/dist/assets/index-CxhR0MPg.js +0 -103
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 [
|
|
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
|
|
487
|
-
*
|
|
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
|
-
|
|
496
|
-
|
|
497
|
-
//
|
|
498
|
-
|
|
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
|
-
|
|
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
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
|
|
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
|
-
|
|
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.
|
|
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": [
|