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 +12 -8
- package/README.md +12 -8
- package/bin/cckeep.js +40 -6
- package/package.json +1 -1
- package/src/config.js +15 -2
- package/src/detect.js +118 -22
- package/src/i18n.js +3 -3
- package/src/run.js +47 -22
- package/src/scheduler.js +60 -13
- package/src/state.js +36 -2
- package/src/tmux.js +39 -2
package/README.ja.md
CHANGED
|
@@ -59,12 +59,13 @@ asdf reshim nodejs # asdf
|
|
|
59
59
|
|
|
60
60
|
ここがこのツールの設計上いちばん難しいところです。タイマーでターミナルに文字を打ち込むツールは、「いま打っても安全か」を確実に判断できない限り、ただ危険なだけになります。以下はすべて実装済みで、[テスト](test/)で守られています。
|
|
61
61
|
|
|
62
|
-
-
|
|
63
|
-
-
|
|
64
|
-
-
|
|
65
|
-
-
|
|
66
|
-
-
|
|
67
|
-
-
|
|
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
|
|
65
|
-
- **Never
|
|
66
|
-
- **Never into
|
|
67
|
-
- **Never
|
|
68
|
-
- **Never
|
|
69
|
-
- **
|
|
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')
|
|
50
|
-
|
|
51
|
-
|
|
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 (
|
|
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.
|
|
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
|
|
11
|
-
|
|
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
|
-
/**
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
|
16
|
-
*
|
|
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
|
|
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
|
-
|
|
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:
|
|
56
|
-
retrying:
|
|
57
|
-
failed:
|
|
58
|
-
|
|
59
|
-
// Signals that make cckeep hold off
|
|
60
|
-
//
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
63
|
-
|
|
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,
|
|
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
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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)
|
|
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, '&')
|
|
32
|
+
.replace(/</g, '<')
|
|
33
|
+
.replace(/>/g, '>');
|
|
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(),
|
|
36
|
-
const
|
|
37
|
-
|
|
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
|
-
${
|
|
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,
|
|
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) =>
|
|
202
|
+
const args = [...xml.matchAll(/<string>([^<]*)<\/string>/g)].map((m) =>
|
|
203
|
+
m[1].replace(/</g, '<').replace(/>/g, '>').replace(/&/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
|
-
|
|
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
|
-
|
|
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 (
|
|
84
|
+
if (out === null) return null;
|
|
85
|
+
if (!out.trim()) return [];
|
|
49
86
|
return out
|
|
50
87
|
.split('\n')
|
|
51
88
|
.filter(Boolean)
|