clauderipple 0.12.3 → 0.12.5
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/CHANGELOG.md +30 -0
- package/README.es.md +6 -4
- package/README.ja.md +2 -2
- package/README.ko.md +4 -3
- package/README.md +5 -4
- package/README.pt-BR.md +6 -4
- package/README.zh-CN.md +2 -2
- package/dist/app/package.json +1 -1
- package/dist/cli/src/index.js +8 -0
- package/dist/cli/src/schtasks.js +7 -1
- package/dist/remote/src/bridge-client.js +40 -17
- package/dist/remote/src/desktop.js +65 -19
- package/dist/remote/src/host-service.js +26 -0
- package/dist/remote/src/local-control.js +1 -0
- package/dist/remote/src/main.js +30 -2
- package/dist/remote/src/processes.js +136 -7
- package/dist/router/src/version.js +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,35 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.12.5 — 2026-10-22
|
|
4
|
+
|
|
5
|
+
### Changed
|
|
6
|
+
|
|
7
|
+
- **The install script adds the menu-bar app on macOS and Windows.** It carries the repair
|
|
8
|
+
screen that opens while the router is down, which someone who does not use a terminal needs.
|
|
9
|
+
Electron is about 270MB and fetched once; `CLAUDERIPPLE_NO_TRAY=1` skips it, and a failed
|
|
10
|
+
fetch leaves the router installed with a hint to add the app later. Linux keeps it opt-in.
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- **Updating restarts the phone host too.** An update replaced the files but left the running
|
|
15
|
+
host on the old code until the machine restarted, so a fix like 0.12.4's Windows sending did
|
|
16
|
+
not arrive. `install` (which every update ends with) now restarts a phone host this
|
|
17
|
+
installation registered, unless a turn is in progress; then it says to run
|
|
18
|
+
`clauderipple remote install` afterwards. A host run from a source checkout is left alone.
|
|
19
|
+
- **Windows with a non-ASCII account name no longer re-registers the router on every install.**
|
|
20
|
+
The scheduled task was read back in the console's code page, so it never matched (#74, #75 by
|
|
21
|
+
@115dkk). Tests that assumed "/" paths or POSIX permission bits now run only where those hold
|
|
22
|
+
(#76).
|
|
23
|
+
|
|
24
|
+
## 0.12.4 — 2026-10-22
|
|
25
|
+
|
|
26
|
+
### Added
|
|
27
|
+
|
|
28
|
+
- **On Windows, the phone can send to a session open in the Claude app.** Before, such a session
|
|
29
|
+
could be read but not written to. It needs Remote Control turned on in the Claude app, the same
|
|
30
|
+
as on macOS; with only the Claude app in use, the login ClaudeRipple saw the app use carries it.
|
|
31
|
+
Handing a phone session over to the Claude app is still macOS only.
|
|
32
|
+
|
|
3
33
|
## 0.12.3 — 2026-10-22
|
|
4
34
|
|
|
5
35
|
### Added
|
package/README.es.md
CHANGED
|
@@ -432,15 +432,17 @@ clauderipple ui
|
|
|
432
432
|
|
|
433
433
|
**Proveedores** → añade ChatGPT o pega una clave de API → **Asignación de modelos**.
|
|
434
434
|
|
|
435
|
-
**La app de la barra de menús / bandeja**
|
|
436
|
-
el
|
|
435
|
+
**La app de la barra de menús / bandeja** muestra el estado del router, abre el panel con un
|
|
436
|
+
clic y ofrece una pantalla de reparación que funciona aunque el router esté caído:
|
|
437
437
|
|
|
438
438
|
```sh
|
|
439
439
|
clauderipple tray
|
|
440
440
|
```
|
|
441
441
|
|
|
442
|
-
|
|
443
|
-
|
|
442
|
+
En macOS y Windows el script de instalación la añade por defecto. Funciona con Electron, unos
|
|
443
|
+
270 MB que se descargan una sola vez; define `CLAUDERIPPLE_NO_TRAY=1` antes del comando de
|
|
444
|
+
instalación para omitirla. En Linux, o tras instalar solo con npm, `clauderipple tray --install`
|
|
445
|
+
la añade. Todo lo demás funciona sin ella.
|
|
444
446
|
|
|
445
447
|
**Actualización.** La bandeja ofrece la nueva versión cuando la hay (**Buscar actualizaciones…**), o bien:
|
|
446
448
|
|
package/README.ja.md
CHANGED
|
@@ -258,13 +258,13 @@ clauderipple ui
|
|
|
258
258
|
|
|
259
259
|
**プロバイダー** → ChatGPTを追加するかAPIキーを貼り付け → **モデルマッピング**。
|
|
260
260
|
|
|
261
|
-
**メニューバー /
|
|
261
|
+
**メニューバー / トレイアプリ**はルーターの状態を表示し、ワンクリックでダッシュボードを開き、ルーターが止まっていても開ける修復画面を備えています。
|
|
262
262
|
|
|
263
263
|
```sh
|
|
264
264
|
clauderipple tray
|
|
265
265
|
```
|
|
266
266
|
|
|
267
|
-
Electron
|
|
267
|
+
macOSとWindowsではインストールスクリプトが標準で追加します。Electronで動作し約270MBで、取得は一度だけです。不要ならインストールコマンドの前に`CLAUDERIPPLE_NO_TRAY=1`を付けてください。Linux、またはnpmだけでインストールした場合は`clauderipple tray --install`で追加します。それ以外の機能はすべてアプリなしで動きます。
|
|
268
268
|
|
|
269
269
|
**アップデート**。新しいバージョンがあればトレイから案内されます(**アップデートを確認…**)。または次のコマンドを実行します。
|
|
270
270
|
|
package/README.ko.md
CHANGED
|
@@ -350,14 +350,15 @@ clauderipple ui
|
|
|
350
350
|
|
|
351
351
|
**프로바이더** → ChatGPT 추가 또는 API 키 붙여 넣기 → **모델 매핑**.
|
|
352
352
|
|
|
353
|
-
**메뉴 막대·트레이
|
|
353
|
+
**메뉴 막대·트레이 앱**은 라우터 상태를 보여주고, 한 번에 대시보드를 열고, 라우터가 죽어도 열리는 복구 화면을 갖고 있습니다.
|
|
354
354
|
|
|
355
355
|
```sh
|
|
356
356
|
clauderipple tray
|
|
357
357
|
```
|
|
358
358
|
|
|
359
|
-
|
|
360
|
-
|
|
359
|
+
macOS와 Windows에서는 설치 스크립트가 기본으로 함께 설치합니다. Electron으로 돌아 약 270MB이고 한 번만 받습니다.
|
|
360
|
+
빼고 싶으면 설치 명령 앞에 `CLAUDERIPPLE_NO_TRAY=1`을 붙이세요. Linux나 npm으로만 설치했다면
|
|
361
|
+
`clauderipple tray --install`로 추가합니다. 나머지 기능은 앱 없이도 다 돕니다.
|
|
361
362
|
|
|
362
363
|
**업데이트.** 새 버전이 나오면 트레이가 알려 줍니다(**업데이트 확인…**). 터미널에서는:
|
|
363
364
|
|
package/README.md
CHANGED
|
@@ -413,15 +413,16 @@ clauderipple ui
|
|
|
413
413
|
|
|
414
414
|
**Providers** → add ChatGPT or paste an API key → **Model mapping**.
|
|
415
415
|
|
|
416
|
-
**The menu-bar / tray app**
|
|
417
|
-
|
|
416
|
+
**The menu-bar / tray app** shows the router's state, opens the dashboard in a click, and
|
|
417
|
+
keeps a repair screen that works while the router is down:
|
|
418
418
|
|
|
419
419
|
```sh
|
|
420
420
|
clauderipple tray
|
|
421
421
|
```
|
|
422
422
|
|
|
423
|
-
|
|
424
|
-
|
|
423
|
+
On macOS and Windows the install script adds it by default. It runs on Electron, about 270MB,
|
|
424
|
+
fetched once; set `CLAUDERIPPLE_NO_TRAY=1` before the install command to skip it. On Linux, or
|
|
425
|
+
after an npm-only install, `clauderipple tray --install` adds it. Everything else works without it.
|
|
425
426
|
|
|
426
427
|
**Updating.** The tray offers a new version when there is one (**Check for Updates…**), or:
|
|
427
428
|
|
package/README.pt-BR.md
CHANGED
|
@@ -424,15 +424,17 @@ clauderipple ui
|
|
|
424
424
|
|
|
425
425
|
**Provedores** → adicione o ChatGPT ou cole uma chave de API → **Mapeamento de modelos**.
|
|
426
426
|
|
|
427
|
-
**O app da barra de menus / bandeja**
|
|
428
|
-
|
|
427
|
+
**O app da barra de menus / bandeja** mostra o estado do roteador, abre o painel com um
|
|
428
|
+
clique e traz uma tela de reparo que funciona mesmo com o roteador fora do ar:
|
|
429
429
|
|
|
430
430
|
```sh
|
|
431
431
|
clauderipple tray
|
|
432
432
|
```
|
|
433
433
|
|
|
434
|
-
|
|
435
|
-
|
|
434
|
+
No macOS e no Windows o script de instalação o adiciona por padrão. Ele roda em Electron, cerca
|
|
435
|
+
de 270 MB baixados uma única vez; defina `CLAUDERIPPLE_NO_TRAY=1` antes do comando de instalação
|
|
436
|
+
para pular. No Linux, ou depois de instalar só pelo npm, `clauderipple tray --install` o adiciona.
|
|
437
|
+
Todo o resto funciona sem ele.
|
|
436
438
|
|
|
437
439
|
**Atualização.** A bandeja oferece a nova versão quando houver uma (**Verificar atualizações…**), ou:
|
|
438
440
|
|
package/README.zh-CN.md
CHANGED
|
@@ -257,13 +257,13 @@ clauderipple ui
|
|
|
257
257
|
|
|
258
258
|
**供应商** → 添加 ChatGPT 或粘贴 API 密钥 → **模型映射**。
|
|
259
259
|
|
|
260
|
-
**菜单栏 /
|
|
260
|
+
**菜单栏 / 托盘应用**显示路由服务的状态,点一下就能打开控制面板,并带有路由服务停止时也能打开的修复界面:
|
|
261
261
|
|
|
262
262
|
```sh
|
|
263
263
|
clauderipple tray
|
|
264
264
|
```
|
|
265
265
|
|
|
266
|
-
|
|
266
|
+
在 macOS 和 Windows 上,安装脚本默认会一并安装它。它基于 Electron,约 270MB,只下载一次;如不需要,在安装命令前加上 `CLAUDERIPPLE_NO_TRAY=1` 即可跳过。在 Linux 上或仅通过 npm 安装时,用 `clauderipple tray --install` 添加。除此之外的一切不需要它也能正常工作。
|
|
267
267
|
|
|
268
268
|
**更新。** 有新版本时,托盘应用会提示(**检查更新…**),也可以运行:
|
|
269
269
|
|
package/dist/app/package.json
CHANGED
package/dist/cli/src/index.js
CHANGED
|
@@ -255,6 +255,14 @@ async function install() {
|
|
|
255
255
|
catch (e) {
|
|
256
256
|
console.log(` menu-bar app launcher not written: ${e.message}`);
|
|
257
257
|
}
|
|
258
|
+
// The phone host has the same problem as the router above: new files, old process. The host
|
|
259
|
+
// decides whether it is ours and idle (remote host-service refreshPlan). Never fatal.
|
|
260
|
+
try {
|
|
261
|
+
execFileSync(installedRuntime.node, [installedRuntime.cli, "remote", "refresh"], { env: { ...process.env, ...installedRuntime.env }, stdio: "inherit" });
|
|
262
|
+
}
|
|
263
|
+
catch {
|
|
264
|
+
console.log(" the phone host was not restarted; if you use it, run: clauderipple remote install");
|
|
265
|
+
}
|
|
258
266
|
console.log("\nDone. New Claude Desktop Code sessions go through ClaudeRipple. Existing sessions pick up settings.json env changes on their next request.");
|
|
259
267
|
}
|
|
260
268
|
async function uninstall() {
|
package/dist/cli/src/schtasks.js
CHANGED
|
@@ -25,9 +25,15 @@ export function taskName() {
|
|
|
25
25
|
function ps(value) {
|
|
26
26
|
return `'${value.replace(/'/g, "''")}'`;
|
|
27
27
|
}
|
|
28
|
+
/**
|
|
29
|
+
* Windows PowerShell writes to a pipe in the console's code page, which we would read as UTF-8: on
|
|
30
|
+
* a Korean Windows an account named in Hangul came back garbled, so the registered task never
|
|
31
|
+
* matched and every install tried to register it again (measured 2026-10-13). The script sets
|
|
32
|
+
* UTF-8 first.
|
|
33
|
+
*/
|
|
28
34
|
function run(script) {
|
|
29
35
|
try {
|
|
30
|
-
const out = execFileSync("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], {
|
|
36
|
+
const out = execFileSync("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", `[Console]::OutputEncoding = [System.Text.Encoding]::UTF8; ${script}`], {
|
|
31
37
|
env: windowsPowerShellEnv(),
|
|
32
38
|
stdio: ["ignore", "pipe", "pipe"],
|
|
33
39
|
windowsHide: true,
|
|
@@ -53,13 +53,42 @@ export function loadObservedAuth(now = Date.now(), home = process.env.CLAUDERIPP
|
|
|
53
53
|
return null;
|
|
54
54
|
}
|
|
55
55
|
}
|
|
56
|
-
|
|
57
|
-
|
|
56
|
+
/** The Claude Code CLI's own login on macOS: the keychain item the CLI writes. */
|
|
57
|
+
function keychainCredentials() {
|
|
58
|
+
try {
|
|
59
|
+
const credentials = object(JSON.parse(execFileSync("security", ["find-generic-password", "-a", process.env.USER ?? os.userInfo().username, "-s", "Claude Code-credentials", "-w"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] })));
|
|
60
|
+
const oauth = object(credentials?.claudeAiOauth);
|
|
61
|
+
if (typeof oauth?.accessToken === "string" && oauth.accessToken && typeof oauth.expiresAt === "number")
|
|
62
|
+
return { accessToken: oauth.accessToken, expiresAt: oauth.expiresAt };
|
|
63
|
+
}
|
|
64
|
+
catch { /* the observed login may still serve */ }
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* The Claude Code CLI's own login on Windows: `%USERPROFILE%\.claude\.credentials.json`. There is
|
|
69
|
+
* no keychain there, and the Claude app on its own keeps no credentials file of its own: it
|
|
70
|
+
* injects the login into the CLI it starts (issue #70).
|
|
71
|
+
*/
|
|
72
|
+
function windowsCredentials(read, userHome) {
|
|
73
|
+
try {
|
|
74
|
+
const credentials = object(JSON.parse(read(path.join(userHome, ".claude", ".credentials.json"))));
|
|
75
|
+
const oauth = object(credentials?.claudeAiOauth);
|
|
76
|
+
if (typeof oauth?.accessToken === "string" && oauth.accessToken && typeof oauth.expiresAt === "number")
|
|
77
|
+
return { accessToken: oauth.accessToken, expiresAt: oauth.expiresAt };
|
|
78
|
+
}
|
|
79
|
+
catch { /* the observed login may still serve */ }
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
export function loadBridgeAuth(options = {}) {
|
|
83
|
+
const platform = options.platform ?? process.platform;
|
|
84
|
+
if (platform !== "darwin" && platform !== "win32")
|
|
58
85
|
throw new Error(DESKTOP_BLOCKED);
|
|
59
|
-
const now = Date.now();
|
|
86
|
+
const now = options.now ?? Date.now();
|
|
87
|
+
const userHome = options.userHome ?? os.homedir();
|
|
88
|
+
const read = options.readFile ?? ((file) => fs.readFileSync(file, "utf8"));
|
|
60
89
|
let organizationUuid;
|
|
61
90
|
try {
|
|
62
|
-
const account = object(object(JSON.parse(
|
|
91
|
+
const account = object(object(JSON.parse(read(path.join(userHome, ".claude.json"))))?.oauthAccount);
|
|
63
92
|
if (typeof account?.organizationUuid !== "string")
|
|
64
93
|
throw new Error();
|
|
65
94
|
organizationUuid = account.organizationUuid;
|
|
@@ -67,23 +96,17 @@ export function loadBridgeAuth() {
|
|
|
67
96
|
catch {
|
|
68
97
|
throw new BridgeAuthError("이 컴퓨터의 Claude 로그인 정보를 읽을 수 없습니다.");
|
|
69
98
|
}
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
const credentials = object(JSON.parse(execFileSync("security", ["find-generic-password", "-a", process.env.USER ?? os.userInfo().username, "-s", "Claude Code-credentials", "-w"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] })));
|
|
73
|
-
const oauth = object(credentials?.claudeAiOauth);
|
|
74
|
-
if (typeof oauth?.accessToken === "string" && oauth.accessToken && typeof oauth.expiresAt === "number")
|
|
75
|
-
keychain = { accessToken: oauth.accessToken, expiresAt: oauth.expiresAt };
|
|
76
|
-
}
|
|
77
|
-
catch { /* the observed login may still serve */ }
|
|
99
|
+
// The CLI's own login: the macOS keychain item, or the credentials file Windows keeps.
|
|
100
|
+
const cli = options.cli ? options.cli() : platform === "darwin" ? keychainCredentials() : windowsCredentials(read, userHome);
|
|
78
101
|
// The CLI's login while it is good; else the one the Claude app is working with; else the
|
|
79
102
|
// CLI's expired one, so the phone is told it expired.
|
|
80
|
-
if (
|
|
81
|
-
return { ...
|
|
82
|
-
const observed = loadObservedAuth(now);
|
|
103
|
+
if (cli && cli.expiresAt - now > 5 * 60_000)
|
|
104
|
+
return { ...cli, organizationUuid };
|
|
105
|
+
const observed = options.observed ? options.observed(now) : loadObservedAuth(now);
|
|
83
106
|
if (observed)
|
|
84
107
|
return { ...observed, organizationUuid };
|
|
85
|
-
if (
|
|
86
|
-
return { ...
|
|
108
|
+
if (cli)
|
|
109
|
+
return { ...cli, organizationUuid };
|
|
87
110
|
throw new BridgeAuthError(LOGIN_EXPIRED);
|
|
88
111
|
}
|
|
89
112
|
/** `slash_commands` minus `terminal_slash_commands`, which phone UIs should hide (SDK init docs). */
|
|
@@ -5,7 +5,7 @@ import { execFileSync } from "node:child_process";
|
|
|
5
5
|
import os from "node:os";
|
|
6
6
|
import path from "node:path";
|
|
7
7
|
import { EventLog } from "./eventlog.js";
|
|
8
|
-
import { processCommands } from "./processes.js";
|
|
8
|
+
import { creationDateToFileTime, parseFileTime, processCommands, processSnapshot, sameFileTime } from "./processes.js";
|
|
9
9
|
import { messageContent, sendUserFileImages } from "./images.js";
|
|
10
10
|
import { PERMISSION_MODES } from "./protocol.js";
|
|
11
11
|
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
@@ -18,15 +18,64 @@ const RECENT_MS = 14 * 24 * 60 * 60 * 1000;
|
|
|
18
18
|
*/
|
|
19
19
|
const verifiedStarts = new Map();
|
|
20
20
|
let snapshot = null;
|
|
21
|
-
/**
|
|
22
|
-
|
|
23
|
-
|
|
21
|
+
/**
|
|
22
|
+
* Is this pid the process the registry recorded? On macOS the start time comes from `ps`.
|
|
23
|
+
*/
|
|
24
|
+
function darwinAlive() {
|
|
25
|
+
return (pid, procStart) => {
|
|
26
|
+
try {
|
|
27
|
+
process.kill(pid, 0);
|
|
28
|
+
if (typeof procStart === "string" && verifiedStarts.get(pid) !== procStart) {
|
|
29
|
+
const started = execFileSync("ps", ["-o", "lstart=", "-p", String(pid)], { encoding: "utf8", env: { ...process.env, LC_ALL: "C", TZ: "UTC" }, stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
30
|
+
if (started !== procStart)
|
|
31
|
+
return false;
|
|
32
|
+
verifiedStarts.set(pid, started);
|
|
33
|
+
}
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return false;
|
|
38
|
+
} // Dead or recycled process, not a live desktop writer.
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Is this pid the process the registry recorded? Windows has no `ps -o lstart=`: the registry's
|
|
43
|
+
* procStart is a FILETIME (100 ns ticks since 1601) and the OS reports the same instant as a
|
|
44
|
+
* CreationDate. A pid alone proves nothing there — Windows recycles pids — so the start times
|
|
45
|
+
* are compared (issue #70).
|
|
46
|
+
*/
|
|
47
|
+
function windowsAlive(processes) {
|
|
48
|
+
const running = new Set();
|
|
49
|
+
const starts = new Map();
|
|
50
|
+
for (const p of processes.current()) {
|
|
51
|
+
running.add(p.pid);
|
|
52
|
+
const start = typeof p.started === "string" ? creationDateToFileTime(p.started) : null;
|
|
53
|
+
if (start !== null)
|
|
54
|
+
starts.set(p.pid, start);
|
|
55
|
+
}
|
|
56
|
+
return (pid, procStart) => {
|
|
57
|
+
if (!running.has(pid))
|
|
58
|
+
return false;
|
|
59
|
+
const started = starts.get(pid);
|
|
60
|
+
const recorded = parseFileTime(procStart);
|
|
61
|
+
if (started === undefined || recorded === null)
|
|
62
|
+
return true; // Nothing to compare: the pid runs.
|
|
63
|
+
return sameFileTime(started, recorded);
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Registry identity is authoritative; ps flags remain a legacy fallback only. Cached for 1 s.
|
|
68
|
+
* The Windows snapshot behind it is read in the background, so this never waits for PowerShell
|
|
69
|
+
* more than once (see ProcessSnapshot).
|
|
70
|
+
*/
|
|
71
|
+
export function liveDesktopProcesses(dir, maxAgeMs = 1000, platform = process.platform, processes = processSnapshot) {
|
|
72
|
+
if (platform !== "darwin" && platform !== "win32")
|
|
24
73
|
return new Map();
|
|
25
74
|
const now = Date.now();
|
|
26
|
-
if (snapshot && snapshot.dir === dir && now - snapshot.at < maxAgeMs)
|
|
75
|
+
if (snapshot && snapshot.dir === dir && snapshot.platform === platform && now - snapshot.at < maxAgeMs)
|
|
27
76
|
return snapshot.result;
|
|
28
77
|
const result = new Map();
|
|
29
|
-
snapshot = { dir, at: now, result };
|
|
78
|
+
snapshot = { dir, platform, at: now, result };
|
|
30
79
|
let entries;
|
|
31
80
|
try {
|
|
32
81
|
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
@@ -36,6 +85,7 @@ export function liveDesktopProcesses(dir, maxAgeMs = 1000, platform = process.pl
|
|
|
36
85
|
return result;
|
|
37
86
|
throw error;
|
|
38
87
|
}
|
|
88
|
+
const candidates = [];
|
|
39
89
|
for (const entry of entries) {
|
|
40
90
|
if (!entry.isFile() || !/^\d+\.json$/.test(entry.name))
|
|
41
91
|
continue;
|
|
@@ -53,20 +103,16 @@ export function liveDesktopProcesses(dir, maxAgeMs = 1000, platform = process.pl
|
|
|
53
103
|
continue;
|
|
54
104
|
if (!["busy", "shell", "idle", "waiting"].includes(str(value.status)))
|
|
55
105
|
continue;
|
|
56
|
-
try {
|
|
57
|
-
process.kill(pid, 0);
|
|
58
|
-
if (typeof value.procStart === "string" && verifiedStarts.get(pid) !== value.procStart) {
|
|
59
|
-
const started = execFileSync("ps", ["-o", "lstart=", "-p", String(pid)], { encoding: "utf8", env: { ...process.env, LC_ALL: "C", TZ: "UTC" }, stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
60
|
-
if (started !== value.procStart)
|
|
61
|
-
continue;
|
|
62
|
-
verifiedStarts.set(pid, started);
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
catch {
|
|
66
|
-
continue;
|
|
67
|
-
} // Dead or recycled process, not a live desktop writer.
|
|
68
106
|
const status = value.status;
|
|
69
|
-
|
|
107
|
+
candidates.push({ pid, procStart: value.procStart, entry: { sessionId: value.sessionId, pid, status, ...(typeof value.bridgeSessionId === "string" ? { bridgeSessionId: value.bridgeSessionId } : {}) } });
|
|
108
|
+
}
|
|
109
|
+
if (!candidates.length)
|
|
110
|
+
return result;
|
|
111
|
+
const alive = platform === "win32" ? windowsAlive(processes) : darwinAlive();
|
|
112
|
+
for (const candidate of candidates) {
|
|
113
|
+
if (!alive(candidate.pid, candidate.procStart))
|
|
114
|
+
continue;
|
|
115
|
+
result.set(candidate.entry.sessionId, candidate.entry);
|
|
70
116
|
}
|
|
71
117
|
return result;
|
|
72
118
|
}
|
|
@@ -104,6 +104,32 @@ async function waitForHost(dir = remoteHome()) {
|
|
|
104
104
|
}
|
|
105
105
|
throw new Error("host service was registered but did not answer its authenticated status; check its logs");
|
|
106
106
|
}
|
|
107
|
+
export function refreshPlan(o) {
|
|
108
|
+
if (!o.installed || o.definition === null)
|
|
109
|
+
return "absent";
|
|
110
|
+
const xml = o.script.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
111
|
+
if (![o.script, xml, o.script.replace(/'/g, "''")].some(form => o.definition.includes(form)))
|
|
112
|
+
return "foreign";
|
|
113
|
+
// An older host does not report it; its update came while the router sat idle, so most likely idle too.
|
|
114
|
+
return o.busy ? "busy" : "restart";
|
|
115
|
+
}
|
|
116
|
+
/** The running host's own answer; undefined when it does not answer or predates the field. */
|
|
117
|
+
export async function hostBusy(dir = remoteHome()) {
|
|
118
|
+
try {
|
|
119
|
+
const cfg = loadConfig(dir, false);
|
|
120
|
+
const secret = fs.readFileSync(path.join(dir, "local.secret"), "utf8").trim();
|
|
121
|
+
const response = await fetch(`http://127.0.0.1:${cfg.port}/local/status`, { headers: { "x-ripple-local": secret }, redirect: "error", signal: AbortSignal.timeout(2000) });
|
|
122
|
+
if (!response.ok) {
|
|
123
|
+
await response.body?.cancel();
|
|
124
|
+
return undefined;
|
|
125
|
+
}
|
|
126
|
+
const body = await response.json();
|
|
127
|
+
return typeof body.busy === "boolean" ? body.busy : undefined;
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
return undefined;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
107
133
|
export async function installService(spec) {
|
|
108
134
|
const platform = process.platform;
|
|
109
135
|
if (platform === "darwin")
|
|
@@ -66,6 +66,7 @@ export class LocalControl {
|
|
|
66
66
|
reachableFromPhone: !!cfg.relay || this.where.reachableFromPhone, pushReady: !!cfg.relay,
|
|
67
67
|
phones: this.opts.phones(), settings: { bind: saved.bind, ...(saved.relay ? { relay: saved.relay } : {}) }, defaultRelay: DEFAULT_RELAY,
|
|
68
68
|
restartRequired: saved.bind !== cfg.bind || saved.relay !== cfg.relay,
|
|
69
|
+
busy: this.opts.busy?.() ?? false,
|
|
69
70
|
});
|
|
70
71
|
return;
|
|
71
72
|
}
|
package/dist/remote/src/main.js
CHANGED
|
@@ -30,7 +30,7 @@ import { RouterClient } from "./router.js";
|
|
|
30
30
|
import { HostServer } from "./server.js";
|
|
31
31
|
import { LocalControl } from "./local-control.js";
|
|
32
32
|
import { findClaude, specFor, cliLoginWarning } from "./service.js";
|
|
33
|
-
import { installService, uninstallService, serviceStatus, servicePaths, renderService, supervisorName, controlShutdown } from "./host-service.js";
|
|
33
|
+
import { installService, uninstallService, serviceStatus, servicePaths, renderService, supervisorName, controlShutdown, refreshPlan, hostBusy } from "./host-service.js";
|
|
34
34
|
import { Sessions } from "./sessions.js";
|
|
35
35
|
import { desktopHandoff } from "./handoff.js";
|
|
36
36
|
import { claudeBinary, claudeCodeLogin } from "../../cli/src/claude-auth.js";
|
|
@@ -179,6 +179,7 @@ async function runHost() {
|
|
|
179
179
|
phones: () => server?.phones() ?? [],
|
|
180
180
|
rotate: token => { server?.rotateToken(token); },
|
|
181
181
|
shutdown: () => stop(),
|
|
182
|
+
busy: () => live.busy(),
|
|
182
183
|
}),
|
|
183
184
|
});
|
|
184
185
|
server = host;
|
|
@@ -234,7 +235,7 @@ async function installCmd() {
|
|
|
234
235
|
const cfg = process.argv.includes("--print") ? loadConfig(dir, false) : configFromArgs(dir);
|
|
235
236
|
// The CLI the host will start; also what a person is told to run when it has no login.
|
|
236
237
|
const claudePath = findClaude(cfg.claudePath);
|
|
237
|
-
const spec =
|
|
238
|
+
const spec = hostSpec(claudePath);
|
|
238
239
|
if (process.argv.includes("--print")) {
|
|
239
240
|
out(renderService(spec));
|
|
240
241
|
return;
|
|
@@ -249,11 +250,38 @@ async function installCmd() {
|
|
|
249
250
|
if (warning)
|
|
250
251
|
out(`⚠ ${warning}\n`);
|
|
251
252
|
}
|
|
253
|
+
function hostSpec(claudePath) {
|
|
254
|
+
return specFor({ script: fileURLToPath(import.meta.url), claudePath, ...(process.env.RIPPLE_REMOTE_HOME ? { remoteHome: process.env.RIPPLE_REMOTE_HOME } : {}) });
|
|
255
|
+
}
|
|
256
|
+
/** Run by `clauderipple update` from the new files: restarts a host this installation registered (refreshPlan). */
|
|
257
|
+
async function refreshCmd() {
|
|
258
|
+
const p = servicePaths();
|
|
259
|
+
let definition = null;
|
|
260
|
+
try {
|
|
261
|
+
definition = fs.readFileSync(p.definition, "utf8");
|
|
262
|
+
}
|
|
263
|
+
catch { /* not registered */ }
|
|
264
|
+
const plan = refreshPlan({ installed: serviceStatus().installed, definition, script: fileURLToPath(import.meta.url), busy: definition === null ? undefined : await hostBusy() });
|
|
265
|
+
if (plan === "absent")
|
|
266
|
+
return;
|
|
267
|
+
if (plan === "foreign") {
|
|
268
|
+
out(`phone host: runs from other files (${p.definition}); left as it is\n`);
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
if (plan === "busy") {
|
|
272
|
+
out("phone host: a turn is in progress, so it keeps the old code; restart it afterwards with: clauderipple remote install\n");
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
const spec = hostSpec(findClaude(loadConfig(remoteHome(), false).claudePath));
|
|
276
|
+
for (const line of await installService(spec))
|
|
277
|
+
out(`phone host: ${line}\n`);
|
|
278
|
+
}
|
|
252
279
|
async function main() {
|
|
253
280
|
const sub = process.argv[2] && !process.argv[2].startsWith("--") ? process.argv[2] : null;
|
|
254
281
|
switch (sub) {
|
|
255
282
|
case null: return runHost();
|
|
256
283
|
case "install": return installCmd();
|
|
284
|
+
case "refresh": return refreshCmd();
|
|
257
285
|
case "uninstall":
|
|
258
286
|
for (const line of await uninstallService())
|
|
259
287
|
out(`${line}\n`);
|
|
@@ -1,11 +1,26 @@
|
|
|
1
|
-
// One OS process snapshot for transcript liveness.
|
|
2
|
-
import { execFileSync } from "node:child_process";
|
|
1
|
+
// One OS process snapshot for transcript liveness. The desktop registry reads macOS and Windows.
|
|
2
|
+
import { execFile, execFileSync } from "node:child_process";
|
|
3
3
|
import { windowsPowerShellEnv } from "../../cli/src/windows-powershell.js";
|
|
4
|
-
|
|
4
|
+
/** 100 ns ticks from 1601-01-01 to 1970-01-01: a Windows FILETIME counts from 1601, Unix time from 1970. */
|
|
5
|
+
const FILETIME_UNIX_TICKS = 116444736000000000n;
|
|
6
|
+
/** A millisecond: the registry's own start time and the OS's CreationDate may differ by a hair. */
|
|
7
|
+
const FILETIME_TOLERANCE = 10000n;
|
|
8
|
+
const exec = (file, args) => execFileSync(file, args, {
|
|
5
9
|
encoding: "utf8", maxBuffer: 16 * 1024 * 1024, timeout: 5000, windowsHide: true, env: windowsPowerShellEnv(), stdio: ["ignore", "pipe", "pipe"],
|
|
6
|
-
})
|
|
10
|
+
});
|
|
11
|
+
const execAsync = (file, args) => new Promise((resolve, reject) => {
|
|
12
|
+
execFile(file, args, { encoding: "utf8", maxBuffer: 16 * 1024 * 1024, timeout: 5000, windowsHide: true, env: windowsPowerShellEnv() }, (error, stdout) => { if (error)
|
|
13
|
+
reject(error);
|
|
14
|
+
else
|
|
15
|
+
resolve(stdout); });
|
|
16
|
+
});
|
|
17
|
+
/** The PowerShell that reads the Windows process table: pid, command line and start time. */
|
|
18
|
+
function windowsSnapshotArgs() {
|
|
19
|
+
return ["-NoProfile", "-NonInteractive", "-Command", "$ErrorActionPreference = 'Stop'; [Console]::OutputEncoding = [Text.UTF8Encoding]::new(); ConvertTo-Json -Compress -InputObject @(Get-CimInstance Win32_Process | Select-Object ProcessId,CommandLine,@{Name='CreationDate';Expression={if ($_.CreationDate) {$_.CreationDate.ToUniversalTime().ToString('o')}}})"];
|
|
20
|
+
}
|
|
21
|
+
export function listProcesses(platform = process.platform, run = exec) {
|
|
7
22
|
if (platform === "win32") {
|
|
8
|
-
const raw = JSON.parse(
|
|
23
|
+
const raw = JSON.parse(run("powershell.exe", windowsSnapshotArgs()));
|
|
9
24
|
if (!Array.isArray(raw))
|
|
10
25
|
throw new Error("invalid Windows process snapshot");
|
|
11
26
|
return raw.flatMap((value) => {
|
|
@@ -16,9 +31,123 @@ export function listProcesses(platform = process.platform, exec = (file, args) =
|
|
|
16
31
|
? [{ pid: p.ProcessId, command: p.CommandLine, ...(typeof p.CreationDate === "string" ? { started: p.CreationDate } : {}) }] : [];
|
|
17
32
|
});
|
|
18
33
|
}
|
|
19
|
-
return
|
|
34
|
+
return run("ps", ["-axo", "pid,command"]).split(/\r?\n/).flatMap(line => {
|
|
20
35
|
const m = /^\s*(\d+)\s+(.+)$/.exec(line);
|
|
21
36
|
return m ? [{ pid: Number(m[1]), command: m[2] }] : [];
|
|
22
37
|
});
|
|
23
38
|
}
|
|
24
|
-
|
|
39
|
+
/** The same snapshot, read without blocking: the Windows path spawns PowerShell in the background. */
|
|
40
|
+
export function listProcessesAsync(platform = process.platform, run = execAsync) {
|
|
41
|
+
if (platform === "win32")
|
|
42
|
+
return run("powershell.exe", windowsSnapshotArgs()).then(output => listProcesses(platform, () => output));
|
|
43
|
+
return run("ps", ["-axo", "pid,command"]).then(output => listProcesses(platform, () => output));
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* A Windows process start as the FILETIME the Claude app writes into its desktop registry:
|
|
47
|
+
* 100 ns ticks since 1601-01-01 UTC. The OS reports the same instant as an ISO string, so it
|
|
48
|
+
* is converted here (issue #70).
|
|
49
|
+
*/
|
|
50
|
+
export function creationDateToFileTime(value) {
|
|
51
|
+
const m = /^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2}):(\d{2})(?:\.(\d+))?(Z|[+-]\d{2}:?\d{2})?$/.exec(value.trim());
|
|
52
|
+
if (!m)
|
|
53
|
+
return null;
|
|
54
|
+
const [, year, month, day, hour, minute, second, fraction, zone] = m;
|
|
55
|
+
const seconds = Date.UTC(Number(year), Number(month) - 1, Number(day), Number(hour), Number(minute), Number(second)) / 1000;
|
|
56
|
+
if (!Number.isFinite(seconds))
|
|
57
|
+
return null;
|
|
58
|
+
let offset = 0;
|
|
59
|
+
if (zone && zone !== "Z") {
|
|
60
|
+
const parts = zone.slice(1).replace(":", "").match(/\d{2}/g) ?? [];
|
|
61
|
+
offset = (zone[0] === "-" ? -1 : 1) * (Number(parts[0] ?? 0) * 60 + Number(parts[1] ?? 0)) * 60;
|
|
62
|
+
}
|
|
63
|
+
// The fraction is in seconds; its digits are the 100 ns ticks themselves.
|
|
64
|
+
const ticks = BigInt(Math.round(seconds - offset)) * 10000000n + BigInt((fraction ?? "").padEnd(7, "0").slice(0, 7) || "0");
|
|
65
|
+
return ticks + FILETIME_UNIX_TICKS;
|
|
66
|
+
}
|
|
67
|
+
/** The registry's own start time, in the same FILETIME. A JSON number has lost its last digits already. */
|
|
68
|
+
export function parseFileTime(value) {
|
|
69
|
+
if (typeof value === "bigint")
|
|
70
|
+
return value;
|
|
71
|
+
if (typeof value === "number")
|
|
72
|
+
return Number.isFinite(value) && Number.isInteger(value) ? BigInt(value) : null;
|
|
73
|
+
if (typeof value === "string" && /^\d{1,20}$/.test(value.trim()))
|
|
74
|
+
return BigInt(value.trim());
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
/** The same process start: the same instant, or within a millisecond of it. */
|
|
78
|
+
export function sameFileTime(a, b) {
|
|
79
|
+
const diff = a > b ? a - b : b - a;
|
|
80
|
+
return diff <= FILETIME_TOLERANCE;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* The host's view of the running processes. On Windows PowerShell costs hundreds of
|
|
84
|
+
* milliseconds, so the snapshot is refreshed in the background and read from memory: the host's
|
|
85
|
+
* event loop never waits for it again (2026-10-12, one keychain read per request blocked the
|
|
86
|
+
* relay pings). Only the very first read waits, once, so the desktop registry is never empty
|
|
87
|
+
* when it is asked for (issue #70).
|
|
88
|
+
*
|
|
89
|
+
* The registry is read synchronously — Sessions.list, summary and isOpen all are — so the
|
|
90
|
+
* snapshot cannot be awaited everywhere without making the whole session list asynchronous.
|
|
91
|
+
* One bounded wait at startup is the price; every later read is a map lookup.
|
|
92
|
+
*/
|
|
93
|
+
export class ProcessSnapshot {
|
|
94
|
+
processes = [];
|
|
95
|
+
at = 0;
|
|
96
|
+
read = false;
|
|
97
|
+
pending = null;
|
|
98
|
+
failed = false;
|
|
99
|
+
platform;
|
|
100
|
+
ttlMs;
|
|
101
|
+
now;
|
|
102
|
+
run;
|
|
103
|
+
runAsync;
|
|
104
|
+
onError;
|
|
105
|
+
constructor(options = {}) {
|
|
106
|
+
this.platform = options.platform ?? process.platform;
|
|
107
|
+
this.ttlMs = options.ttlMs ?? 5000;
|
|
108
|
+
this.now = options.now ?? Date.now;
|
|
109
|
+
this.run = options.exec ?? exec;
|
|
110
|
+
this.runAsync = options.execAsync ?? execAsync;
|
|
111
|
+
this.onError = options.onError ?? (error => console.error("[process snapshot]", error instanceof Error ? error.message : String(error)));
|
|
112
|
+
}
|
|
113
|
+
/** The processes known now; on Windows this is what the last read found. */
|
|
114
|
+
current() {
|
|
115
|
+
if (this.platform !== "win32")
|
|
116
|
+
return listProcesses(this.platform, this.run);
|
|
117
|
+
if (!this.read) {
|
|
118
|
+
// The first read is the one the host cannot do without: it waits, once.
|
|
119
|
+
this.read = true;
|
|
120
|
+
try {
|
|
121
|
+
this.processes = listProcesses(this.platform, this.run);
|
|
122
|
+
this.at = this.now();
|
|
123
|
+
this.failed = false;
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
this.refresh();
|
|
127
|
+
}
|
|
128
|
+
return this.processes;
|
|
129
|
+
}
|
|
130
|
+
if (this.now() - this.at >= this.ttlMs)
|
|
131
|
+
this.refresh();
|
|
132
|
+
return this.processes;
|
|
133
|
+
}
|
|
134
|
+
/** Reads a new snapshot in the background; the current one is served until it lands. */
|
|
135
|
+
refresh() {
|
|
136
|
+
if (this.pending)
|
|
137
|
+
return;
|
|
138
|
+
this.pending = listProcessesAsync(this.platform, this.runAsync).then(processes => {
|
|
139
|
+
this.processes = processes;
|
|
140
|
+
this.at = this.now();
|
|
141
|
+
this.failed = false;
|
|
142
|
+
}).catch(error => {
|
|
143
|
+
// The snapshot stays as it was; a run of failures is reported once, not every read.
|
|
144
|
+
if (!this.failed) {
|
|
145
|
+
this.failed = true;
|
|
146
|
+
this.onError(error);
|
|
147
|
+
}
|
|
148
|
+
}).finally(() => { this.pending = null; });
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
/** The snapshot the host reads. One per process, so every reader sees the same processes. */
|
|
152
|
+
export const processSnapshot = new ProcessSnapshot();
|
|
153
|
+
export function processCommands() { return processSnapshot.current().map(p => `${p.pid} ${p.command}`).join("\n"); }
|
|
@@ -5,4 +5,4 @@
|
|
|
5
5
|
// from the manifests. Until 0.1.1 the router and the CLI each carried their own literal, and both
|
|
6
6
|
// still said "0.1.0" in the 0.1.1 release: after an update nobody could tell which router was
|
|
7
7
|
// running, and the tray app had nothing to compare (2026-09-15, reported from a Windows install).
|
|
8
|
-
export const VERSION = "0.12.
|
|
8
|
+
export const VERSION = "0.12.5";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "clauderipple",
|
|
3
|
-
"version": "0.12.
|
|
3
|
+
"version": "0.12.5",
|
|
4
4
|
"description": "Use GPT (any ChatGPT plan with Codex), DeepSeek and 400+ other models in the Claude Desktop Code tab and Claude Code while staying signed in to your Claude subscription — no third-party gateway mode. Several ChatGPT accounts that take over when one runs out, in Codex too.",
|
|
5
5
|
"license": "GPL-3.0-only",
|
|
6
6
|
"workspaces": [
|