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 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** es opcional. Muestra el estado del router y abre
436
- el panel con un clic:
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
- Funciona con Electron, que ocupa unos 270 MB y por eso no se instala por defecto.
443
- `clauderipple tray --install` lo descarga una sola vez; todo lo demás funciona sin él.
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で動作し、約270MBあるため、デフォルトではインストールされません。`clauderipple tray --install`で一度だけ取得します。それ以外の機能はすべて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
- Electron으로 도는데 용량이 270MB라 기본 설치에는 넣지 않았습니다. `clauderipple tray --install`이 한 번만
360
- 받아 옵니다. 나머지 기능은 Electron 없이도 다 돕니다.
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** is optional. It shows the router's state and opens the
417
- dashboard in a click:
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
- It runs on Electron, which is about 270MB and is therefore not installed by default.
424
- `clauderipple tray --install` fetches it once; everything else works without it.
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** é opcional. Ele mostra o estado do roteador e abre o
428
- painel com um clique:
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
- Ele roda em Electron, que tem cerca de 270 MB e, por isso, não é instalado por padrão.
435
- `clauderipple tray --install` baixa o Electron uma única vez; todo o resto funciona sem ele.
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
- 它基于 Electron,体积约 270MB,因此默认不安装。`clauderipple tray --install` 会下载一次;除此之外的一切不需要它也能正常工作。
266
+ 在 macOS 和 Windows 上,安装脚本默认会一并安装它。它基于 Electron,约 270MB,只下载一次;如不需要,在安装命令前加上 `CLAUDERIPPLE_NO_TRAY=1` 即可跳过。在 Linux 上或仅通过 npm 安装时,用 `clauderipple tray --install` 添加。除此之外的一切不需要它也能正常工作。
267
267
 
268
268
  **更新。** 有新版本时,托盘应用会提示(**检查更新…**),也可以运行:
269
269
 
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "private": true,
3
- "version": "0.12.3",
3
+ "version": "0.12.5",
4
4
  "type": "commonjs"
5
5
  }
@@ -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() {
@@ -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
- export function loadBridgeAuth() {
57
- if (process.platform !== "darwin")
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(fs.readFileSync(path.join(os.homedir(), ".claude.json"), "utf8")))?.oauthAccount);
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
- let keychain = null;
71
- try {
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 (keychain && keychain.expiresAt - now > 5 * 60_000)
81
- return { ...keychain, organizationUuid };
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 (keychain)
86
- return { ...keychain, organizationUuid };
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
- /** Registry identity is authoritative; ps flags remain a legacy fallback only. Cached for 1 s. */
22
- export function liveDesktopProcesses(dir, maxAgeMs = 1000, platform = process.platform) {
23
- if (platform !== "darwin")
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
- result.set(value.sessionId, { sessionId: value.sessionId, pid, status, ...(typeof value.bridgeSessionId === "string" ? { bridgeSessionId: value.bridgeSessionId } : {}) });
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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
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
  }
@@ -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 = specFor({ script: fileURLToPath(import.meta.url), claudePath, ...(process.env.RIPPLE_REMOTE_HOME ? { remoteHome: process.env.RIPPLE_REMOTE_HOME } : {}) });
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. Desktop registry identity remains macOS-only.
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
- export function listProcesses(platform = process.platform, exec = (file, args) => execFileSync(file, args, {
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(exec("powershell.exe", ["-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')}}})"]));
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 exec("ps", ["-axo", "pid,command"]).split(/\r?\n/).flatMap(line => {
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
- export function processCommands() { return listProcesses().map(p => `${p.pid} ${p.command}`).join("\n"); }
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.3";
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",
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": [