cckeep 0.1.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/LICENSE +21 -0
- package/README.ja.md +183 -0
- package/README.md +183 -0
- package/bin/cckeep.js +185 -0
- package/package.json +44 -0
- package/src/config.js +60 -0
- package/src/detect.js +136 -0
- package/src/i18n.js +65 -0
- package/src/run.js +98 -0
- package/src/scheduler.js +134 -0
- package/src/state.js +56 -0
- package/src/tmux.js +70 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 kamihork
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.ja.md
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
<img src="https://raw.githubusercontent.com/kamihork/cckeep/main/assets/logo.png" width="140" height="140" alt="cckeep ロゴ — 開いたまま保たれるリンク">
|
|
3
|
+
|
|
4
|
+
<h1>cckeep</h1>
|
|
5
|
+
|
|
6
|
+
<p><strong>Claude Code のリモートコントロールが黙って死ぬのを防ぎます。</strong><br>
|
|
7
|
+
リモートコントロールは約31秒で再試行を諦め、二度と戻りません。<br>
|
|
8
|
+
<code>cckeep</code> はそれを検知して繋ぎ直します — 作業中のセッションには触れずに。</p>
|
|
9
|
+
|
|
10
|
+
<p>
|
|
11
|
+
<a href="https://www.npmjs.com/package/cckeep"><img src="https://img.shields.io/npm/v/cckeep?color=1f9d8f&label=npm" alt="npm version"></a>
|
|
12
|
+
<a href="https://www.npmjs.com/package/cckeep"><img src="https://img.shields.io/npm/dt/cckeep?color=3987e5" alt="npm downloads"></a>
|
|
13
|
+
<a href="https://github.com/kamihork/cckeep/actions/workflows/test.yml"><img src="https://github.com/kamihork/cckeep/actions/workflows/test.yml/badge.svg" alt="test status"></a>
|
|
14
|
+
<a href="LICENSE"><img src="https://img.shields.io/github/license/kamihork/cckeep?color=199e70" alt="license"></a>
|
|
15
|
+
</p>
|
|
16
|
+
|
|
17
|
+
<p><a href="https://kamihork.github.io/cckeep/">Website</a> | <a href="README.md">English</a> | 日本語</p>
|
|
18
|
+
</div>
|
|
19
|
+
|
|
20
|
+
## 何が問題か
|
|
21
|
+
|
|
22
|
+
[リモートコントロール](https://code.claude.com/docs/en/remote-control)は、手元で動いている Claude Code のセッションをスマホや claude.ai から操作する機能です。接続が切れても自動で再接続してくれます — ただし **1/2/4/8/16秒のバックオフで5回まで**。合計 **31秒** の予算です。ノートを閉じる、Wi-Fi を切り替える、エレベーターに乗る。それだけで予算は尽き、接続は閉じられて二度と戻りません。
|
|
23
|
+
|
|
24
|
+
もう一つの壊れ方もあります。`/rc reconnecting` の表示のまま永久に固まるパターンで、これが [anthropics/claude-code#34255](https://github.com/anthropics/claude-code/issues/34255) — 2026年3月から open、👍 99件、未修正です。
|
|
25
|
+
|
|
26
|
+
どちらにせよ気づき方は同じです。スマホを開いたらセッションが消えている。公式に案内されている復旧方法は、机に戻って `/remote-control` と打つことです。
|
|
27
|
+
|
|
28
|
+
## クイックスタート
|
|
29
|
+
|
|
30
|
+
```sh
|
|
31
|
+
npx cckeep install
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
バックグラウンドジョブを登録します(macOS は launchd、Linux は systemd user timer)。15秒ごとに確認し、死んでいるものを繋ぎ直します。
|
|
35
|
+
|
|
36
|
+
条件が一つあります。**Claude Code が tmux の中で動いていること。** 素のターミナルで起動したセッションには別プロセスから入力を送る手段がなく、どんなツールでも手が出せません。→ [tmux で Claude Code を動かす](#tmux-で-claude-code-を動かす)
|
|
37
|
+
|
|
38
|
+
```sh
|
|
39
|
+
npx cckeep # いま何が見えているか(何も変更しません)
|
|
40
|
+
npx cckeep doctor # tmux・ペイン・スケジューラの確認
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
> 机に戻る手間が省けたなら、⭐ が同じ問題を抱えている人に届く助けになります。
|
|
44
|
+
|
|
45
|
+
## 作業中のセッションには打ち込みません
|
|
46
|
+
|
|
47
|
+
これが設計上の核心です。タイマーでターミナルに文字を打ち込むツールは、「いま安全か」を確実に判断できない限り危険物でしかありません。以下はすべて実装され、[テスト](test/)されています。
|
|
48
|
+
|
|
49
|
+
- **ターン実行中は絶対に打たない。** ペインを2秒あけて2回キャプチャします。実行中はスピナーとトークンカウンタが動くので、2回が一致すれば何も起きていない証拠、違えば手を出さない
|
|
50
|
+
- **ダイアログには打たない。** 権限プロンプトや選択メニューでは Enter が「選択」になります。選択マーカーが画面にあれば、その回はスキップ
|
|
51
|
+
- **あなたが開いたパネルには打たない。** `/remote-control` は QR コード付きのステータスパネルを開きます。cckeep が Enter を押すのは、自分で開いたときだけ
|
|
52
|
+
- **自分で切ったセッションは戻さない。** 一度でも接続済みだったペインだけを追跡します。意図的に切ったものは切れたまま
|
|
53
|
+
- **連打しない。** 1ペインにつき5分に1回まで
|
|
54
|
+
- **直前に再確認する。** 判断は1回のキャプチャで下し、待機後にもう一度確認します。その間に復帰したりダイアログが出ていれば、何も送りません
|
|
55
|
+
|
|
56
|
+
`--dry-run` を付けると、何をするつもりかだけを表示して一切送信しません。
|
|
57
|
+
|
|
58
|
+
## 何を見ているか
|
|
59
|
+
|
|
60
|
+
| 画面の状態 | 意味 | cckeep の動作 |
|
|
61
|
+
|---|---|---|
|
|
62
|
+
| `/rc active` | 接続中 | ペインを記憶するだけ |
|
|
63
|
+
| `/rc reconnecting` | 31秒の予算内 | 待つ(たいていこれで直る) |
|
|
64
|
+
| `/rc reconnecting` が2分継続 | 固着([#34255](https://github.com/anthropics/claude-code/issues/34255)) | パネルを開いて切断し、張り直す |
|
|
65
|
+
| `Remote Control disconnected` | 諦めた | 即座に繋ぎ直す |
|
|
66
|
+
| 表示なし(以前はあった) | 通知が流れて消えた | 4回静かなら繋ぎ直す |
|
|
67
|
+
| 表示なし(一度もなかった) | そういう使い方ではない | 何もしない |
|
|
68
|
+
|
|
69
|
+
## コマンド
|
|
70
|
+
|
|
71
|
+
```
|
|
72
|
+
cckeep # 状態表示: Claude Code のペインごとに1行
|
|
73
|
+
cckeep watch # スケジュール登録せずフォアグラウンドで実行
|
|
74
|
+
cckeep once # 1回だけ実行(スケジューラが叩くもの)
|
|
75
|
+
cckeep install # バックグラウンドジョブを登録
|
|
76
|
+
cckeep uninstall # 解除
|
|
77
|
+
cckeep doctor # tmux・ペイン・スケジューラ・各パス
|
|
78
|
+
cckeep logs # これまでの動作
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
オプション: `--dry-run`、`--json`、`--interval <秒>`、`--lang en|ja`(`LANG` から自動判定)。
|
|
82
|
+
|
|
83
|
+
## tmux で Claude Code を動かす
|
|
84
|
+
|
|
85
|
+
cckeep は tmux ペインを読み、そこに入力します。動いている Claude Code セッションに別プロセスから届く経路はこれだけで、だからこそ tmux 内での起動が必要です。プロセスの再起動は代替になりません — 会話が終わってしまい、守りたかったものそのものを失うからです。
|
|
86
|
+
|
|
87
|
+
最小の変更は、対話起動だけを包むシェル関数です。`claude update` や `claude doctor`、`claude -p` はそのまま素通しします。
|
|
88
|
+
|
|
89
|
+
```sh
|
|
90
|
+
cc() {
|
|
91
|
+
local a
|
|
92
|
+
for a in "$@"; do
|
|
93
|
+
case "$a" in
|
|
94
|
+
-p|--print|-v|--version|-h|--help|--bg|--background|--output-format) command claude "$@"; return ;;
|
|
95
|
+
agents|auth|doctor|install|mcp|plugin|project|setup-token|update|upgrade|remote-control|rc|config)
|
|
96
|
+
command claude "$@"; return ;;
|
|
97
|
+
-*) ;;
|
|
98
|
+
*) break ;;
|
|
99
|
+
esac
|
|
100
|
+
done
|
|
101
|
+
local session="claude-$(basename "$PWD")-$(printf '%s' "$PWD" | cksum | cut -d' ' -f1)"
|
|
102
|
+
if [ -n "$TMUX" ]; then command claude "$@"
|
|
103
|
+
elif tmux has-session -t "=$session" 2>/dev/null; then tmux attach-session -t "=$session"
|
|
104
|
+
else tmux new-session -s "$session" -c "$PWD" claude "$@"
|
|
105
|
+
fi
|
|
106
|
+
}
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
あわせて `~/.tmux.conf` に2行必要です。これが無いと tmux 内で Shift+Enter とデスクトップ通知が壊れます([公式の案内](https://code.claude.com/docs/en/terminal-config#configure-tmux))。
|
|
110
|
+
|
|
111
|
+
```sh
|
|
112
|
+
set -g allow-passthrough on
|
|
113
|
+
set -s extended-keys on
|
|
114
|
+
set -as terminal-features 'xterm*:extkeys'
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Ctrl+B の衝突は対処不要です。Claude Code は tmux を検出して、自身のショートカットを `Ctrl+B Ctrl+B` に読み替えます。
|
|
118
|
+
|
|
119
|
+
## 設定
|
|
120
|
+
|
|
121
|
+
デフォルトは「存在に気づかない」ことを狙って調整してあります。`~/.cckeep/config.json`、環境変数、実行時フラグの順で上書きされます。壊れた設定ファイルは黙って一部だけ読むのではなく、エラーで停止します。
|
|
122
|
+
|
|
123
|
+
```json
|
|
124
|
+
{
|
|
125
|
+
"interval": 15,
|
|
126
|
+
"cooldown": 300,
|
|
127
|
+
"stuckLimit": 8,
|
|
128
|
+
"missLimit": 4,
|
|
129
|
+
"settle": 2000,
|
|
130
|
+
"paneCommand": "claude"
|
|
131
|
+
}
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
- `interval` — 巡回間隔(秒)。`install` が登録する間隔でもある
|
|
135
|
+
- `cooldown` — 同じペインに再度手を出せるまでの秒数
|
|
136
|
+
- `stuckLimit` — `reconnecting` が何回続いたら固着とみなすか
|
|
137
|
+
- `missLimit` — 表示のあったペインが何回無表示なら繋ぎ直すか
|
|
138
|
+
- `settle` — 静止判定の2回のキャプチャ間隔(ミリ秒)。遅いマシンでは増やす
|
|
139
|
+
- `paneCommand` — Claude Code のペインと判定するフォアグラウンドプロセス名
|
|
140
|
+
|
|
141
|
+
すべてに環境変数版があります: `CCKEEP_INTERVAL`、`CCKEEP_COOLDOWN`、`CCKEEP_STUCK_LIMIT`、`CCKEEP_MISS_LIMIT`、`CCKEEP_SETTLE`、`CCKEEP_PANE_COMMAND`。`CCKEEP_HOME` で状態・設定・ログの置き場所を `~/.cckeep` から移せます。
|
|
142
|
+
|
|
143
|
+
## 適用範囲
|
|
144
|
+
|
|
145
|
+
cckeep がするのは接続の張り直しだけです。Claude Code の再試行予算そのものは**変えません** — クローズドソースのバイナリ内の定数であり、変えられるのは Anthropic だけです。[#34255](https://github.com/anthropics/claude-code/issues/34255) が修正されればこのツールは不要になります。それが正しい結末です。それまでは、ここに star を付けるより向こうに 👍 を付けるほうが価値があります。
|
|
146
|
+
|
|
147
|
+
設計上、以下は対象外です。
|
|
148
|
+
|
|
149
|
+
- **tmux 外のセッション** — 入力を送る経路がない
|
|
150
|
+
- **VS Code 拡張** — ターミナル TUI ではなく、tmux で包めない
|
|
151
|
+
- **サーバーモード**(`claude remote-control`)— 自分が起動したプロセスなので、launchd/systemd や `while true` ループで直接監督すればよい
|
|
152
|
+
- **10分を超える通信断** — Claude Code 自身がセッションを終了するため、張り直す対象が残らない
|
|
153
|
+
|
|
154
|
+
## プライバシー
|
|
155
|
+
|
|
156
|
+
cckeep は接続状態を判定するために tmux ペインの表示テキストを読みます。それはあなたの会話です。したがって:
|
|
157
|
+
|
|
158
|
+
- すべてローカルで完結し、このパッケージにネットワークコードはありません
|
|
159
|
+
- テレメトリなし、アカウントなし、外部送信なし
|
|
160
|
+
- ペインのテキストは数個の判定文字列と照合した後すぐ破棄され、ログに残るのはペイン名と判定結果だけです
|
|
161
|
+
- ログは `~/.cckeep/cckeep.log`。`cckeep logs` で表示できます
|
|
162
|
+
|
|
163
|
+
## 仕組み
|
|
164
|
+
|
|
165
|
+
Claude Code はフッターにリモートコントロールの状態を描画します。接続中は `/rc active`、再試行中は `/rc reconnecting`、諦めたときは `Remote Control disconnected` の通知です。cckeep は tmux にフォアグラウンドプロセスが `claude` のペインを問い合わせ、`tmux capture-pane` からこれらを読み取り、ペインごとの小さなカウンタを `~/.cckeep/state.json` に保持します。
|
|
166
|
+
|
|
167
|
+
判定層(`src/detect.js`)は画面テキストと直前の状態だけを引数に取る純粋関数です。だからこそ安全ルールをターミナル無しで網羅的にテストできます。I/O は実行層(`src/run.js`)が担当します — 静止判定、直前の再確認、キー送信です。
|
|
168
|
+
|
|
169
|
+
これらは公開 API ではありません。判定文字列は UI テキストであり変わり得ます。変わったとき cckeep は誤動作ではなく沈黙します — 読めないペインは「一度も接続していない」ように見え、接続を見たことがないペインには決して手を出さないためです。
|
|
170
|
+
|
|
171
|
+
## 開発
|
|
172
|
+
|
|
173
|
+
```sh
|
|
174
|
+
git clone https://github.com/kamihork/cckeep.git && cd cckeep
|
|
175
|
+
npm test # 36テスト。ネットワークも tmux も不要
|
|
176
|
+
node bin/cckeep.js doctor
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
テストは tmux をフェイクするのでどこでも走ります。コントリビューション歓迎です — 特に、検出が漏れる Claude Code のバージョンやターミナルでの実際の表示文字列が助かります。[CONTRIBUTING.md](CONTRIBUTING.md) を参照してください。
|
|
180
|
+
|
|
181
|
+
## ライセンス
|
|
182
|
+
|
|
183
|
+
[MIT](LICENSE) © [kamihork](https://github.com/kamihork)
|
package/README.md
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
<img src="https://raw.githubusercontent.com/kamihork/cckeep/main/assets/logo.png" width="140" height="140" alt="cckeep logo — a link held open between two ends">
|
|
3
|
+
|
|
4
|
+
<h1>cckeep</h1>
|
|
5
|
+
|
|
6
|
+
<p><strong>Keeps Claude Code Remote Control from silently going dead.</strong><br>
|
|
7
|
+
Remote Control retries for about 31 seconds and then gives up for good.<br>
|
|
8
|
+
<code>cckeep</code> notices, and re-arms the session — without touching one that's busy.</p>
|
|
9
|
+
|
|
10
|
+
<p>
|
|
11
|
+
<a href="https://www.npmjs.com/package/cckeep"><img src="https://img.shields.io/npm/v/cckeep?color=1f9d8f&label=npm" alt="npm version"></a>
|
|
12
|
+
<a href="https://www.npmjs.com/package/cckeep"><img src="https://img.shields.io/npm/dt/cckeep?color=3987e5" alt="npm downloads"></a>
|
|
13
|
+
<a href="https://github.com/kamihork/cckeep/actions/workflows/test.yml"><img src="https://github.com/kamihork/cckeep/actions/workflows/test.yml/badge.svg" alt="test status"></a>
|
|
14
|
+
<a href="LICENSE"><img src="https://img.shields.io/github/license/kamihork/cckeep?color=199e70" alt="license"></a>
|
|
15
|
+
</p>
|
|
16
|
+
|
|
17
|
+
<p><a href="https://kamihork.github.io/cckeep/">Website</a> | English | <a href="README.ja.md">日本語</a></p>
|
|
18
|
+
</div>
|
|
19
|
+
|
|
20
|
+
## The problem
|
|
21
|
+
|
|
22
|
+
[Remote Control](https://code.claude.com/docs/en/remote-control) lets you drive a local Claude Code session from your phone or from claude.ai. It reconnects on its own when the link drops — for **5 attempts with 1/2/4/8/16-second backoff**. That is a **31-second** budget. Close your laptop lid, switch Wi-Fi, ride an elevator, and the budget is gone. The connection closes and never comes back.
|
|
23
|
+
|
|
24
|
+
There is a second failure too: the session wedges in `/rc reconnecting` and sits there forever. That one is [anthropics/claude-code#34255](https://github.com/anthropics/claude-code/issues/34255) — open since March 2026, 99 👍, no fix.
|
|
25
|
+
|
|
26
|
+
Either way you find out the same way: you reach for your phone, and the session is gone. The documented recovery is to walk back to your desk and type `/remote-control`.
|
|
27
|
+
|
|
28
|
+
## Quick start
|
|
29
|
+
|
|
30
|
+
```sh
|
|
31
|
+
npx cckeep install
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
That registers a background job — launchd on macOS, a systemd user timer on Linux — that checks every 15 seconds and re-arms whatever went dead.
|
|
35
|
+
|
|
36
|
+
One requirement: **Claude Code has to be running inside tmux.** A session started in a bare terminal cannot be reached from another process, so there is nothing any tool can do for it. See [Running Claude Code in tmux](#running-claude-code-in-tmux).
|
|
37
|
+
|
|
38
|
+
```sh
|
|
39
|
+
npx cckeep # what it sees right now — changes nothing
|
|
40
|
+
npx cckeep doctor # check tmux, panes, and the scheduler
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
> If cckeep saved you a walk back to your desk, a ⭐ helps other Remote Control users find it.
|
|
44
|
+
|
|
45
|
+
## It will not type into a session that is working
|
|
46
|
+
|
|
47
|
+
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/):
|
|
48
|
+
|
|
49
|
+
- **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.
|
|
50
|
+
- **Never into a dialog.** Permission prompts and pickers turn Enter into a selection. If a selection marker is on screen, the pass is skipped.
|
|
51
|
+
- **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.
|
|
52
|
+
- **Never a session you turned off.** Only panes seen connected at least once are ever chased. Disable Remote Control deliberately and it stays off.
|
|
53
|
+
- **Never in a tight loop.** One action per pane per 5 minutes.
|
|
54
|
+
- **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.
|
|
55
|
+
|
|
56
|
+
`--dry-run` prints what it would do and sends nothing.
|
|
57
|
+
|
|
58
|
+
## What it watches for
|
|
59
|
+
|
|
60
|
+
| State on screen | What it means | What cckeep does |
|
|
61
|
+
|---|---|---|
|
|
62
|
+
| `/rc active` | connected | remembers the pane, nothing else |
|
|
63
|
+
| `/rc reconnecting` | inside the 31-second budget | waits — this usually resolves |
|
|
64
|
+
| `/rc reconnecting`, 2 minutes on | wedged ([#34255](https://github.com/anthropics/claude-code/issues/34255)) | cycles the bridge: opens the panel, disconnects, reconnects |
|
|
65
|
+
| `Remote Control disconnected` | gave up | re-arms immediately |
|
|
66
|
+
| no indicator, on a pane that had one | notification scrolled away | re-arms after 4 quiet checks |
|
|
67
|
+
| no indicator, on a pane that never had one | not your setup | nothing, ever |
|
|
68
|
+
|
|
69
|
+
## Commands
|
|
70
|
+
|
|
71
|
+
```
|
|
72
|
+
cckeep # status: one line per Claude Code pane
|
|
73
|
+
cckeep watch # run in the foreground instead of scheduling
|
|
74
|
+
cckeep once # a single pass — what the scheduler runs
|
|
75
|
+
cckeep install # register the background job
|
|
76
|
+
cckeep uninstall # remove it
|
|
77
|
+
cckeep doctor # tmux, panes, scheduler, paths
|
|
78
|
+
cckeep logs # what it has done
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Options: `--dry-run`, `--json`, `--interval <s>`, `--lang en|ja` (auto-detected from `LANG`).
|
|
82
|
+
|
|
83
|
+
## Running Claude Code in tmux
|
|
84
|
+
|
|
85
|
+
cckeep reads and types into tmux panes. That is the only channel a separate process has into a live Claude Code session — and it is why the session must be started inside tmux. Restarting the process is not an alternative: it would end the conversation, which is exactly what you are trying to save.
|
|
86
|
+
|
|
87
|
+
The smallest change is a shell function that wraps interactive launches only, so `claude update`, `claude doctor` and `claude -p` still behave normally:
|
|
88
|
+
|
|
89
|
+
```sh
|
|
90
|
+
cc() {
|
|
91
|
+
local a
|
|
92
|
+
for a in "$@"; do
|
|
93
|
+
case "$a" in
|
|
94
|
+
-p|--print|-v|--version|-h|--help|--bg|--background|--output-format) command claude "$@"; return ;;
|
|
95
|
+
agents|auth|doctor|install|mcp|plugin|project|setup-token|update|upgrade|remote-control|rc|config)
|
|
96
|
+
command claude "$@"; return ;;
|
|
97
|
+
-*) ;;
|
|
98
|
+
*) break ;;
|
|
99
|
+
esac
|
|
100
|
+
done
|
|
101
|
+
local session="claude-$(basename "$PWD")-$(printf '%s' "$PWD" | cksum | cut -d' ' -f1)"
|
|
102
|
+
if [ -n "$TMUX" ]; then command claude "$@"
|
|
103
|
+
elif tmux has-session -t "=$session" 2>/dev/null; then tmux attach-session -t "=$session"
|
|
104
|
+
else tmux new-session -s "$session" -c "$PWD" claude "$@"
|
|
105
|
+
fi
|
|
106
|
+
}
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
Claude Code also needs two lines in `~/.tmux.conf`, or Shift+Enter and desktop notifications break inside tmux ([official guidance](https://code.claude.com/docs/en/terminal-config#configure-tmux)):
|
|
110
|
+
|
|
111
|
+
```sh
|
|
112
|
+
set -g allow-passthrough on
|
|
113
|
+
set -s extended-keys on
|
|
114
|
+
set -as terminal-features 'xterm*:extkeys'
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Ctrl+B needs no fix: Claude Code detects tmux and rebinds its own shortcut to `Ctrl+B Ctrl+B`.
|
|
118
|
+
|
|
119
|
+
## Configuration
|
|
120
|
+
|
|
121
|
+
Defaults are tuned so you never notice it. Override in `~/.cckeep/config.json`, by environment variable, or per-run flag — later wins. A malformed config is a hard error rather than a silent half-load.
|
|
122
|
+
|
|
123
|
+
```json
|
|
124
|
+
{
|
|
125
|
+
"interval": 15,
|
|
126
|
+
"cooldown": 300,
|
|
127
|
+
"stuckLimit": 8,
|
|
128
|
+
"missLimit": 4,
|
|
129
|
+
"settle": 2000,
|
|
130
|
+
"paneCommand": "claude"
|
|
131
|
+
}
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
- `interval` — seconds between passes (also what `install` schedules)
|
|
135
|
+
- `cooldown` — seconds before the same pane may be acted on again
|
|
136
|
+
- `stuckLimit` — checks in `reconnecting` before the bridge is treated as wedged
|
|
137
|
+
- `missLimit` — checks with no indicator before re-arming a pane that had one
|
|
138
|
+
- `settle` — milliseconds between the two captures of the idle check; raise it on a slow machine
|
|
139
|
+
- `paneCommand` — foreground process name that marks a pane as Claude Code
|
|
140
|
+
|
|
141
|
+
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`.
|
|
142
|
+
|
|
143
|
+
## Scope
|
|
144
|
+
|
|
145
|
+
cckeep re-arms the connection. It does **not** raise Claude Code's retry budget — that is a constant inside a closed-source binary, and only Anthropic can change it. If [#34255](https://github.com/anthropics/claude-code/issues/34255) is fixed, this tool becomes unnecessary, which is the right outcome. Until then, a 👍 there is worth more than a star here.
|
|
146
|
+
|
|
147
|
+
Out of reach by design:
|
|
148
|
+
|
|
149
|
+
- **Sessions outside tmux** — no channel to type into
|
|
150
|
+
- **The VS Code extension** — not a terminal TUI; tmux cannot wrap it
|
|
151
|
+
- **Server mode** (`claude remote-control`) — that one is a process you own, so supervise it with launchd/systemd directly, or a `while true` loop
|
|
152
|
+
- **Outages past ~10 minutes** — Claude Code exits the session itself; there is nothing left to re-arm
|
|
153
|
+
|
|
154
|
+
## Privacy
|
|
155
|
+
|
|
156
|
+
cckeep reads the visible text of your tmux panes to decide whether a pane is connected. That text is your conversation. Therefore:
|
|
157
|
+
|
|
158
|
+
- everything stays on your machine; there is no network code in this package
|
|
159
|
+
- no telemetry, no account, no phone-home
|
|
160
|
+
- pane text is matched against a handful of indicator strings and thrown away — only pane labels and verdicts reach the log
|
|
161
|
+
- the log lives at `~/.cckeep/cckeep.log`; `cckeep logs` prints it
|
|
162
|
+
|
|
163
|
+
## How it works
|
|
164
|
+
|
|
165
|
+
Claude Code paints a Remote Control indicator in its footer: `/rc active` when connected, `/rc reconnecting` while retrying, and a `Remote Control disconnected` notification when it gives up. cckeep asks tmux for every pane whose foreground process is `claude`, reads those indicators out of `tmux capture-pane`, and keeps a small per-pane counter in `~/.cckeep/state.json`.
|
|
166
|
+
|
|
167
|
+
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.
|
|
168
|
+
|
|
169
|
+
None of this is a published API — the indicator strings are UI text and can change. When they do, cckeep stops acting rather than acting wrongly: a pane it cannot read looks "never connected", and panes it has never seen connected are never touched.
|
|
170
|
+
|
|
171
|
+
## Development
|
|
172
|
+
|
|
173
|
+
```sh
|
|
174
|
+
git clone https://github.com/kamihork/cckeep.git && cd cckeep
|
|
175
|
+
npm test # 36 tests, no network, no tmux required
|
|
176
|
+
node bin/cckeep.js doctor
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
The test suite fakes tmux, so it runs anywhere. Contributions welcome — especially indicator strings from Claude Code versions or terminals where detection misses. See [CONTRIBUTING.md](CONTRIBUTING.md).
|
|
180
|
+
|
|
181
|
+
## License
|
|
182
|
+
|
|
183
|
+
[MIT](LICENSE) © [kamihork](https://github.com/kamihork)
|
package/bin/cckeep.js
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
3
|
+
import { platform } from 'node:os';
|
|
4
|
+
import { runPass } from '../src/run.js';
|
|
5
|
+
import { loadConfig, configPath } from '../src/config.js';
|
|
6
|
+
import { logPath, statePath, homeDir } from '../src/state.js';
|
|
7
|
+
import * as tmux from '../src/tmux.js';
|
|
8
|
+
import * as scheduler from '../src/scheduler.js';
|
|
9
|
+
import { pickLang, strings } from '../src/i18n.js';
|
|
10
|
+
|
|
11
|
+
const HELP = `cckeep — keep Claude Code Remote Control from silently going dead
|
|
12
|
+
|
|
13
|
+
USAGE
|
|
14
|
+
cckeep [command] [options]
|
|
15
|
+
|
|
16
|
+
COMMANDS
|
|
17
|
+
status what cckeep sees right now (default)
|
|
18
|
+
watch run in the foreground, one pass every --interval seconds
|
|
19
|
+
once a single pass — what the scheduler runs
|
|
20
|
+
install register a background job (launchd on macOS, systemd on Linux)
|
|
21
|
+
uninstall remove it
|
|
22
|
+
doctor check tmux, Claude Code panes, and the scheduler
|
|
23
|
+
logs show recent actions
|
|
24
|
+
|
|
25
|
+
OPTIONS
|
|
26
|
+
--interval <s> seconds between passes (default 15)
|
|
27
|
+
--dry-run report what would be sent, send nothing
|
|
28
|
+
--json machine-readable output
|
|
29
|
+
--lang en|ja output language (default: from LANG)
|
|
30
|
+
-h, --help this text
|
|
31
|
+
-v, --version print version
|
|
32
|
+
|
|
33
|
+
Remote Control retries 5 times over ~31 seconds and then gives up for good.
|
|
34
|
+
cckeep watches tmux panes running Claude Code and types /remote-control
|
|
35
|
+
into the ones that went dead — never into one that is busy or showing a dialog.
|
|
36
|
+
`;
|
|
37
|
+
|
|
38
|
+
function parseArgs(argv) {
|
|
39
|
+
const opts = { command: null, dryRun: false, json: false, lang: null, interval: null };
|
|
40
|
+
for (let i = 0; i < argv.length; i++) {
|
|
41
|
+
const a = argv[i];
|
|
42
|
+
if (a === '--dry-run') opts.dryRun = true;
|
|
43
|
+
else if (a === '--json') opts.json = true;
|
|
44
|
+
else if (a === '--lang') opts.lang = argv[++i];
|
|
45
|
+
else if (a === '--interval') opts.interval = Number(argv[++i]);
|
|
46
|
+
else if (a === '-h' || a === '--help') opts.command = 'help';
|
|
47
|
+
else if (a === '-v' || a === '--version') opts.command = 'version';
|
|
48
|
+
else if (!a.startsWith('-') && !opts.command) opts.command = a;
|
|
49
|
+
}
|
|
50
|
+
return opts;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function version() {
|
|
54
|
+
const p = new URL('../package.json', import.meta.url);
|
|
55
|
+
return JSON.parse(readFileSync(p, 'utf8')).version;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const REASON_KEY = {
|
|
59
|
+
connected: 'connected',
|
|
60
|
+
retrying: 'retrying',
|
|
61
|
+
waiting: 'waiting',
|
|
62
|
+
'never-connected': 'neverConnected',
|
|
63
|
+
dialog: 'dialog',
|
|
64
|
+
cooldown: 'cooldown',
|
|
65
|
+
busy: 'busy',
|
|
66
|
+
recovered: 'recovered',
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
function describe(result, t) {
|
|
70
|
+
if (result.action === 'rearm') return `${t.rearmed} (${result.reason})`;
|
|
71
|
+
if (result.action === 'confirm-panel') return t.confirmed;
|
|
72
|
+
if (result.action === 'would-rearm') return `${t.wouldRearm} (${result.reason})`;
|
|
73
|
+
if (result.action === 'would-confirm-panel') return t.wouldConfirm;
|
|
74
|
+
return t[REASON_KEY[result.reason]] ?? result.reason;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function render(out, t, opts) {
|
|
78
|
+
if (opts.json) {
|
|
79
|
+
console.log(JSON.stringify(out, null, 2));
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
if (out.error === 'no-tmux') return console.log(t.noTmux);
|
|
83
|
+
if (out.error === 'no-server') return console.log(t.noServer);
|
|
84
|
+
if (!out.results.length) {
|
|
85
|
+
console.log(t.noPanes);
|
|
86
|
+
console.log(t.hintOutside);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
for (const r of out.results) {
|
|
90
|
+
console.log(`${r.pane.padEnd(14)}${describe(r, t)}`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function main() {
|
|
95
|
+
const opts = parseArgs(process.argv.slice(2));
|
|
96
|
+
const lang = pickLang(opts.lang);
|
|
97
|
+
const t = strings(lang);
|
|
98
|
+
|
|
99
|
+
if (opts.command === 'help') return console.log(HELP);
|
|
100
|
+
if (opts.command === 'version') return console.log(version());
|
|
101
|
+
|
|
102
|
+
let config;
|
|
103
|
+
try {
|
|
104
|
+
config = loadConfig(opts.interval ? { interval: opts.interval } : {});
|
|
105
|
+
} catch (err) {
|
|
106
|
+
console.error(err.message);
|
|
107
|
+
process.exit(1);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
switch (opts.command ?? 'status') {
|
|
111
|
+
case 'status': {
|
|
112
|
+
const out = await runPass({ config, dryRun: true });
|
|
113
|
+
render(out, t, opts);
|
|
114
|
+
break;
|
|
115
|
+
}
|
|
116
|
+
case 'once': {
|
|
117
|
+
const out = await runPass({ config, dryRun: opts.dryRun });
|
|
118
|
+
render(out, t, opts);
|
|
119
|
+
break;
|
|
120
|
+
}
|
|
121
|
+
case 'watch': {
|
|
122
|
+
if (!opts.json) console.log(t.watching(config.interval));
|
|
123
|
+
for (;;) {
|
|
124
|
+
const out = await runPass({ config, dryRun: opts.dryRun });
|
|
125
|
+
for (const r of out.results) {
|
|
126
|
+
if (r.action !== 'none') console.log(`${new Date().toISOString().slice(11, 19)} ${r.pane} ${describe(r, t)}`);
|
|
127
|
+
}
|
|
128
|
+
await new Promise((r) => setTimeout(r, config.interval * 1000));
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
case 'install': {
|
|
132
|
+
const res = scheduler.install({ interval: config.interval });
|
|
133
|
+
if (res.kind === 'unsupported') {
|
|
134
|
+
console.log(t.unsupported);
|
|
135
|
+
process.exit(1);
|
|
136
|
+
}
|
|
137
|
+
console.log(res.ok ? t.installed(res.kind, res.path) : t.installFailed(res.kind));
|
|
138
|
+
if (!res.ok) process.exit(1);
|
|
139
|
+
break;
|
|
140
|
+
}
|
|
141
|
+
case 'uninstall': {
|
|
142
|
+
const res = scheduler.uninstall();
|
|
143
|
+
if (res.kind === 'unsupported') return console.log(t.unsupported);
|
|
144
|
+
console.log(t.uninstalled(res.kind));
|
|
145
|
+
break;
|
|
146
|
+
}
|
|
147
|
+
case 'doctor': {
|
|
148
|
+
const bin = tmux.tmuxPath();
|
|
149
|
+
const panes = bin && tmux.hasServer() ? tmux.listPanes() : [];
|
|
150
|
+
const claudePanes = panes.filter((p) => p.command === config.paneCommand);
|
|
151
|
+
const rows = [
|
|
152
|
+
['platform', platform()],
|
|
153
|
+
['node', process.version],
|
|
154
|
+
['tmux', bin || t.doctorFail],
|
|
155
|
+
['tmux server', bin && tmux.hasServer() ? t.doctorOk : t.doctorFail],
|
|
156
|
+
['claude panes', String(claudePanes.length)],
|
|
157
|
+
['scheduler', scheduler.isInstalled() ? t.doctorOk : t.doctorFail],
|
|
158
|
+
['home', homeDir()],
|
|
159
|
+
['config', existsSync(configPath()) ? configPath() : `${configPath()} (defaults)`],
|
|
160
|
+
['state', statePath()],
|
|
161
|
+
['log', logPath()],
|
|
162
|
+
];
|
|
163
|
+
if (opts.json) console.log(JSON.stringify(Object.fromEntries(rows), null, 2));
|
|
164
|
+
else for (const [k, v] of rows) console.log(`${k.padEnd(14)}${v}`);
|
|
165
|
+
if (!bin) process.exitCode = 1;
|
|
166
|
+
break;
|
|
167
|
+
}
|
|
168
|
+
case 'logs': {
|
|
169
|
+
const p = logPath();
|
|
170
|
+
if (!existsSync(p)) return console.log(t.noLog);
|
|
171
|
+
const lines = readFileSync(p, 'utf8').trim().split('\n');
|
|
172
|
+
console.log(lines.slice(-40).join('\n'));
|
|
173
|
+
break;
|
|
174
|
+
}
|
|
175
|
+
default:
|
|
176
|
+
console.error(`Unknown command: ${opts.command}\n`);
|
|
177
|
+
console.log(HELP);
|
|
178
|
+
process.exit(1);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
main().catch((err) => {
|
|
183
|
+
console.error(err?.message || err);
|
|
184
|
+
process.exit(1);
|
|
185
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "cckeep",
|
|
3
|
+
"version": "0.1.0",
|
|
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
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"cckeep": "bin/cckeep.js"
|
|
8
|
+
},
|
|
9
|
+
"main": "src/detect.js",
|
|
10
|
+
"files": [
|
|
11
|
+
"bin",
|
|
12
|
+
"src",
|
|
13
|
+
"README.md",
|
|
14
|
+
"LICENSE"
|
|
15
|
+
],
|
|
16
|
+
"engines": {
|
|
17
|
+
"node": ">=18"
|
|
18
|
+
},
|
|
19
|
+
"scripts": {
|
|
20
|
+
"test": "node --test test/*.test.js"
|
|
21
|
+
},
|
|
22
|
+
"keywords": [
|
|
23
|
+
"claude-code",
|
|
24
|
+
"remote-control",
|
|
25
|
+
"tmux",
|
|
26
|
+
"watchdog",
|
|
27
|
+
"reconnect",
|
|
28
|
+
"coding-agent",
|
|
29
|
+
"agent",
|
|
30
|
+
"cli",
|
|
31
|
+
"launchd",
|
|
32
|
+
"systemd"
|
|
33
|
+
],
|
|
34
|
+
"author": "kamihork <kmihoken@gmail.com>",
|
|
35
|
+
"repository": {
|
|
36
|
+
"type": "git",
|
|
37
|
+
"url": "git+https://github.com/kamihork/cckeep.git"
|
|
38
|
+
},
|
|
39
|
+
"homepage": "https://github.com/kamihork/cckeep#readme",
|
|
40
|
+
"bugs": {
|
|
41
|
+
"url": "https://github.com/kamihork/cckeep/issues"
|
|
42
|
+
},
|
|
43
|
+
"license": "MIT"
|
|
44
|
+
}
|
package/src/config.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { homeDir } from './state.js';
|
|
4
|
+
import { DEFAULTS } from './detect.js';
|
|
5
|
+
|
|
6
|
+
export const BASE = {
|
|
7
|
+
...DEFAULTS,
|
|
8
|
+
/** Seconds between passes in watch mode, and what `install` schedules. */
|
|
9
|
+
interval: 15,
|
|
10
|
+
/** Milliseconds between the two captures of the idle check. */
|
|
11
|
+
settle: 2000,
|
|
12
|
+
/** Milliseconds between typing the command and pressing Enter. */
|
|
13
|
+
keyDelay: 1000,
|
|
14
|
+
/** Foreground process name that marks a pane as Claude Code. */
|
|
15
|
+
paneCommand: 'claude',
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const NUMERIC = new Set(['stuckLimit', 'missLimit', 'cooldown', 'interval', 'settle', 'keyDelay']);
|
|
19
|
+
|
|
20
|
+
const ENV = {
|
|
21
|
+
CCKEEP_INTERVAL: 'interval',
|
|
22
|
+
CCKEEP_COOLDOWN: 'cooldown',
|
|
23
|
+
CCKEEP_STUCK_LIMIT: 'stuckLimit',
|
|
24
|
+
CCKEEP_MISS_LIMIT: 'missLimit',
|
|
25
|
+
CCKEEP_SETTLE: 'settle',
|
|
26
|
+
CCKEEP_KEY_DELAY: 'keyDelay',
|
|
27
|
+
CCKEEP_PANE_COMMAND: 'paneCommand',
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export function configPath() {
|
|
31
|
+
return join(homeDir(), 'config.json');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Precedence: built-in defaults < config file < environment < CLI flags. */
|
|
35
|
+
export function loadConfig(overrides = {}) {
|
|
36
|
+
let fromFile = {};
|
|
37
|
+
const p = configPath();
|
|
38
|
+
if (existsSync(p)) {
|
|
39
|
+
try {
|
|
40
|
+
fromFile = JSON.parse(readFileSync(p, 'utf8')) || {};
|
|
41
|
+
} catch (err) {
|
|
42
|
+
// Silently running with half the settings dropped is worse than stopping.
|
|
43
|
+
throw new Error(`${p} is not valid JSON: ${err.message}`);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const fromEnv = {};
|
|
48
|
+
for (const [key, name] of Object.entries(ENV)) {
|
|
49
|
+
const raw = process.env[key];
|
|
50
|
+
if (raw !== undefined && raw !== '') fromEnv[name] = raw;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const merged = { ...BASE, ...fromFile, ...fromEnv, ...overrides };
|
|
54
|
+
for (const key of NUMERIC) {
|
|
55
|
+
const value = Number(merged[key]);
|
|
56
|
+
if (!Number.isFinite(value) || value < 0) throw new Error(`config: ${key} must be a non-negative number`);
|
|
57
|
+
merged[key] = value;
|
|
58
|
+
}
|
|
59
|
+
return merged;
|
|
60
|
+
}
|
package/src/detect.js
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// The decision core. Deliberately pure: it takes what is on screen plus what we
|
|
2
|
+
// remembered about a pane, and returns what to do. No tmux, no clock, no disk —
|
|
3
|
+
// so every rule below is directly testable, which matters a lot for a tool whose
|
|
4
|
+
// failure mode is typing into someone's terminal at the wrong moment.
|
|
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/;
|
|
10
|
+
|
|
11
|
+
/** The /remote-control status panel — its first entry is "Disconnect this session". */
|
|
12
|
+
const PANEL = /Disconnect this session|(?:Show|Hide) QR code/;
|
|
13
|
+
|
|
14
|
+
/**
|
|
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.
|
|
17
|
+
*/
|
|
18
|
+
const MODAL = /(?:❯|›)\s*(?:\d+\.|Yes|No)|Do you want|\(y\/n\)/;
|
|
19
|
+
|
|
20
|
+
export const DEFAULTS = {
|
|
21
|
+
/** Consecutive checks stuck in "reconnecting" before we treat the bridge as wedged. */
|
|
22
|
+
stuckLimit: 8,
|
|
23
|
+
/** Consecutive checks with no indicator at all before re-arming a pane that had one. */
|
|
24
|
+
missLimit: 4,
|
|
25
|
+
/** Seconds before the same pane may be acted on again. */
|
|
26
|
+
cooldown: 300,
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export function emptyState() {
|
|
30
|
+
return { seen: false, miss: 0, stuck: 0, panelPending: false, lastActionAt: 0 };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Reduce a captured pane to the handful of signals the rules care about.
|
|
35
|
+
* @param {string} screen raw `tmux capture-pane -p` output
|
|
36
|
+
*/
|
|
37
|
+
export function readScreen(screen) {
|
|
38
|
+
return {
|
|
39
|
+
connected: screen.includes(CONNECTED),
|
|
40
|
+
retrying: screen.includes(RETRYING),
|
|
41
|
+
failed: FAILED.test(screen),
|
|
42
|
+
panel: PANEL.test(screen),
|
|
43
|
+
modal: MODAL.test(screen),
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Decide what to do with one pane.
|
|
49
|
+
*
|
|
50
|
+
* Returns `{ action, reason, state }` where action is one of:
|
|
51
|
+
* 'none' — leave the pane alone
|
|
52
|
+
* 'confirm-panel' — press Enter on the /remote-control panel we opened, which
|
|
53
|
+
* selects "Disconnect this session" and tears down the
|
|
54
|
+
* wedged bridge so the next pass can build a fresh one
|
|
55
|
+
* 'rearm' — type /remote-control
|
|
56
|
+
*
|
|
57
|
+
* `rearm` is a recommendation, not a green light: the caller must still confirm
|
|
58
|
+
* the pane is idle (see isIdle in run.js) before typing, because a running turn
|
|
59
|
+
* would swallow the command as a chat message.
|
|
60
|
+
*/
|
|
61
|
+
export function decide({ screen, state = emptyState(), now = 0, config = {} }) {
|
|
62
|
+
const cfg = { ...DEFAULTS, ...config };
|
|
63
|
+
const s = readScreen(screen);
|
|
64
|
+
const next = { ...state };
|
|
65
|
+
|
|
66
|
+
// Step two of the wedged-bridge recovery. We opened the panel on an earlier
|
|
67
|
+
// pass, so "Disconnect this session" should now be focused. Confirm it from
|
|
68
|
+
// the text actually on screen rather than by counting arrow keys — and only
|
|
69
|
+
// when we are the one who opened it, since the user may have opened the same
|
|
70
|
+
// panel to read the QR code.
|
|
71
|
+
if (next.panelPending) {
|
|
72
|
+
next.panelPending = false;
|
|
73
|
+
if (s.panel && !s.modal) {
|
|
74
|
+
next.lastActionAt = 0; // let the next pass re-arm without waiting out the cooldown
|
|
75
|
+
return { action: 'confirm-panel', reason: 'stuck-cycle', state: next };
|
|
76
|
+
}
|
|
77
|
+
// The panel never appeared: the command we sent probably reconnected
|
|
78
|
+
// directly. Fall through and judge the pane on what it shows now.
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (s.connected) {
|
|
82
|
+
next.seen = true;
|
|
83
|
+
next.miss = 0;
|
|
84
|
+
next.stuck = 0;
|
|
85
|
+
return { action: 'none', reason: 'connected', state: next };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
let dead = null;
|
|
89
|
+
|
|
90
|
+
if (s.retrying) {
|
|
91
|
+
// Claude Code's own retry budget is 5 attempts over roughly 31 seconds, and
|
|
92
|
+
// a healthy reconnect resolves well inside that. Sitting in "reconnecting"
|
|
93
|
+
// far past it is the wedge from anthropics/claude-code#34255, which never
|
|
94
|
+
// recovers on its own.
|
|
95
|
+
next.stuck += 1;
|
|
96
|
+
if (next.stuck < cfg.stuckLimit) {
|
|
97
|
+
return { action: 'none', reason: 'retrying', state: next };
|
|
98
|
+
}
|
|
99
|
+
next.stuck = 0;
|
|
100
|
+
dead = 'stuck';
|
|
101
|
+
} else {
|
|
102
|
+
next.stuck = 0;
|
|
103
|
+
if (s.failed) {
|
|
104
|
+
next.miss = 0;
|
|
105
|
+
dead = 'disconnected';
|
|
106
|
+
} else if (!next.seen) {
|
|
107
|
+
// Never seen connected. Could be a session the user runs without Remote
|
|
108
|
+
// Control on purpose — not ours to switch on.
|
|
109
|
+
return { action: 'none', reason: 'never-connected', state: next };
|
|
110
|
+
} else {
|
|
111
|
+
next.miss += 1;
|
|
112
|
+
if (next.miss < cfg.missLimit) {
|
|
113
|
+
return { action: 'none', reason: 'waiting', state: next };
|
|
114
|
+
}
|
|
115
|
+
next.miss = 0;
|
|
116
|
+
dead = 'silent';
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (s.modal || s.panel) {
|
|
121
|
+
return { action: 'none', reason: 'dialog', state: next };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (state.lastActionAt && now - state.lastActionAt < cfg.cooldown) {
|
|
125
|
+
return { action: 'none', reason: 'cooldown', state: next };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
next.lastActionAt = now;
|
|
129
|
+
if (dead === 'stuck') {
|
|
130
|
+
// A wedged bridge still believes it is connecting, so /remote-control opens
|
|
131
|
+
// the status panel instead of retrying. Remember that, and finish the cycle
|
|
132
|
+
// next pass once the panel is visible.
|
|
133
|
+
next.panelPending = true;
|
|
134
|
+
}
|
|
135
|
+
return { action: 'rearm', reason: dead, state: next };
|
|
136
|
+
}
|
package/src/i18n.js
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
const en = {
|
|
2
|
+
noTmux: 'tmux not found. cckeep reads and types into tmux panes, so tmux is required.',
|
|
3
|
+
noServer: 'No tmux server is running — nothing to watch.',
|
|
4
|
+
noPanes: 'No tmux pane is running Claude Code.',
|
|
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
|
+
connected: 'connected',
|
|
8
|
+
retrying: 'retrying',
|
|
9
|
+
waiting: 'no indicator',
|
|
10
|
+
neverConnected: 'not connected',
|
|
11
|
+
dialog: 'dialog open',
|
|
12
|
+
cooldown: 'cooling down',
|
|
13
|
+
busy: 'busy',
|
|
14
|
+
recovered: 'recovered',
|
|
15
|
+
rearmed: 're-armed',
|
|
16
|
+
confirmed: 'bridge cycled',
|
|
17
|
+
wouldRearm: 'would re-arm',
|
|
18
|
+
wouldConfirm: 'would cycle bridge',
|
|
19
|
+
watching: (n) => `Watching every ${n}s. Ctrl+C to stop.`,
|
|
20
|
+
installed: (kind, path) => `Installed (${kind}): ${path}`,
|
|
21
|
+
installFailed: (kind) => `Wrote the ${kind} unit, but could not load it. Load it manually, or run \`cckeep watch\` in a terminal.`,
|
|
22
|
+
uninstalled: (kind) => `Removed the ${kind} job.`,
|
|
23
|
+
unsupported: 'Automatic scheduling supports macOS (launchd) and Linux (systemd user timers). Elsewhere, run `cckeep watch` under your own supervisor.',
|
|
24
|
+
noLog: 'No log yet.',
|
|
25
|
+
doctorOk: 'ok',
|
|
26
|
+
doctorFail: 'missing',
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const ja = {
|
|
30
|
+
noTmux: 'tmux が見つかりません。cckeep は tmux のペインを読んで入力するため、tmux が必須です。',
|
|
31
|
+
noServer: 'tmux サーバーが起動していません。監視対象がありません。',
|
|
32
|
+
noPanes: 'Claude Code が動いている tmux ペインがありません。',
|
|
33
|
+
hintOutside: 'tmux の外で起動した Claude Code には届きません。別プロセスから入力を送る手段がないためです。',
|
|
34
|
+
header: 'ペイン 状態 詳細',
|
|
35
|
+
connected: '接続中',
|
|
36
|
+
retrying: '再試行中',
|
|
37
|
+
waiting: '表示なし',
|
|
38
|
+
neverConnected: '未接続',
|
|
39
|
+
dialog: 'ダイアログ表示中',
|
|
40
|
+
cooldown: 'クールダウン中',
|
|
41
|
+
busy: '実行中',
|
|
42
|
+
recovered: '復帰済み',
|
|
43
|
+
rearmed: '再接続を送信',
|
|
44
|
+
confirmed: 'ブリッジを張り直し',
|
|
45
|
+
wouldRearm: '再接続を送信する',
|
|
46
|
+
wouldConfirm: 'ブリッジを張り直す',
|
|
47
|
+
watching: (n) => `${n} 秒ごとに監視します。Ctrl+C で停止。`,
|
|
48
|
+
installed: (kind, path) => `登録しました (${kind}): ${path}`,
|
|
49
|
+
installFailed: (kind) => `${kind} の定義は書きましたが、読み込みに失敗しました。手動で読み込むか、ターミナルで \`cckeep watch\` を実行してください。`,
|
|
50
|
+
uninstalled: (kind) => `${kind} のジョブを削除しました。`,
|
|
51
|
+
unsupported: '自動スケジュール登録は macOS (launchd) と Linux (systemd user timer) に対応しています。それ以外では `cckeep watch` を任意の常駐手段で動かしてください。',
|
|
52
|
+
noLog: 'ログはまだありません。',
|
|
53
|
+
doctorOk: 'ok',
|
|
54
|
+
doctorFail: '未検出',
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export function pickLang(explicit) {
|
|
58
|
+
if (explicit === 'en' || explicit === 'ja') return explicit;
|
|
59
|
+
const env = process.env.CCKEEP_LANG || process.env.LC_ALL || process.env.LANG || '';
|
|
60
|
+
return /^ja/i.test(env) ? 'ja' : 'en';
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function strings(lang) {
|
|
64
|
+
return lang === 'ja' ? ja : en;
|
|
65
|
+
}
|
package/src/run.js
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { decide, readScreen } from './detect.js';
|
|
2
|
+
import * as realTmux from './tmux.js';
|
|
3
|
+
import { loadState, saveState, forPane, prune, appendLog } from './state.js';
|
|
4
|
+
|
|
5
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Is the pane quiet enough to type into?
|
|
9
|
+
*
|
|
10
|
+
* A running turn animates a spinner and a token counter, so two byte-identical
|
|
11
|
+
* captures a couple of seconds apart is a cheap and reliable "nothing is
|
|
12
|
+
* happening here" signal — and it needs no knowledge of Claude Code's changing
|
|
13
|
+
* status-line wording.
|
|
14
|
+
*/
|
|
15
|
+
export async function isIdle(tmux, paneId, settle) {
|
|
16
|
+
const a = tmux.capture(paneId);
|
|
17
|
+
await sleep(settle);
|
|
18
|
+
const b = tmux.capture(paneId);
|
|
19
|
+
return a.length > 0 && a === b;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* One pass over every tmux pane running Claude Code.
|
|
24
|
+
*
|
|
25
|
+
* @returns {Promise<{results: Array, acted: number}>} one result per candidate
|
|
26
|
+
* pane, for the CLI to render. Nothing is written when dryRun is set.
|
|
27
|
+
*/
|
|
28
|
+
export async function runPass({ tmux = realTmux, config, dryRun = false, now = Math.floor(Date.now() / 1000) } = {}) {
|
|
29
|
+
const results = [];
|
|
30
|
+
if (!tmux.tmuxPath()) return { results, acted: 0, error: 'no-tmux' };
|
|
31
|
+
if (!tmux.hasServer()) return { results, acted: 0, error: 'no-server' };
|
|
32
|
+
|
|
33
|
+
const panes = tmux.listPanes();
|
|
34
|
+
const state = loadState();
|
|
35
|
+
let acted = 0;
|
|
36
|
+
|
|
37
|
+
for (const pane of panes) {
|
|
38
|
+
if (pane.command !== config.paneCommand) continue;
|
|
39
|
+
|
|
40
|
+
const screen = tmux.capture(pane.id);
|
|
41
|
+
if (!screen) continue;
|
|
42
|
+
|
|
43
|
+
const before = forPane(state, pane.id);
|
|
44
|
+
const { action, reason, state: after } = decide({ screen, state: before, now, config });
|
|
45
|
+
state[pane.id] = after;
|
|
46
|
+
|
|
47
|
+
const result = { pane: pane.label, id: pane.id, action, reason, signals: readScreen(screen) };
|
|
48
|
+
|
|
49
|
+
if (action === 'none') {
|
|
50
|
+
results.push(result);
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (dryRun) {
|
|
55
|
+
result.action = `would-${action}`;
|
|
56
|
+
results.push(result);
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (action === 'confirm-panel') {
|
|
61
|
+
tmux.sendEnter(pane.id);
|
|
62
|
+
appendLog(`${pane.label}: closing wedged bridge via panel`);
|
|
63
|
+
acted += 1;
|
|
64
|
+
results.push(result);
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// action === 'rearm'. The decision was made from a single capture; make
|
|
69
|
+
// sure nothing has started running in the meantime before typing.
|
|
70
|
+
if (!(await isIdle(tmux, pane.id, config.settle))) {
|
|
71
|
+
result.action = 'none';
|
|
72
|
+
result.reason = 'busy';
|
|
73
|
+
state[pane.id] = { ...after, lastActionAt: before.lastActionAt, panelPending: before.panelPending };
|
|
74
|
+
results.push(result);
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Re-read: the pane may have reconnected or opened a dialog while we waited.
|
|
79
|
+
const recheck = readScreen(tmux.capture(pane.id));
|
|
80
|
+
if (recheck.connected || recheck.modal || recheck.panel) {
|
|
81
|
+
result.action = 'none';
|
|
82
|
+
result.reason = recheck.connected ? 'recovered' : 'dialog';
|
|
83
|
+
state[pane.id] = { ...after, lastActionAt: before.lastActionAt, panelPending: before.panelPending };
|
|
84
|
+
results.push(result);
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
tmux.sendText(pane.id, '/remote-control');
|
|
89
|
+
await sleep(config.keyDelay ?? 1000);
|
|
90
|
+
tmux.sendEnter(pane.id);
|
|
91
|
+
appendLog(`${pane.label}: re-arming Remote Control (${reason})`);
|
|
92
|
+
acted += 1;
|
|
93
|
+
results.push(result);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (!dryRun) saveState(prune(state, panes.map((p) => p.id)));
|
|
97
|
+
return { results, acted };
|
|
98
|
+
}
|
package/src/scheduler.js
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { mkdirSync, writeFileSync, existsSync, unlinkSync } from 'node:fs';
|
|
3
|
+
import { homedir, platform } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
|
|
7
|
+
export const LABEL = 'io.github.kamihork.cckeep';
|
|
8
|
+
|
|
9
|
+
function cliPath() {
|
|
10
|
+
return fileURLToPath(new URL('../bin/cckeep.js', import.meta.url));
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function nodePath() {
|
|
14
|
+
return process.execPath;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function plistPath() {
|
|
18
|
+
return join(homedir(), 'Library', 'LaunchAgents', `${LABEL}.plist`);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function systemdDir() {
|
|
22
|
+
return join(process.env.XDG_CONFIG_HOME || join(homedir(), '.config'), 'systemd', 'user');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function renderPlist({ interval, node = nodePath(), cli = cliPath(), home }) {
|
|
26
|
+
const env = home
|
|
27
|
+
? ` <key>EnvironmentVariables</key>\n <dict>\n <key>CCKEEP_HOME</key>\n <string>${home}</string>\n </dict>\n`
|
|
28
|
+
: '';
|
|
29
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
30
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
31
|
+
<plist version="1.0">
|
|
32
|
+
<dict>
|
|
33
|
+
<key>Label</key>
|
|
34
|
+
<string>${LABEL}</string>
|
|
35
|
+
<key>ProgramArguments</key>
|
|
36
|
+
<array>
|
|
37
|
+
<string>${node}</string>
|
|
38
|
+
<string>${cli}</string>
|
|
39
|
+
<string>once</string>
|
|
40
|
+
</array>
|
|
41
|
+
<key>StartInterval</key>
|
|
42
|
+
<integer>${interval}</integer>
|
|
43
|
+
<key>RunAtLoad</key>
|
|
44
|
+
<true/>
|
|
45
|
+
<key>ProcessType</key>
|
|
46
|
+
<string>Background</string>
|
|
47
|
+
${env}</dict>
|
|
48
|
+
</plist>
|
|
49
|
+
`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function renderSystemdService({ node = nodePath(), cli = cliPath() }) {
|
|
53
|
+
return `[Unit]
|
|
54
|
+
Description=cckeep — keep Claude Code Remote Control alive
|
|
55
|
+
|
|
56
|
+
[Service]
|
|
57
|
+
Type=oneshot
|
|
58
|
+
ExecStart=${node} ${cli} once
|
|
59
|
+
`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function renderSystemdTimer({ interval }) {
|
|
63
|
+
return `[Unit]
|
|
64
|
+
Description=cckeep — keep Claude Code Remote Control alive
|
|
65
|
+
|
|
66
|
+
[Timer]
|
|
67
|
+
OnBootSec=${interval}s
|
|
68
|
+
OnUnitActiveSec=${interval}s
|
|
69
|
+
AccuracySec=1s
|
|
70
|
+
|
|
71
|
+
[Install]
|
|
72
|
+
WantedBy=timers.target
|
|
73
|
+
`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function run(cmd, args) {
|
|
77
|
+
try {
|
|
78
|
+
execFileSync(cmd, args, { stdio: ['ignore', 'ignore', 'ignore'] });
|
|
79
|
+
return true;
|
|
80
|
+
} catch {
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function install({ interval }) {
|
|
86
|
+
const os = platform();
|
|
87
|
+
if (os === 'darwin') {
|
|
88
|
+
const p = plistPath();
|
|
89
|
+
mkdirSync(join(homedir(), 'Library', 'LaunchAgents'), { recursive: true });
|
|
90
|
+
writeFileSync(p, renderPlist({ interval, home: process.env.CCKEEP_HOME }));
|
|
91
|
+
const uid = process.getuid();
|
|
92
|
+
run('launchctl', ['bootout', `gui/${uid}/${LABEL}`]);
|
|
93
|
+
const ok = run('launchctl', ['bootstrap', `gui/${uid}`, p]);
|
|
94
|
+
return { kind: 'launchd', path: p, ok };
|
|
95
|
+
}
|
|
96
|
+
if (os === 'linux') {
|
|
97
|
+
const dir = systemdDir();
|
|
98
|
+
mkdirSync(dir, { recursive: true });
|
|
99
|
+
writeFileSync(join(dir, 'cckeep.service'), renderSystemdService({}));
|
|
100
|
+
writeFileSync(join(dir, 'cckeep.timer'), renderSystemdTimer({ interval }));
|
|
101
|
+
run('systemctl', ['--user', 'daemon-reload']);
|
|
102
|
+
const ok = run('systemctl', ['--user', 'enable', '--now', 'cckeep.timer']);
|
|
103
|
+
return { kind: 'systemd', path: dir, ok };
|
|
104
|
+
}
|
|
105
|
+
return { kind: 'unsupported', path: null, ok: false };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function uninstall() {
|
|
109
|
+
const os = platform();
|
|
110
|
+
if (os === 'darwin') {
|
|
111
|
+
const p = plistPath();
|
|
112
|
+
run('launchctl', ['bootout', `gui/${process.getuid()}/${LABEL}`]);
|
|
113
|
+
if (existsSync(p)) unlinkSync(p);
|
|
114
|
+
return { kind: 'launchd', path: p, ok: true };
|
|
115
|
+
}
|
|
116
|
+
if (os === 'linux') {
|
|
117
|
+
const dir = systemdDir();
|
|
118
|
+
run('systemctl', ['--user', 'disable', '--now', 'cckeep.timer']);
|
|
119
|
+
for (const f of ['cckeep.timer', 'cckeep.service']) {
|
|
120
|
+
const p = join(dir, f);
|
|
121
|
+
if (existsSync(p)) unlinkSync(p);
|
|
122
|
+
}
|
|
123
|
+
run('systemctl', ['--user', 'daemon-reload']);
|
|
124
|
+
return { kind: 'systemd', path: dir, ok: true };
|
|
125
|
+
}
|
|
126
|
+
return { kind: 'unsupported', path: null, ok: false };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function isInstalled() {
|
|
130
|
+
const os = platform();
|
|
131
|
+
if (os === 'darwin') return existsSync(plistPath());
|
|
132
|
+
if (os === 'linux') return existsSync(join(systemdDir(), 'cckeep.timer'));
|
|
133
|
+
return false;
|
|
134
|
+
}
|
package/src/state.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, writeFileSync, existsSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { join, dirname } from 'node:path';
|
|
4
|
+
import { emptyState } from './detect.js';
|
|
5
|
+
|
|
6
|
+
export function homeDir() {
|
|
7
|
+
return process.env.CCKEEP_HOME || join(homedir(), '.cckeep');
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function statePath() {
|
|
11
|
+
return join(homeDir(), 'state.json');
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function logPath() {
|
|
15
|
+
return join(homeDir(), 'cckeep.log');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function loadState() {
|
|
19
|
+
const p = statePath();
|
|
20
|
+
if (!existsSync(p)) return {};
|
|
21
|
+
try {
|
|
22
|
+
const parsed = JSON.parse(readFileSync(p, 'utf8'));
|
|
23
|
+
return parsed && typeof parsed === 'object' ? parsed : {};
|
|
24
|
+
} catch {
|
|
25
|
+
// A corrupt state file must never stop the watchdog: the worst it costs is
|
|
26
|
+
// one forgotten pane, and every counter rebuilds within a few passes.
|
|
27
|
+
return {};
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function saveState(state) {
|
|
32
|
+
const p = statePath();
|
|
33
|
+
mkdirSync(dirname(p), { recursive: true });
|
|
34
|
+
writeFileSync(p, JSON.stringify(state, null, 2));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function forPane(state, paneId) {
|
|
38
|
+
return { ...emptyState(), ...(state[paneId] || {}) };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Forget panes that no longer exist, so state.json cannot grow without bound. */
|
|
42
|
+
export function prune(state, livePaneIds) {
|
|
43
|
+
const live = new Set(livePaneIds);
|
|
44
|
+
const out = {};
|
|
45
|
+
for (const [id, value] of Object.entries(state)) if (live.has(id)) out[id] = value;
|
|
46
|
+
return out;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function appendLog(line) {
|
|
50
|
+
try {
|
|
51
|
+
const p = logPath();
|
|
52
|
+
mkdirSync(dirname(p), { recursive: true });
|
|
53
|
+
const stamp = new Date().toISOString().replace('T', ' ').slice(0, 19);
|
|
54
|
+
writeFileSync(p, `[${stamp}] ${line}\n`, { flag: 'a' });
|
|
55
|
+
} catch {}
|
|
56
|
+
}
|
package/src/tmux.js
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
|
|
4
|
+
// Schedulers run with a minimal PATH — cron notoriously omits /opt/homebrew/bin,
|
|
5
|
+
// and launchd is no better. Resolving tmux to an absolute path here is what
|
|
6
|
+
// keeps a scheduled run from silently finding nothing to do.
|
|
7
|
+
const CANDIDATES = [
|
|
8
|
+
'/opt/homebrew/bin/tmux',
|
|
9
|
+
'/usr/local/bin/tmux',
|
|
10
|
+
'/usr/bin/tmux',
|
|
11
|
+
'/bin/tmux',
|
|
12
|
+
'/opt/local/bin/tmux',
|
|
13
|
+
'/home/linuxbrew/.linuxbrew/bin/tmux',
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
let cached;
|
|
17
|
+
|
|
18
|
+
export function tmuxPath() {
|
|
19
|
+
if (cached) return cached;
|
|
20
|
+
const fromEnv = process.env.CCKEEP_TMUX;
|
|
21
|
+
if (fromEnv && existsSync(fromEnv)) return (cached = fromEnv);
|
|
22
|
+
for (const p of CANDIDATES) if (existsSync(p)) return (cached = p);
|
|
23
|
+
try {
|
|
24
|
+
const found = execFileSync('command', ['-v', 'tmux'], { shell: true, encoding: 'utf8' }).trim();
|
|
25
|
+
if (found && existsSync(found)) return (cached = found);
|
|
26
|
+
} catch {}
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function tmux(args, { allowFail = true } = {}) {
|
|
31
|
+
const bin = tmuxPath();
|
|
32
|
+
if (!bin) return null;
|
|
33
|
+
try {
|
|
34
|
+
return execFileSync(bin, args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
|
|
35
|
+
} catch (err) {
|
|
36
|
+
if (allowFail) return null;
|
|
37
|
+
throw err;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function hasServer() {
|
|
42
|
+
return tmux(['has-session']) !== null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Every pane on the server, with the process currently in the foreground. */
|
|
46
|
+
export function listPanes() {
|
|
47
|
+
const out = tmux(['list-panes', '-a', '-F', '#{pane_id}\t#{pane_current_command}\t#{session_name}\t#{window_index}\t#{pane_index}']);
|
|
48
|
+
if (!out) return [];
|
|
49
|
+
return out
|
|
50
|
+
.split('\n')
|
|
51
|
+
.filter(Boolean)
|
|
52
|
+
.map((line) => {
|
|
53
|
+
const [id, command, session, windowIndex, paneIndex] = line.split('\t');
|
|
54
|
+
return { id, command, session, windowIndex, paneIndex, label: `${session}:${windowIndex}.${paneIndex}` };
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function capture(paneId) {
|
|
59
|
+
return tmux(['capture-pane', '-p', '-t', paneId]) ?? '';
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function sendText(paneId, text) {
|
|
63
|
+
// -l sends the string literally, so a pane in some unexpected mode cannot
|
|
64
|
+
// reinterpret it as a key name.
|
|
65
|
+
tmux(['send-keys', '-l', '-t', paneId, text]);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function sendEnter(paneId) {
|
|
69
|
+
tmux(['send-keys', '-t', paneId, 'Enter']);
|
|
70
|
+
}
|