clauderipple 0.12.3 → 0.12.4

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,14 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.12.4 — 2026-10-22
4
+
5
+ ### Added
6
+
7
+ - **On Windows, the phone can send to a session open in the Claude app.** Before, such a session
8
+ could be read but not written to. It needs Remote Control turned on in the Claude app, the same
9
+ as on macOS; with only the Claude app in use, the login ClaudeRipple saw the app use carries it.
10
+ Handing a phone session over to the Claude app is still macOS only.
11
+
3
12
  ## 0.12.3 — 2026-10-22
4
13
 
5
14
  ### Added
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "private": true,
3
- "version": "0.12.3",
3
+ "version": "0.12.4",
4
4
  "type": "commonjs"
5
5
  }
@@ -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
  }
@@ -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.4";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clauderipple",
3
- "version": "0.12.3",
3
+ "version": "0.12.4",
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": [