cckeep 0.2.3 → 0.4.0

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.ja.md CHANGED
@@ -59,12 +59,13 @@ asdf reshim nodejs # asdf
59
59
 
60
60
  ここがこのツールの設計上いちばん難しいところです。タイマーでターミナルに文字を打ち込むツールは、「いま打っても安全か」を確実に判断できない限り、ただ危険なだけになります。以下はすべて実装済みで、[テスト](test/)で守られています。
61
61
 
62
- - **ターンの実行中は打たない。** ペインを2秒あけて2回キャプチャします。実行中はスピナーとトークン数が動くので、2回が同じなら何もしていない証拠です。違っていれば手を出しません
63
- - **ダイアログには打たない。** 権限プロンプトや選択メニューでは、Enter が「選択」になってしまいます。選択マーカーが画面にあれば、その回はまるごと見送ります
64
- - **自分で開いたパネルには触らない。** `/remote-control` QR コード付きのステータスパネルを開きます。cckeep Enter を押すのは、自分でパネルを開いたときだけです
65
- - **自分で切った接続は戻さない。** 一度でも接続できていたペインだけを対象にします。意図して切ったものは切れたままです
66
- - **連打しない。** 同じペインに手を出すのは5分に1回までです
67
- - **送る直前にもう一度確かめる。** 判断は1回のキャプチャで下しますが、待機したあとにもう一度確認します。その間に復帰していたり、ダイアログが出ていれば何も送りません
62
+ - **入力中の文字の上には打たない。** Enter は入力欄の内容をそのまま送信するので、書きかけの下書きがあるとコマンドがくっついて送信されてしまいます。入力欄に何か入っている場合、そもそも入力欄が見つからない場合は何も送りません。静止判定ではこれを防げません。下書きは動かないからです
63
+ - **ターンの実行中は打たない。** ペインを3回キャプチャします。実行中はスピナーとトークン数が動くので、3回とも同じなら何もしていない証拠です。2回ではなく3回、しかも間隔をあえて半端な値にしているのは、周期が間隔を割り切るアニメーションだと同じ絵が並んで静止に見えてしまうためです
64
+ - **ダイアログには打たない。** 権限プロンプトでは Enter が「選択」になります。選択マーカーは画面のどこにあっても対象にしますが、英語の言い回し(`Do you want …` など)は入力欄の近くにある場合だけ対象にします。Claude Code は通常の返答でも同じ言い回しを使うためです
65
+ - **自分で開いたパネルには触らない。** `/remote-control` は QR コード付きのステータスパネルを開きます。cckeep が Enter を押すのは自分でパネルを開いたときだけで、押す直前にもパネルがまだ開いているかを確認します
66
+ - **話題に出しただけのセッションは対象にしない。** 状態の判定は画面末尾の数行だけから読み、会話本文からは読みません。`/rc active` について話しているセッションが接続中と誤認されることはありません。一度も接続を確認していないペインに手を出すのは、Claude Code 自身が「接続が切れた」と表示した場合だけです
67
+ - **連打しない。同時に2つ動かない。** 同じペインに手を出すのは5分に1回まで。さらにロックを取るので、`cckeep watch` とスケジュール実行が並走しても、2つの処理が混ざって壊れた入力になることはありません
68
+ - **送る直前にもう一度確かめる。** 判断は1回のキャプチャで下しますが、待機後にもう一度確認します。その間に復帰した、ダイアログが出た、何か入力された — いずれでも何も送りません。そしてそれまでの経過はリセットせず保持します
68
69
 
69
70
  `--dry-run` を付けると、送る内容を表示するだけで実際には何も送りません。
70
71
 
@@ -72,7 +73,7 @@ asdf reshim nodejs # asdf
72
73
 
73
74
  | 画面の状態 | 意味 | cckeep の動作 |
74
75
  |---|---|---|
75
- | `/rc active` | 接続できている | ペインを覚えるだけ |
76
+ | `/rc active`、または切り詰められた `/rc` | 接続できている | ペインを覚えるだけ |
76
77
  | `/rc reconnecting` | 31秒の再試行中 | 待つ(たいていはこれで戻る) |
77
78
  | `/rc reconnecting` が2分以上 | 固まっている([#34255](https://github.com/anthropics/claude-code/issues/34255)) | パネルを開いて切断し、繋ぎ直す |
78
79
  | `Remote Control disconnected` | 諦めた | すぐに繋ぎ直す |
@@ -170,8 +171,9 @@ Ctrl+B の衝突については何もしなくて構いません。Claude Code
170
171
  - `missLimit`: 以前は表示があったペインが、何回続けて無表示なら繋ぎ直すか
171
172
  - `settle`: 静止判定に使う2回のキャプチャの間隔(ミリ秒)。遅いマシンでは増やしてください
172
173
  - `paneCommand`: Claude Code のペインだと判定するフォアグラウンドプロセス名
174
+ - `tmuxSocket`: 既定以外のサーバーで tmux を動かしている場合のソケット名またはパス(`tmux -L name` / `-S path`)。空なら既定サーバー
173
175
 
174
- どの項目にも環境変数版があります。`CCKEEP_INTERVAL`、`CCKEEP_COOLDOWN`、`CCKEEP_STUCK_LIMIT`、`CCKEEP_MISS_LIMIT`、`CCKEEP_SETTLE`、`CCKEEP_PANE_COMMAND` です。`CCKEEP_HOME` を指定すれば、状態・設定・ログの置き場所を `~/.cckeep` 以外に移せます。
176
+ どの項目にも環境変数版があります。`CCKEEP_INTERVAL`、`CCKEEP_COOLDOWN`、`CCKEEP_STUCK_LIMIT`、`CCKEEP_MISS_LIMIT`、`CCKEEP_SETTLE`、`CCKEEP_PANE_COMMAND`、`CCKEEP_TMUX_SOCKET` です。`CCKEEP_HOME` を指定すれば、状態・設定・ログの置き場所を `~/.cckeep` 以外に移せます。
175
177
 
176
178
  ## 適用範囲
177
179
 
@@ -199,6 +201,8 @@ Claude Code はフッターにリモートコントロールの状態を表示
199
201
 
200
202
  ペインを探すところに一手間あります。Claude Code は自身のプロセスタイトルを書き換えるため、tmux はそのペインのコマンド名を `claude` ではなく `2.1.220` のように報告します。tmux の報告する名前だけで判定すると、実機では1つも見つかりません。そこで cckeep はプロセステーブルも参照し、ペインのプロセス(またはそこから起動されたプロセス)が実際に `claude` であればそのペインを対象とします。
201
203
 
204
+ インジケータは右寄せで描画されるため、カスタムステータスラインを使っていたりペインが狭かったりすると、`active` が削られて `/rc` だけになります。cckeep はこれを接続中として扱います。インジケータ自体は接続がある間しか描画されず、接続中と読んでもペインを記録して待つだけなので、안全な側に倒れるからです。
205
+
202
206
  「どこを見るか」も同じくらい重要です。状態の判定に使う文字列は画面末尾の十数行からしか読みません。これらの言葉は普通の会話にも出てくるので、たとえば `/rc active` について話しているだけのセッションが「接続中」に見えてしまうからです。逆にダイアログとステータスパネルの検出は画面全体を対象にしています。こちらは誤検知しても1回見送るだけで済みますが、見落とすと誤ったキー入力に直結します。
203
207
 
204
208
  判定層(`src/detect.js`)は、画面テキストと直前の状態だけを受け取る純粋関数です。だからこそ、安全ルールをターミナル無しで網羅的にテストできます。静止判定・直前の再確認・キー送信といった I/O は、実行層(`src/run.js`)が担当します。
package/README.md CHANGED
@@ -61,12 +61,13 @@ One requirement: **Claude Code has to be running inside tmux.** A session starte
61
61
 
62
62
  This is the whole design problem. A watchdog that types into your terminal on a timer is a liability unless it is certain the moment is safe. Every one of these is enforced, and [tested](test/):
63
63
 
64
- - **Never during a turn.** The pane is captured twice, two seconds apart. A running turn animates a spinner and a token counter, so identical captures mean nothing is happening. Different ones mean hands off.
65
- - **Never into a dialog.** Permission prompts and pickers turn Enter into a selection. If a selection marker is on screen, the pass is skipped.
66
- - **Never into the panel you opened.** `/remote-control` opens a status panel with a QR code. cckeep only presses Enter there when it opened the panel itself.
67
- - **Never a session you turned off.** Only panes seen connected at least once are ever chased. Disable Remote Control deliberately and it stays off.
68
- - **Never in a tight loop.** One action per pane per 5 minutes.
69
- - **Re-checked at the last moment.** The decision is made from one capture, then re-verified after the idle wait if the pane reconnected or opened a dialog in between, nothing is sent.
64
+ - **Never on top of what you typed.** Enter submits whatever is in the composer, so an unsent draft would go out with the command glued onto it. If anything is in the box or the box cannot be found at all — nothing is sent. The idle check cannot cover this: a draft sitting in the box is perfectly still.
65
+ - **Never during a turn.** The pane is captured three times. A running turn animates a spinner and a token counter, so identical captures mean nothing is happening. Three rather than two, at an interval that is deliberately not round, because any animation whose period divides the interval would otherwise alias into identical frames.
66
+ - **Never into a dialog.** Permission prompts turn Enter into a selection. A selection marker counts anywhere on screen; the plain English phrasings only count near the composer, since Claude Code writes sentences like "Do you want me to run the tests as well?" in ordinary replies.
67
+ - **Never into the panel you opened.** `/remote-control` opens a status panel with a QR code. cckeep only presses Enter there when it opened the panel itself, and only if the panel is still up when it goes to press it.
68
+ - **Never a session that only mentioned it.** The indicators are read from the last few rows, never from the transcript, so a session discussing `/rc active` is not mistaken for a connected one. A pane that has never been seen connected is only ever acted on when Claude Code itself prints that the link died.
69
+ - **Never in a tight loop, and never twice at once.** One action per pane per 5 minutes, and a lock so that `cckeep watch` running alongside the scheduled job cannot interleave two passes into one garbled prompt.
70
+ - **Re-checked at the last moment.** The decision is made from one capture, then re-verified after the wait — reconnected in between, dialog appeared, something typed? Nothing is sent, and the pane keeps the progress it had made rather than starting its wait over.
70
71
 
71
72
  `--dry-run` prints what it would do and sends nothing.
72
73
 
@@ -74,7 +75,7 @@ This is the whole design problem. A watchdog that types into your terminal on a
74
75
 
75
76
  | State on screen | What it means | What cckeep does |
76
77
  |---|---|---|
77
- | `/rc active` | connected | remembers the pane, nothing else |
78
+ | `/rc active`, or a truncated `/rc` | connected | remembers the pane, nothing else |
78
79
  | `/rc reconnecting` | inside the 31-second budget | waits — this usually resolves |
79
80
  | `/rc reconnecting`, 2 minutes on | wedged ([#34255](https://github.com/anthropics/claude-code/issues/34255)) | cycles the bridge: opens the panel, disconnects, reconnects |
80
81
  | `Remote Control disconnected` | gave up | re-arms immediately |
@@ -174,8 +175,9 @@ Defaults are tuned so you never notice it. Override in `~/.cckeep/config.json`,
174
175
  - `missLimit` — checks with no indicator before re-arming a pane that had one
175
176
  - `settle` — milliseconds between the two captures of the idle check; raise it on a slow machine
176
177
  - `paneCommand` — foreground process name that marks a pane as Claude Code
178
+ - `tmuxSocket` — socket name or path, if your tmux runs on something other than the default server (`tmux -L name` / `-S path`). Empty means the default
177
179
 
178
- Every key has an env twin: `CCKEEP_INTERVAL`, `CCKEEP_COOLDOWN`, `CCKEEP_STUCK_LIMIT`, `CCKEEP_MISS_LIMIT`, `CCKEEP_SETTLE`, `CCKEEP_PANE_COMMAND`. `CCKEEP_HOME` moves state, config and log off `~/.cckeep`.
180
+ Every key has an env twin: `CCKEEP_INTERVAL`, `CCKEEP_COOLDOWN`, `CCKEEP_STUCK_LIMIT`, `CCKEEP_MISS_LIMIT`, `CCKEEP_SETTLE`, `CCKEEP_PANE_COMMAND`, `CCKEEP_TMUX_SOCKET`. `CCKEEP_HOME` moves state, config and log off `~/.cckeep`.
179
181
 
180
182
  ## Scope
181
183
 
@@ -203,6 +205,8 @@ Claude Code paints a Remote Control indicator in its footer: `/rc active` when c
203
205
 
204
206
  Finding those panes takes one extra step: Claude Code rewrites its own process title, so tmux reports such a pane as `2.1.220` rather than `claude`. Matching the name tmux reports therefore finds nothing on a real machine. cckeep checks the process table as well, and treats a pane as Claude Code's when the pane's process — or anything it spawned — is actually `claude`.
205
207
 
208
+ The indicator is right-aligned, so a custom status line or a narrow pane squeezes it down to a bare `/rc` with the word cut off. cckeep treats that as connected: the indicator only renders while a link exists, and reading it as connected merely records the pane and waits.
209
+
206
210
  Where it looks matters as much as what it looks for. The state indicators are read from the last dozen lines only, because the words themselves turn up in ordinary conversation — a session where you happen to discuss `/rc active` would otherwise read as connected. Dialog and status-panel detection deliberately scans the whole pane instead: a false positive there costs one skipped pass, while a miss costs a keystroke in the wrong place.
207
211
 
208
212
  The decision layer (`src/detect.js`) is a pure function of screen text plus prior state, which is why the safety rules can be tested exhaustively without a terminal. The runner (`src/run.js`) does the I/O: the idle check, the last-moment re-check, and the keystrokes.
package/bin/cckeep.js CHANGED
@@ -7,7 +7,7 @@ import { logPath, statePath, homeDir } from '../src/state.js';
7
7
  import * as tmux from '../src/tmux.js';
8
8
  import * as scheduler from '../src/scheduler.js';
9
9
  import { pickLang, strings } from '../src/i18n.js';
10
- import { resolveCommand } from '../src/commands.js';
10
+ import { resolveCommand, isKnownCommand } from '../src/commands.js';
11
11
  import { parsePsTable, isTargetPane } from '../src/procs.js';
12
12
 
13
13
  const HELP = `cckeep — keep Claude Code Remote Control from silently going dead
@@ -41,16 +41,33 @@ into the ones that went dead — never into one that is busy or showing a dialog
41
41
  `;
42
42
 
43
43
  function parseArgs(argv) {
44
- const opts = { command: null, dryRun: false, json: false, lang: null, interval: null };
44
+ const opts = { command: null, dryRun: false, json: false, lang: null, interval: null, error: null };
45
+ const fail = (msg) => {
46
+ if (!opts.error) opts.error = msg;
47
+ };
48
+ // Unknown flags used to be skipped in silence, so `--dry-runn` performed real
49
+ // sends into live panes. The one flag that prevents sending must never fail
50
+ // open on a typo.
45
51
  for (let i = 0; i < argv.length; i++) {
46
52
  const a = argv[i];
47
53
  if (a === '--dry-run') opts.dryRun = true;
48
54
  else if (a === '--json') opts.json = true;
49
- else if (a === '--lang') opts.lang = argv[++i];
50
- else if (a === '--interval') opts.interval = Number(argv[++i]);
51
- else if (a === '-h' || a === '--help') opts.command = 'help';
55
+ else if (a === '--lang') {
56
+ const v = argv[++i];
57
+ if (v === undefined || v.startsWith('-')) fail('--lang needs a value: en or ja');
58
+ else if (v !== 'en' && v !== 'ja') fail(`--lang must be en or ja, not "${v}"`);
59
+ else opts.lang = v;
60
+ } else if (a === '--interval') {
61
+ const v = argv[++i];
62
+ const n = Number(v);
63
+ if (v === undefined || v.startsWith('-')) fail('--interval needs a value in seconds');
64
+ else if (!Number.isInteger(n) || n < 1) fail(`--interval must be a whole number of seconds, not "${v}"`);
65
+ else opts.interval = n;
66
+ } else if (a === '-h' || a === '--help') opts.command = 'help';
52
67
  else if (a === '-v' || a === '--version') opts.command = 'version';
53
- else if (!a.startsWith('-') && !opts.command) opts.command = a;
68
+ else if (a.startsWith('-')) fail(`Unknown option: ${a}`);
69
+ else if (!opts.command) opts.command = a;
70
+ else fail(`Unexpected argument: ${a}`);
54
71
  }
55
72
  return opts;
56
73
  }
@@ -68,6 +85,7 @@ const REASON_KEY = {
68
85
  dialog: 'dialog',
69
86
  cooldown: 'cooldown',
70
87
  busy: 'busy',
88
+ 'composer-busy': 'composerBusy',
71
89
  recovered: 'recovered',
72
90
  };
73
91
 
@@ -106,6 +124,18 @@ async function main() {
106
124
  if (opts.command === 'help') return console.log(HELP);
107
125
  if (opts.command === 'version') return console.log(version());
108
126
 
127
+ if (opts.error) {
128
+ console.error(opts.error);
129
+ console.error('Run `cckeep --help` for the full list.');
130
+ process.exit(2);
131
+ }
132
+
133
+ if (opts.command !== null && !isKnownCommand(opts.command)) {
134
+ console.error(`Unknown command: ${opts.command}\n`);
135
+ console.log(HELP);
136
+ process.exit(1);
137
+ }
138
+
109
139
  let config;
110
140
  try {
111
141
  config = loadConfig(opts.interval ? { interval: opts.interval } : {});
@@ -114,6 +144,10 @@ async function main() {
114
144
  process.exit(1);
115
145
  }
116
146
 
147
+ // The socket and binary can only come from the config file, so hand them to
148
+ // the tmux layer before anything queries it.
149
+ tmux.configureTmux({ socket: config.tmuxSocket, binary: config.tmuxBinary });
150
+
117
151
  switch (resolveCommand(opts.command)) {
118
152
  case 'status': {
119
153
  const out = await runPass({ config, dryRun: true });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cckeep",
3
- "version": "0.2.3",
3
+ "version": "0.4.0",
4
4
  "description": "Keeps Claude Code Remote Control from silently going dead. Re-arms the sessions its 31-second retry budget gives up on — local, tmux-native, zero dependencies.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/config.js CHANGED
@@ -7,16 +7,25 @@ export const BASE = {
7
7
  ...DEFAULTS,
8
8
  /** Seconds between passes in watch mode, and what `enable` schedules. */
9
9
  interval: 15,
10
- /** Milliseconds between the two captures of the idle check. */
11
- settle: 2000,
10
+ /** Milliseconds between idle-check captures. Deliberately not a round
11
+ * number: a spinner whose period divides the interval would alias to
12
+ * identical frames and read as idle. */
13
+ settle: 1700,
12
14
  /** Milliseconds between typing the command and pressing Enter. */
13
15
  keyDelay: 1000,
14
16
  /** Foreground process name that marks a pane as Claude Code. */
15
17
  paneCommand: 'claude',
18
+ /** tmux socket name or path, for anyone not on the default server. */
19
+ tmuxSocket: '',
20
+ /** Absolute path to tmux, for installs outside the usual locations. */
21
+ tmuxBinary: '',
16
22
  };
17
23
 
18
24
  const NUMERIC = new Set(['stuckLimit', 'missLimit', 'cooldown', 'interval', 'settle', 'keyDelay']);
19
25
 
26
+ /** launchd and systemd both want whole seconds, and a fraction breaks the plist. */
27
+ const INTEGER = new Set(['interval', 'stuckLimit', 'missLimit']);
28
+
20
29
  const ENV = {
21
30
  CCKEEP_INTERVAL: 'interval',
22
31
  CCKEEP_COOLDOWN: 'cooldown',
@@ -25,6 +34,8 @@ const ENV = {
25
34
  CCKEEP_SETTLE: 'settle',
26
35
  CCKEEP_KEY_DELAY: 'keyDelay',
27
36
  CCKEEP_PANE_COMMAND: 'paneCommand',
37
+ CCKEEP_TMUX_SOCKET: 'tmuxSocket',
38
+ CCKEEP_TMUX: 'tmuxBinary',
28
39
  };
29
40
 
30
41
  export function configPath() {
@@ -51,9 +62,11 @@ export function loadConfig(overrides = {}) {
51
62
  }
52
63
 
53
64
  const merged = { ...BASE, ...fromFile, ...fromEnv, ...overrides };
65
+ if (Number(merged.interval) < 1) throw new Error('config: interval must be at least 1 second');
54
66
  for (const key of NUMERIC) {
55
67
  const value = Number(merged[key]);
56
68
  if (!Number.isFinite(value) || value < 0) throw new Error(`config: ${key} must be a non-negative number`);
69
+ if (INTEGER.has(key) && !Number.isInteger(value)) throw new Error(`config: ${key} must be a whole number`);
57
70
  merged[key] = value;
58
71
  }
59
72
  return merged;
package/src/detect.js CHANGED
@@ -3,19 +3,55 @@
3
3
  // so every rule below is directly testable, which matters a lot for a tool whose
4
4
  // failure mode is typing into someone's terminal at the wrong moment.
5
5
 
6
- /** Indicators Claude Code paints in the footer for the Remote Control link. */
7
- const CONNECTED = '/rc active';
8
- const RETRYING = '/rc reconnecting';
9
- const FAILED = /Remote Control disconnected|Remote Control failed|\/rc failed/;
6
+ /**
7
+ * The Remote Control indicator Claude Code right-aligns in its footer.
8
+ *
9
+ * It reads `/rc active`, `/rc reconnecting` or `/rc failed` when there is room
10
+ * — but it is right-aligned on the status line, so a custom statusline or a
11
+ * narrow pane truncates it to a bare `/rc`. Matching the full phrase therefore
12
+ * never fires for anyone with a status line of their own, which is common.
13
+ *
14
+ * Anchored to the end of the line because the indicator is right-aligned. A
15
+ * separator and link may follow it (`/rc active · claude.ai/code`), but prose
16
+ * may not — that is what keeps `/rc` written mid-sentence from matching.
17
+ */
18
+ const RC_INDICATOR = /(?:^|\s)\/rc(?:\s+(active|reconnecting|failed))?(?:\s*[\u00b7|].*)?\s*$/;
19
+
20
+ /** Lines a user types into; they may well contain `/rc` themselves. */
21
+ const INPUT_LINE = /^[❯>]/;
22
+
23
+ /** The notification Claude Code prints when it has given up for good. */
24
+ const FAILED_NOTICE = /Remote Control disconnected|Remote Control failed/;
10
25
 
11
26
  /** The /remote-control status panel — its first entry is "Disconnect this session". */
12
27
  const PANEL = /Disconnect this session|(?:Show|Hide) QR code/;
13
28
 
14
29
  /**
15
- * Anything that turns Enter or a slash command into a selection: permission
16
- * prompts, pickers, confirmations. Typing into one of these picks an option.
30
+ * Anything that turns Enter into a selection: permission prompts, pickers,
31
+ * confirmations. Typing into one of these picks an option.
32
+ *
33
+ * Split in two on purpose. A selection marker followed by an option is close to
34
+ * unambiguous, so it counts anywhere on screen. The bare English phrases are
35
+ * things Claude Code also writes in ordinary replies ("Do you want me to run
36
+ * the tests as well?"), and such a sentence sitting in the transcript forever
37
+ * would hold the pane off forever — so those only count near the composer,
38
+ * where a real dialog renders.
39
+ */
40
+ const MODAL_MARKER = /(?:^|\s)(?:❯|›|>)\s*(?:\d+\s*[.)]|Yes\b|No\b)/m;
41
+ const MODAL_PROMPT = /Do you want|Would you like|Allow\b[^\n]{0,40}\?|\(y\/n\)|\[y\/N\]/i;
42
+
43
+ /** How far up from the composer a dialog can plausibly render. */
44
+ const MODAL_LINES = 20;
45
+
46
+ /**
47
+ * The composer, and whether anything is typed in it.
48
+ *
49
+ * `send-keys` inserts at the cursor and Enter submits whatever is in the box,
50
+ * so an unsent draft would be submitted with `/remote-control` glued onto it —
51
+ * a text injection into the conversation *and* a failed reconnect. The idle
52
+ * check cannot catch this: a draft sitting in the box is perfectly still.
17
53
  */
18
- const MODAL = /(?:❯|›)\s*(?:\d+\.|Yes|No)|Do you want|\(y\/n\)/;
54
+ const COMPOSER = /^(?:│\s*)?(?:❯|>)\s?(.*?)\s*(?:│)?$/;
19
55
 
20
56
  /**
21
57
  * How many lines from the bottom count as "the footer".
@@ -28,6 +64,13 @@ const MODAL = /(?:❯|›)\s*(?:\d+\.|Yes|No)|Do you want|\(y\/n\)/;
28
64
  */
29
65
  const FOOTER_LINES = 12;
30
66
 
67
+ /**
68
+ * The indicator sits on the status line, one or two rows from the bottom — a
69
+ * tighter window than the notifications above it, so that `/rc` typed into the
70
+ * input box cannot be mistaken for it.
71
+ */
72
+ const INDICATOR_LINES = 4;
73
+
31
74
  export const DEFAULTS = {
32
75
  /** Consecutive checks stuck in "reconnecting" before we treat the bridge as wedged. */
33
76
  stuckLimit: 8,
@@ -45,22 +88,63 @@ export function emptyState() {
45
88
  * Reduce a captured pane to the handful of signals the rules care about.
46
89
  * @param {string} screen raw `tmux capture-pane -p` output
47
90
  */
91
+ /**
92
+ * Read the indicator out of the last few lines.
93
+ *
94
+ * Returns 'active' | 'reconnecting' | 'failed', or null when no indicator is on
95
+ * screen. A truncated `/rc` counts as 'active': the indicator only renders while
96
+ * a link exists, and reading it as connected merely records the pane and waits,
97
+ * which is the safe direction.
98
+ */
99
+ /**
100
+ * 'empty' | 'draft' | 'unknown'. Unknown means we could not find the composer,
101
+ * which is treated like a draft: if we cannot see the box, we do not type.
102
+ */
103
+ function readComposer(lines, window = INDICATOR_LINES + 4) {
104
+ for (const line of lines.slice(Math.max(0, lines.length - window)).reverse()) {
105
+ const m = line.match(COMPOSER);
106
+ if (m) return m[1].trim() === '' ? 'empty' : 'draft';
107
+ }
108
+ return 'unknown';
109
+ }
110
+
111
+ function readIndicator(lines, window = INDICATOR_LINES) {
112
+ for (const line of lines.slice(Math.max(0, lines.length - window))) {
113
+ if (INPUT_LINE.test(line.trim())) continue;
114
+ const m = line.match(RC_INDICATOR);
115
+ if (m) return m[1] ?? 'active';
116
+ }
117
+ return null;
118
+ }
119
+
48
120
  export function readScreen(screen, footerLines = FOOTER_LINES) {
49
- const lines = String(screen).split('\n');
121
+ // `capture-pane` returns the whole pane, blank rows included, so a UI that
122
+ // does not fill the pane leaves the bottom padded with empty lines. Scanning
123
+ // the last N lines would then scan nothing but padding and see no indicator
124
+ // at all — a silent failure. Measure the footer from the last row that has
125
+ // something on it.
126
+ const all = String(screen).split('\n');
127
+ let end = all.length;
128
+ while (end > 0 && all[end - 1].trim() === '') end -= 1;
129
+ const lines = all.slice(0, end);
130
+
50
131
  const footer = lines.slice(Math.max(0, lines.length - footerLines)).join('\n');
132
+ const modalWindow = lines.slice(Math.max(0, lines.length - MODAL_LINES)).join('\n');
133
+ const indicator = readIndicator(lines);
51
134
 
52
135
  return {
53
136
  // Signals that make cckeep act are read from the footer only, so text in
54
137
  // the conversation cannot trigger anything.
55
- connected: footer.includes(CONNECTED),
56
- retrying: footer.includes(RETRYING),
57
- failed: FAILED.test(footer),
58
-
59
- // Signals that make cckeep hold off are read from the whole pane. A false
60
- // positive here costs a skipped pass; missing one costs a keystroke in the
61
- // wrong place.
62
- panel: PANEL.test(screen),
63
- modal: MODAL.test(screen),
138
+ connected: indicator === 'active',
139
+ retrying: indicator === 'reconnecting',
140
+ failed: indicator === 'failed' || FAILED_NOTICE.test(footer),
141
+
142
+ // Signals that make cckeep hold off. A false positive here costs a skipped
143
+ // pass; missing one costs a keystroke in the wrong place.
144
+ panel: PANEL.test(footer),
145
+ modal: MODAL_MARKER.test(lines.join('\n')) || MODAL_PROMPT.test(modalWindow),
146
+
147
+ composer: readComposer(lines),
64
148
  };
65
149
  }
66
150
 
@@ -90,7 +174,9 @@ export function decide({ screen, state = emptyState(), now = 0, config = {} }) {
90
174
  // panel to read the QR code.
91
175
  if (next.panelPending) {
92
176
  next.panelPending = false;
93
- if (s.panel && !s.modal) {
177
+ // Same rules as any other keystroke: not into a dialog, and not on top of
178
+ // something the user has typed.
179
+ if (s.panel && !s.modal && s.composer === 'empty') {
94
180
  next.lastActionAt = 0; // let the next pass re-arm without waiting out the cooldown
95
181
  return { action: 'confirm-panel', reason: 'stuck-cycle', state: next };
96
182
  }
@@ -105,6 +191,20 @@ export function decide({ screen, state = emptyState(), now = 0, config = {} }) {
105
191
  return { action: 'none', reason: 'connected', state: next };
106
192
  }
107
193
 
194
+ // Held off before any counter moves. Doing this after the counters were
195
+ // consumed meant a dialog — or a sentence merely worded like one, or panel
196
+ // text cckeep itself had left in the scrollback — reset the progress every
197
+ // pass, so the pane could never mature into a recovery at all.
198
+ if (s.modal || s.panel) {
199
+ return { action: 'none', reason: 'dialog', state: next };
200
+ }
201
+
202
+ // Enter submits whatever sits in the composer, so an unsent draft would go
203
+ // out with the command glued to it.
204
+ if (s.composer !== 'empty') {
205
+ return { action: 'none', reason: 'composer-busy', state: next };
206
+ }
207
+
108
208
  let dead = null;
109
209
 
110
210
  if (s.retrying) {
@@ -137,10 +237,6 @@ export function decide({ screen, state = emptyState(), now = 0, config = {} }) {
137
237
  }
138
238
  }
139
239
 
140
- if (s.modal || s.panel) {
141
- return { action: 'none', reason: 'dialog', state: next };
142
- }
143
-
144
240
  if (state.lastActionAt && now - state.lastActionAt < cfg.cooldown) {
145
241
  return { action: 'none', reason: 'cooldown', state: next };
146
242
  }
package/src/i18n.js CHANGED
@@ -3,7 +3,6 @@ const en = {
3
3
  noServer: 'No tmux server is running — nothing to watch.',
4
4
  noPanes: 'No tmux pane is running Claude Code.',
5
5
  hintOutside: 'Claude Code started outside tmux cannot be reached: there is no way to type into it from another process.',
6
- header: 'pane state detail',
7
6
  connected: 'connected',
8
7
  retrying: 'retrying',
9
8
  waiting: 'no indicator',
@@ -11,6 +10,7 @@ const en = {
11
10
  dialog: 'dialog open',
12
11
  cooldown: 'cooling down',
13
12
  busy: 'busy',
13
+ composerBusy: 'something typed in the box',
14
14
  recovered: 'recovered',
15
15
  rearmed: 're-armed',
16
16
  confirmed: 'bridge cycled',
@@ -23,7 +23,7 @@ const en = {
23
23
  unsupported: 'Automatic scheduling supports macOS (launchd) and Linux (systemd user timers). Elsewhere, run `cckeep watch` under your own supervisor.',
24
24
  ephemeral: (p) => `Refusing to schedule: this copy of cckeep lives in npm's throwaway npx cache\n ${p}\nA scheduled job pointing there stops working the moment the cache is cleared — silently. Install it properly first:\n\n npm install -g cckeep && cckeep enable\n`,
25
25
  scheduledPath: 'scheduled cmd',
26
- scheduledMissing: 'MISSING — run `cckeep install` again',
26
+ scheduledMissing: 'MISSING — run `cckeep enable` again',
27
27
  noLog: 'No log yet.',
28
28
  doctorOk: 'ok',
29
29
  doctorFail: 'missing',
@@ -34,7 +34,6 @@ const ja = {
34
34
  noServer: 'tmux が起動していないので、監視するものがありません。',
35
35
  noPanes: 'Claude Code が動いている tmux ペインが見つかりません。',
36
36
  hintOutside: 'tmux の外で起動した Claude Code には手が出せません。外のプロセスから入力を送る経路がないためです。',
37
- header: 'ペイン 状態 詳細',
38
37
  connected: '接続中',
39
38
  retrying: '再試行中',
40
39
  waiting: '表示なし',
@@ -42,6 +41,7 @@ const ja = {
42
41
  dialog: 'ダイアログが開いている',
43
42
  cooldown: '待機中(連打防止)',
44
43
  busy: '実行中のためスキップ',
44
+ composerBusy: '入力欄に文字があるため見送り',
45
45
  recovered: '自力で復帰',
46
46
  rearmed: '繋ぎ直しました',
47
47
  confirmed: '接続を張り直しました',
package/src/run.js CHANGED
@@ -1,10 +1,24 @@
1
1
  import { decide, readScreen } from './detect.js';
2
2
  import { parsePsTable, isTargetPane } from './procs.js';
3
3
  import * as realTmux from './tmux.js';
4
- import { loadState, saveState, forPane, prune, appendLog } from './state.js';
4
+ import { loadState, saveState, forPane, prune, appendLog, acquireLock, releaseLock } from './state.js';
5
5
 
6
6
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
7
7
 
8
+ /**
9
+ * What to put back when a send is called off at the last moment. The counters
10
+ * had already matured to reach this point; discarding them would make the pane
11
+ * start its wait from scratch every time it happened to be busy.
12
+ */
13
+ function abortedCounters(before) {
14
+ return {
15
+ lastActionAt: before.lastActionAt,
16
+ panelPending: before.panelPending,
17
+ miss: before.miss,
18
+ stuck: before.stuck,
19
+ };
20
+ }
21
+
8
22
  /**
9
23
  * Is the pane quiet enough to type into?
10
24
  *
@@ -14,10 +28,14 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
14
28
  * status-line wording.
15
29
  */
16
30
  export async function isIdle(tmux, paneId, settle) {
31
+ // Three samples, not two: with two, any animation whose period divides the
32
+ // interval aliases to identical frames and a running turn reads as idle.
17
33
  const a = tmux.capture(paneId);
18
34
  await sleep(settle);
19
35
  const b = tmux.capture(paneId);
20
- return a.length > 0 && a === b;
36
+ if (a.length === 0 || a !== b) return false;
37
+ await sleep(Math.round(settle / 2));
38
+ return tmux.capture(paneId) === b;
21
39
  }
22
40
 
23
41
  /**
@@ -32,6 +50,8 @@ export async function runPass({ tmux = realTmux, config, dryRun = false, now = M
32
50
  if (!tmux.hasServer()) return { results, acted: 0, error: 'no-server' };
33
51
 
34
52
  const panes = tmux.listPanes();
53
+ if (panes === null) return { results, acted: 0, error: 'query-failed' };
54
+ if (!dryRun && !acquireLock()) return { results, acted: 0, error: 'busy-elsewhere' };
35
55
  const procs = parsePsTable(tmux.processTable ? tmux.processTable() : '');
36
56
  const state = loadState();
37
57
  let acted = 0;
@@ -59,42 +79,47 @@ export async function runPass({ tmux = realTmux, config, dryRun = false, now = M
59
79
  continue;
60
80
  }
61
81
 
62
- if (action === 'confirm-panel') {
63
- tmux.sendEnter(pane.id);
64
- appendLog(`${pane.label}: closing wedged bridge via panel`);
65
- acted += 1;
66
- results.push(result);
67
- continue;
68
- }
69
-
70
- // action === 'rearm'. The decision was made from a single capture; make
71
- // sure nothing has started running in the meantime before typing.
82
+ // Both actions type into the pane, so both go through the same checks: the
83
+ // decision came from one capture, and anything could have happened since.
72
84
  if (!(await isIdle(tmux, pane.id, config.settle))) {
73
85
  result.action = 'none';
74
86
  result.reason = 'busy';
75
- state[pane.id] = { ...after, lastActionAt: before.lastActionAt, panelPending: before.panelPending };
87
+ state[pane.id] = { ...after, ...abortedCounters(before) };
76
88
  results.push(result);
77
89
  continue;
78
90
  }
79
91
 
80
- // Re-read: the pane may have reconnected or opened a dialog while we waited.
92
+ // Re-read: the pane may have reconnected, opened a dialog, or had something
93
+ // typed into it while we waited.
81
94
  const recheck = readScreen(tmux.capture(pane.id));
82
- if (recheck.connected || recheck.modal || recheck.panel) {
95
+ const stillSafe =
96
+ action === 'confirm-panel'
97
+ ? recheck.panel && !recheck.modal && recheck.composer === 'empty'
98
+ : !recheck.connected && !recheck.modal && !recheck.panel && recheck.composer === 'empty';
99
+ if (!stillSafe) {
83
100
  result.action = 'none';
84
- result.reason = recheck.connected ? 'recovered' : 'dialog';
85
- state[pane.id] = { ...after, lastActionAt: before.lastActionAt, panelPending: before.panelPending };
101
+ result.reason = recheck.connected ? 'recovered' : recheck.composer !== 'empty' ? 'composer-busy' : 'dialog';
102
+ state[pane.id] = { ...after, ...abortedCounters(before) };
86
103
  results.push(result);
87
104
  continue;
88
105
  }
89
106
 
90
- tmux.sendText(pane.id, '/remote-control');
91
- await sleep(config.keyDelay ?? 1000);
92
- tmux.sendEnter(pane.id);
93
- appendLog(`${pane.label}: re-arming Remote Control (${reason})`);
107
+ if (action === 'confirm-panel') {
108
+ tmux.sendEnter(pane.id);
109
+ appendLog(`${pane.label}: closing wedged bridge via panel`);
110
+ } else {
111
+ tmux.sendText(pane.id, '/remote-control');
112
+ await sleep(config.keyDelay ?? 1000);
113
+ tmux.sendEnter(pane.id);
114
+ appendLog(`${pane.label}: re-arming Remote Control (${reason})`);
115
+ }
94
116
  acted += 1;
95
117
  results.push(result);
96
118
  }
97
119
 
98
- if (!dryRun) saveState(prune(state, panes.map((p) => p.id)));
120
+ if (!dryRun) {
121
+ saveState(prune(state, panes.map((p) => p.id)));
122
+ releaseLock();
123
+ }
99
124
  return { results, acted };
100
125
  }
package/src/scheduler.js CHANGED
@@ -2,6 +2,7 @@ import { execFileSync } from 'node:child_process';
2
2
  import { mkdirSync, writeFileSync, readFileSync, existsSync, unlinkSync } from 'node:fs';
3
3
  import { homedir, platform } from 'node:os';
4
4
  import { join } from 'node:path';
5
+ import { logPath } from './state.js';
5
6
  import { fileURLToPath } from 'node:url';
6
7
 
7
8
  export const LABEL = 'io.github.kamihork.cckeep';
@@ -24,6 +25,28 @@ function nodePath() {
24
25
  return process.execPath;
25
26
  }
26
27
 
28
+ /** The plist is XML, so anything interpolated into it has to be escaped. */
29
+ function xml(value) {
30
+ return String(value)
31
+ .replace(/&/g, '&amp;')
32
+ .replace(/</g, '&lt;')
33
+ .replace(/>/g, '&gt;');
34
+ }
35
+
36
+ /**
37
+ * Environment the scheduled job needs but would not otherwise inherit. A
38
+ * scheduled run starts from launchd's or systemd's environment, not the shell
39
+ * you typed `enable` in, so a socket or tmux path set there is simply gone —
40
+ * and the job then watches the wrong server, forever, in silence.
41
+ */
42
+ function inheritedEnv() {
43
+ const keep = {};
44
+ for (const key of ['CCKEEP_HOME', 'CCKEEP_TMUX_SOCKET', 'CCKEEP_TMUX']) {
45
+ if (process.env[key]) keep[key] = process.env[key];
46
+ }
47
+ return keep;
48
+ }
49
+
27
50
  export function plistPath() {
28
51
  return join(homedir(), 'Library', 'LaunchAgents', `${LABEL}.plist`);
29
52
  }
@@ -32,9 +55,17 @@ export function systemdDir() {
32
55
  return join(process.env.XDG_CONFIG_HOME || join(homedir(), '.config'), 'systemd', 'user');
33
56
  }
34
57
 
35
- export function renderPlist({ interval, node = nodePath(), cli = cliPath(), home }) {
36
- const env = home
37
- ? ` <key>EnvironmentVariables</key>\n <dict>\n <key>CCKEEP_HOME</key>\n <string>${home}</string>\n </dict>\n`
58
+ export function renderPlist({ interval, node = nodePath(), cli = cliPath(), env = {}, log }) {
59
+ const entries = Object.entries(env).filter(([, v]) => v);
60
+ const envBlock = entries.length
61
+ ? ` <key>EnvironmentVariables</key>\n <dict>\n${entries
62
+ .map(([k, v]) => ` <key>${xml(k)}</key>\n <string>${xml(v)}</string>`)
63
+ .join('\n')}\n </dict>\n`
64
+ : '';
65
+ // Without these, everything a scheduled run reports — a bad config, a
66
+ // missing tmux — goes to /dev/null and the failure is invisible.
67
+ const logBlock = log
68
+ ? ` <key>StandardOutPath</key>\n <string>${xml(log)}</string>\n <key>StandardErrorPath</key>\n <string>${xml(log)}</string>\n`
38
69
  : '';
39
70
  return `<?xml version="1.0" encoding="UTF-8"?>
40
71
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
@@ -44,28 +75,35 @@ export function renderPlist({ interval, node = nodePath(), cli = cliPath(), home
44
75
  <string>${LABEL}</string>
45
76
  <key>ProgramArguments</key>
46
77
  <array>
47
- <string>${node}</string>
48
- <string>${cli}</string>
78
+ <string>${xml(node)}</string>
79
+ <string>${xml(cli)}</string>
49
80
  <string>once</string>
50
81
  </array>
51
82
  <key>StartInterval</key>
52
- <integer>${interval}</integer>
83
+ <integer>${Math.max(1, Math.round(interval))}</integer>
53
84
  <key>RunAtLoad</key>
54
85
  <true/>
55
86
  <key>ProcessType</key>
56
87
  <string>Background</string>
57
- ${env}</dict>
88
+ ${envBlock}${logBlock}</dict>
58
89
  </plist>
59
90
  `;
60
91
  }
61
92
 
62
- export function renderSystemdService({ node = nodePath(), cli = cliPath() }) {
93
+ export function renderSystemdService({ node = nodePath(), cli = cliPath(), env = {} } = {}) {
94
+ // systemd splits ExecStart on whitespace and reads % as a specifier, so the
95
+ // paths are quoted and % is escaped.
96
+ const arg = (v) => `"${String(v).replace(/%/g, '%%').replace(/"/g, '\\"')}"`;
97
+ const envLines = Object.entries(env)
98
+ .filter(([, v]) => v)
99
+ .map(([k, v]) => `Environment=${k}=${String(v).replace(/%/g, '%%')}`)
100
+ .join('\n');
63
101
  return `[Unit]
64
102
  Description=cckeep — keep Claude Code Remote Control alive
65
103
 
66
104
  [Service]
67
105
  Type=oneshot
68
- ExecStart=${node} ${cli} once
106
+ ${envLines}${envLines ? '\n' : ''}ExecStart=${arg(node)} ${arg(cli)} once
69
107
  `;
70
108
  }
71
109
 
@@ -100,7 +138,7 @@ export function install({ interval }) {
100
138
  if (os === 'darwin') {
101
139
  const p = plistPath();
102
140
  mkdirSync(join(homedir(), 'Library', 'LaunchAgents'), { recursive: true });
103
- writeFileSync(p, renderPlist({ interval, home: process.env.CCKEEP_HOME }));
141
+ writeFileSync(p, renderPlist({ interval, env: inheritedEnv(), log: logPath() }));
104
142
  const uid = process.getuid();
105
143
  run('launchctl', ['bootout', `gui/${uid}/${LABEL}`]);
106
144
  const ok = run('launchctl', ['bootstrap', `gui/${uid}`, p]);
@@ -109,9 +147,12 @@ export function install({ interval }) {
109
147
  if (os === 'linux') {
110
148
  const dir = systemdDir();
111
149
  mkdirSync(dir, { recursive: true });
112
- writeFileSync(join(dir, 'cckeep.service'), renderSystemdService({}));
150
+ writeFileSync(join(dir, 'cckeep.service'), renderSystemdService({ env: inheritedEnv() }));
113
151
  writeFileSync(join(dir, 'cckeep.timer'), renderSystemdTimer({ interval }));
114
152
  run('systemctl', ['--user', 'daemon-reload']);
153
+ // Without lingering the user manager stops at logout and the timer dies
154
+ // with it — silently, on exactly the remote boxes this matters most on.
155
+ run('loginctl', ['enable-linger', process.env.USER ?? '']);
115
156
  const ok = run('systemctl', ['--user', 'enable', '--now', 'cckeep.timer']);
116
157
  return { kind: 'systemd', path: dir, ok };
117
158
  }
@@ -158,7 +199,9 @@ export function scheduledCli() {
158
199
  const p = plistPath();
159
200
  if (!existsSync(p)) return null;
160
201
  const xml = readFileSync(p, 'utf8');
161
- const args = [...xml.matchAll(/<string>([^<]*)<\/string>/g)].map((m) => m[1]);
202
+ const args = [...xml.matchAll(/<string>([^<]*)<\/string>/g)].map((m) =>
203
+ m[1].replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/g, '&'),
204
+ );
162
205
  return args.find((a) => a.endsWith('cckeep.js')) ?? null;
163
206
  }
164
207
  if (os === 'linux') {
@@ -166,7 +209,11 @@ export function scheduledCli() {
166
209
  if (!existsSync(p)) return null;
167
210
  const unit = readFileSync(p, 'utf8');
168
211
  const line = unit.match(/^ExecStart=(.*)$/m)?.[1] ?? '';
169
- return line.split(/\s+/).find((a) => a.endsWith('cckeep.js')) ?? null;
212
+ // Paths are quoted, so read the quoted words rather than splitting on
213
+ // whitespace — a path with a space used to come back mangled.
214
+ const quoted = [...line.matchAll(/"([^"]*)"/g)].map((m) => m[1].replace(/%%/g, '%'));
215
+ const words = quoted.length ? quoted : line.split(/\s+/);
216
+ return words.find((a) => a.endsWith('cckeep.js')) ?? null;
170
217
  }
171
218
  } catch {}
172
219
  return null;
package/src/state.js CHANGED
@@ -1,4 +1,4 @@
1
- import { mkdirSync, readFileSync, writeFileSync, existsSync } from 'node:fs';
1
+ import { mkdirSync, readFileSync, writeFileSync, renameSync, rmSync, statSync, existsSync } from 'node:fs';
2
2
  import { homedir } from 'node:os';
3
3
  import { join, dirname } from 'node:path';
4
4
  import { emptyState } from './detect.js';
@@ -31,7 +31,41 @@ export function loadState() {
31
31
  export function saveState(state) {
32
32
  const p = statePath();
33
33
  mkdirSync(dirname(p), { recursive: true });
34
- writeFileSync(p, JSON.stringify(state, null, 2));
34
+ // Write then rename, so a concurrent reader never sees a half-written file
35
+ // and falls back to an empty state — which would lose every `seen` flag.
36
+ const tmp = `${p}.${process.pid}.tmp`;
37
+ writeFileSync(tmp, JSON.stringify(state, null, 2));
38
+ renameSync(tmp, p);
39
+ }
40
+
41
+ /**
42
+ * One pass at a time. `cckeep watch` in a terminal alongside the scheduled job
43
+ * is an easy thing to end up with, and two passes acting on the same pane
44
+ * interleave into a single garbled prompt — the cooldown cannot help, because
45
+ * both read the state before either writes it.
46
+ */
47
+ export function acquireLock(staleAfterMs = 120000) {
48
+ const p = join(homeDir(), 'cckeep.lock');
49
+ mkdirSync(dirname(p), { recursive: true });
50
+ try {
51
+ writeFileSync(p, String(process.pid), { flag: 'wx' });
52
+ return true;
53
+ } catch {
54
+ try {
55
+ if (Date.now() - statSync(p).mtimeMs > staleAfterMs) {
56
+ rmSync(p, { force: true });
57
+ writeFileSync(p, String(process.pid), { flag: 'wx' });
58
+ return true;
59
+ }
60
+ } catch {}
61
+ return false;
62
+ }
63
+ }
64
+
65
+ export function releaseLock() {
66
+ try {
67
+ rmSync(join(homeDir(), 'cckeep.lock'), { force: true });
68
+ } catch {}
35
69
  }
36
70
 
37
71
  export function forPane(state, paneId) {
package/src/tmux.js CHANGED
@@ -15,8 +15,37 @@ const CANDIDATES = [
15
15
 
16
16
  let cached;
17
17
 
18
+ /**
19
+ * tmux runs one server per socket. Anyone using `tmux -L name` or `-S path` has
20
+ * their panes on a different server, and without this cckeep silently watches
21
+ * the wrong one — it reports "no panes" while the sessions are right there.
22
+ * It also makes cckeep testable against a throwaway server instead of the one
23
+ * holding your real conversations.
24
+ */
25
+ export function socketArgs(socketOverride) {
26
+ // `||` rather than `??`: the config default is an empty string, and an empty
27
+ // string must fall through to the environment instead of shadowing it.
28
+ const socket = socketOverride || process.env.CCKEEP_TMUX_SOCKET;
29
+ if (!socket) return [];
30
+ return socket.includes('/') ? ['-S', socket] : ['-L', socket];
31
+ }
32
+
33
+ /**
34
+ * Settings that reach tmux from the config file rather than the environment.
35
+ * Set once from the CLI after the config is loaded, because every call site
36
+ * below is deep inside a pass and threading them through would touch every
37
+ * signature for no gain.
38
+ */
39
+ let configured = { socket: '', binary: '' };
40
+
41
+ export function configureTmux({ socket = '', binary = '' } = {}) {
42
+ configured = { socket, binary };
43
+ cached = binary && existsSync(binary) ? binary : undefined;
44
+ }
45
+
18
46
  export function tmuxPath() {
19
47
  if (cached) return cached;
48
+ if (configured.binary && existsSync(configured.binary)) return (cached = configured.binary);
20
49
  const fromEnv = process.env.CCKEEP_TMUX;
21
50
  if (fromEnv && existsSync(fromEnv)) return (cached = fromEnv);
22
51
  for (const p of CANDIDATES) if (existsSync(p)) return (cached = p);
@@ -31,7 +60,7 @@ function tmux(args, { allowFail = true } = {}) {
31
60
  const bin = tmuxPath();
32
61
  if (!bin) return null;
33
62
  try {
34
- return execFileSync(bin, args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
63
+ return execFileSync(bin, [...socketArgs(configured.socket), ...args], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
35
64
  } catch (err) {
36
65
  if (allowFail) return null;
37
66
  throw err;
@@ -43,9 +72,17 @@ export function hasServer() {
43
72
  }
44
73
 
45
74
  /** Every pane on the server, with the process currently in the foreground. */
75
+ /**
76
+ * Every pane on the server, or null if tmux could not be asked.
77
+ *
78
+ * The distinction matters: an empty array used to also mean "the query failed",
79
+ * and the caller pruned its state to match — wiping every pane it had ever seen
80
+ * connected, which no pane can re-earn while it is disconnected.
81
+ */
46
82
  export function listPanes() {
47
83
  const out = tmux(['list-panes', '-a', '-F', '#{pane_id}\t#{pane_current_command}\t#{session_name}\t#{window_index}\t#{pane_index}\t#{pane_pid}']);
48
- if (!out) return [];
84
+ if (out === null) return null;
85
+ if (!out.trim()) return [];
49
86
  return out
50
87
  .split('\n')
51
88
  .filter(Boolean)