clauderipple 0.9.4 → 0.10.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.
Files changed (118) hide show
  1. package/CHANGELOG.md +80 -0
  2. package/README.md +31 -0
  3. package/dist/app/package.json +1 -1
  4. package/dist/cli/src/hooks/agent-title.js +7 -4
  5. package/dist/cli/src/index.js +43 -0
  6. package/dist/cli/src/remote.js +263 -0
  7. package/dist/cli/src/schtasks.js +48 -40
  8. package/dist/cli/src/settings.js +2 -1
  9. package/dist/mod/hooks/i18n.ts +252 -6
  10. package/dist/mod/hooks/judge.ts +35 -17
  11. package/dist/mod/hooks/modes.ts +56 -1
  12. package/dist/mod/hooks/register.tsx +238 -32
  13. package/dist/mod/hooks/session.ts +98 -1
  14. package/dist/mod/hooks/settings.js +7 -3
  15. package/dist/mod/hooks/settings.ts +9 -4
  16. package/dist/mod/hooks/stats.ts +25 -1
  17. package/dist/mod/hooks/ui/agents.tsx +858 -79
  18. package/dist/mod/hooks/ui/alerts.tsx +20 -17
  19. package/dist/mod/hooks/ui/art.ts +33 -7
  20. package/dist/mod/hooks/ui/band.tsx +262 -81
  21. package/dist/mod/hooks/ui/flat.ts +265 -0
  22. package/dist/mod/hooks/ui/kit.tsx +70 -55
  23. package/dist/mod/hooks/ui/look.ts +129 -0
  24. package/dist/mod/hooks/ui/settings.tsx +71 -46
  25. package/dist/mod/hooks/ui/setup.tsx +15 -13
  26. package/dist/mod/hooks/ui/theme.ts +3 -0
  27. package/dist/mod/hooks/ui/tools.ts +124 -0
  28. package/dist/mod/hooks/ui/view.ts +116 -3
  29. package/dist/mod/hooks/workers.ts +33 -6
  30. package/dist/mod/tests/register.test.tsx +155 -11
  31. package/dist/mod/tests/ui.test.tsx +375 -27
  32. package/dist/mod/types/index.d.ts +68 -2
  33. package/dist/remote/package.json +28 -0
  34. package/dist/remote/src/away.js +85 -0
  35. package/dist/remote/src/bridge-client.js +295 -0
  36. package/dist/remote/src/composer-menu.js +11 -0
  37. package/dist/remote/src/config.js +102 -0
  38. package/dist/remote/src/desktop.js +1018 -0
  39. package/dist/remote/src/e2ee.js +126 -0
  40. package/dist/remote/src/e2ee.ts +126 -0
  41. package/dist/remote/src/eventlog.js +298 -0
  42. package/dist/remote/src/expo-push.js +127 -0
  43. package/dist/remote/src/folders.js +79 -0
  44. package/dist/remote/src/fsutil.js +39 -0
  45. package/dist/remote/src/handoff.js +38 -0
  46. package/dist/remote/src/host-service.js +168 -0
  47. package/dist/remote/src/images.js +215 -0
  48. package/dist/remote/src/local-control.js +138 -0
  49. package/dist/remote/src/main.js +264 -0
  50. package/dist/remote/src/permissions.js +124 -0
  51. package/dist/remote/src/power.js +76 -0
  52. package/dist/remote/src/processes.js +24 -0
  53. package/dist/remote/src/protocol.js +32 -0
  54. package/dist/remote/src/protocol.ts +284 -0
  55. package/dist/remote/src/push-content.js +32 -0
  56. package/dist/remote/src/push.js +134 -0
  57. package/dist/remote/src/receipts.js +111 -0
  58. package/dist/remote/src/relay.js +438 -0
  59. package/dist/remote/src/router.js +208 -0
  60. package/dist/remote/src/sdk-launcher.js +18 -0
  61. package/dist/remote/src/server.js +706 -0
  62. package/dist/remote/src/service.js +157 -0
  63. package/dist/remote/src/sessions.js +1800 -0
  64. package/dist/remote/src/sidebar-storage.js +211 -0
  65. package/dist/remote/src/sidebar.js +173 -0
  66. package/dist/remote/src/tool-details.js +205 -0
  67. package/dist/remote/src/tool-details.ts +153 -0
  68. package/dist/remote/src/worktree.js +99 -0
  69. package/dist/remote/web/app.ts +746 -0
  70. package/dist/remote/web/conn.ts +193 -0
  71. package/dist/remote/web/demo.ts +134 -0
  72. package/dist/remote/web/dom.ts +124 -0
  73. package/dist/remote/web/esc.ts +4 -0
  74. package/dist/remote/web/i18n.ts +374 -0
  75. package/dist/remote/web/icon.svg +1 -0
  76. package/dist/remote/web/index.html +24 -0
  77. package/dist/remote/web/manifest.webmanifest +10 -0
  78. package/dist/remote/web/mascot.ts +60 -0
  79. package/dist/remote/web/md.ts +78 -0
  80. package/dist/remote/web/notify.ts +55 -0
  81. package/dist/remote/web/pushstate.ts +27 -0
  82. package/dist/remote/web/secure.ts +109 -0
  83. package/dist/remote/web/sidebar.ts +106 -0
  84. package/dist/remote/web/style.css +311 -0
  85. package/dist/remote/web/sw.ts +48 -0
  86. package/dist/remote/web/timeline.ts +224 -0
  87. package/dist/remote/web/views.ts +323 -0
  88. package/dist/router/src/admin.js +227 -4
  89. package/dist/router/src/config.js +30 -0
  90. package/dist/router/src/index.js +39 -0
  91. package/dist/router/src/ingress/translate.js +6 -2
  92. package/dist/router/src/phone.js +82 -0
  93. package/dist/router/src/presets.js +3 -0
  94. package/dist/router/src/providers/anthropic-usage.js +33 -1
  95. package/dist/router/src/providers/chatgpt/index.js +41 -11
  96. package/dist/router/src/providers/chatgpt/translate.js +147 -7
  97. package/dist/router/src/providers/google/index.js +26 -0
  98. package/dist/router/src/providers/google/prompt-refusal.js +29 -0
  99. package/dist/router/src/providers/google/usage.js +257 -0
  100. package/dist/router/src/providers/grok/index.js +18 -0
  101. package/dist/router/src/providers/grok/usage.js +77 -0
  102. package/dist/router/src/providers/openai/index.js +29 -5
  103. package/dist/router/src/providers/openai/translate.js +260 -27
  104. package/dist/router/src/providers/opencode-usage.js +116 -0
  105. package/dist/router/src/proxy.js +339 -258
  106. package/dist/router/src/reasoning.js +157 -0
  107. package/dist/router/src/remote-host.js +320 -0
  108. package/dist/router/src/remote-tunnel.js +320 -0
  109. package/dist/router/src/requestlog.js +48 -39
  110. package/dist/router/src/thinking.js +20 -9
  111. package/dist/router/src/usage-history.js +227 -0
  112. package/dist/router/src/version.js +1 -1
  113. package/dist/ui/app.js +436 -59
  114. package/dist/ui/i18n.js +184 -0
  115. package/dist/ui/index.html +24 -5
  116. package/dist/ui/style.css +39 -0
  117. package/docs/ARCHITECTURE.md +164 -12
  118. package/package.json +7 -1
@@ -25,6 +25,9 @@ export type StatusRequest = {
25
25
 
26
26
  export type Effort = 'low' | 'medium' | 'high' | 'xhigh' | 'max'
27
27
 
28
+ /** The desktop's skins: the deep (dark water, the first look) and flat (the app's own surfaces). */
29
+ export type Skin = 'deep' | 'flat'
30
+
28
31
  /** API usage may omit counts; the engine's ModelUsage does not expose cache TTL evidence. */
29
32
  export type Usage = {
30
33
  input_tokens?: number
@@ -183,6 +186,8 @@ export type Settings = {
183
186
  swampAlert: boolean
184
187
  layout: 'dashboard' | 'minimal'
185
188
  appearance: 'auto' | 'dark' | 'light'
189
+ /** How the desktop draws the band and the cards: the deep or flat (ui/look.ts). */
190
+ skin: Skin
186
191
  language: 'auto' | Lang
187
192
  /** Band parts switched off: 'timer' | 'reason' | 'route'. */
188
193
  hide: string[]
@@ -210,7 +215,14 @@ export type Verdict = { effort: Effort; sure?: number; why: string }
210
215
  export type Pick = Verdict & { by: JudgeKind | 'manual' | 'followup'; at: number }
211
216
 
212
217
  /** A judge's answer for a delegated task; a judge that names no kind means do. */
213
- export type WorkerVerdict = { domain: Domain; level: Level; kind?: Kind; effort: Effort; sure?: number; why: string }
218
+ export type WorkerVerdict = {
219
+ domain: Domain; level: Level; kind?: Kind; effort: Effort
220
+ /** Confidence in the level, the one that moves a task up the ladder. */
221
+ sure?: number
222
+ /** Jev's confidence in each answer, kept in the pick log so a rubric change can be compared. */
223
+ odds?: { domain?: number; level?: number; kind?: number; effort?: number }
224
+ why: string
225
+ }
214
226
 
215
227
  /** What a spawn of ripple:auto (or a taken-over spawn) was turned into. */
216
228
  export type WorkerPick = WorkerVerdict & {
@@ -220,10 +232,13 @@ export type WorkerPick = WorkerVerdict & {
220
232
  position?: number
221
233
  skipped?: { rung: Rung; position: number; model: string; reason: string }[]
222
234
  target: TierTarget
223
- by: JudgeKind | 'marker' | 'default'
235
+ /** Who named the tier: the judge, the main chat's tier marker (`session`), a model marker, or the default. */
236
+ by: JudgeKind | 'session' | 'marker' | 'default'
224
237
  ms: number
225
238
  /** Set when the tier's own agent could not run and the fallback did. */
226
239
  fallbackReason?: string
240
+ /** Joins the pick's lines in the pick log: the pick, the judge's shadow verdict, the outcome. */
241
+ id?: string
227
242
  }
228
243
 
229
244
  export type AgentState = 'running' | 'waiting' | 'done' | 'failed'
@@ -243,6 +258,19 @@ export type AgentRec = {
243
258
  endedAt?: number
244
259
  /** Weighted tokens (spec §5 stats weights). */
245
260
  cost: number
261
+ /** The provider it runs on by config name (`anthropic` for Claude's own), for its card's dot. */
262
+ provider?: string
263
+ /**
264
+ * The effort its last step was sent at: the model id's `@effort` where it carries one (the router
265
+ * forces that, packages/router/src/routing.ts), else the request's own effort.
266
+ */
267
+ effort?: string | number
268
+ /** The model that answered its last step, by the id the API reported (TurnUsage `model`). */
269
+ answeredBy?: string
270
+ /** Its steps' token counts as the API reported them, summed; the shape of StatusRequest's usage. */
271
+ usage?: { input: number; cached: number; cacheWrite: number; output: number }
272
+ /** Tool calls it started. */
273
+ tools?: number
246
274
  }
247
275
 
248
276
  /** The main chat's prompt cache as far as its responses show it. */
@@ -265,6 +293,32 @@ export type WarmState = {
265
293
  /** The hottest plan window at or above the alert line. */
266
294
  export type Hot = { kind: string; percent: number; resetsAt?: string }
267
295
 
296
+ /**
297
+ * One plan window for the band's second row, the limits row: every window of every provider whose
298
+ * quota the router can read (Claude's plan windows and per-model weeklies, each ChatGPT account's
299
+ * windows, each Antigravity account's Gemini and third-party windows). The row reads them all from
300
+ * `GET /api/limits` (falling back to the older status routes on a router that predates it).
301
+ * `provider` is the provider's config name (the grouping key; 'claude' on the fallback path);
302
+ * `type` is its kind ('anthropic', 'chatgpt', 'google', …), which picks the group head's colour;
303
+ * `title` is the head's name ("Claude", "GPT", "Gemini"); `account` numbers the provider's eligible
304
+ * accounts from 0 (the row reads "GPT" alone, or "GPT 1", "GPT 2", … when a provider has several);
305
+ * `kind` is 'five_hour', 'seven_day', 'scoped' (a per-model/third-party window, its name in `label`)
306
+ * or 'window' (a window of another length, its minutes in `windowMinutes`).
307
+ */
308
+ export type LimitItem = {
309
+ provider: string
310
+ /** The provider's kind, for the head's colour. Absent on an older item, the name stands in. */
311
+ type?: string
312
+ /** The head's name; absent, derived from the provider's type or name. */
313
+ title?: string
314
+ account: number
315
+ kind: 'five_hour' | 'seven_day' | 'scoped' | 'window'
316
+ label: string
317
+ windowMinutes?: number
318
+ percent: number
319
+ resetsAt?: string
320
+ }
321
+
268
322
  export type Stats = {
269
323
  prompts: number
270
324
  requests: number
@@ -312,6 +366,8 @@ declare module 'claude-code' {
312
366
  context: { percent: number; tokens: number; window: number } | null
313
367
  hot: Hot | null
314
368
  hotHidden: number | null
369
+ /** The window the hot card was put off for: the put-off lasts until that window moves on. */
370
+ hotHiddenFor: Hot | null
315
371
  saveUntil: number | null
316
372
  swampHidden: number | null
317
373
  coldHidden: boolean
@@ -342,6 +398,14 @@ declare module 'claude-code' {
342
398
  effortMenuOpen: boolean
343
399
  agentsOpen: boolean
344
400
  agentsShowDone: boolean
401
+ /** The agent whose conversation the Agents pane shows; null shows the list. */
402
+ agentOpen: string | null
403
+ /** Pages of earlier entries the open conversation shows beyond its latest. */
404
+ agentEarlier: number
405
+ /** The open conversation's tool runs shown call by call, by their first call's id. */
406
+ agentRunsOpen: string[]
407
+ /** Counts the Agents pane's seconds while an agent runs: a write redraws its clocks. */
408
+ agentsTick: number
345
409
  jev: JevState
346
410
  routerModels: RouterModel[]
347
411
  /** The router's rung choices and presets (GET /api/mod-options), for the quick settings. */
@@ -354,6 +418,8 @@ declare module 'claude-code' {
354
418
  judgeRuns: JudgeRun[]
355
419
  judgeDownNetwork: boolean
356
420
  limit: Hot | null
421
+ /** Every provider's plan windows for the limits row, from `GET /api/limits`. Null until read. */
422
+ limits: LimitItem[] | null
357
423
  demo: 'cold' | 'swamp' | 'hot' | 'down' | 'kept' | null
358
424
  }
359
425
  }
@@ -0,0 +1,28 @@
1
+ {
2
+ "name": "@clauderipple/remote",
3
+ "version": "0.8.10",
4
+ "private": true,
5
+ "type": "module",
6
+ "description": "Ripple Remote: a host on this machine that runs Claude Code sessions in any folder on any routed model, and a phone-first web app that drives them.",
7
+ "license": "GPL-3.0-only",
8
+ "scripts": { "test": "node --test test/*.test.ts" },
9
+ "bin": {
10
+ "ripple-host": "./src/main.ts"
11
+ },
12
+ "engines": {
13
+ "node": ">=24"
14
+ },
15
+ "dependencies": {
16
+ "@anthropic-ai/claude-agent-sdk": "0.3.283",
17
+ "qrcode-generator": "2.0.4",
18
+ "tweetnacl": "1.0.3",
19
+ "web-push": "3.6.7",
20
+ "ws": "8.21.3",
21
+ "zod": "4.1.12"
22
+ },
23
+ "devDependencies": {
24
+ "@types/web-push": "3.6.4",
25
+ "@types/ws": "8.18.1",
26
+ "wrangler": "4.142.0"
27
+ }
28
+ }
@@ -0,0 +1,85 @@
1
+ // Whether this Mac is "away" — its screen locked, or no keyboard/mouse input for a while. It is
2
+ // the gate for pushes about Mac sessions the host is not tailing (Sessions' onAlert path): the
3
+ // person is at the desk while these are false, so a phone buzz would only be noise. Probe failures
4
+ // answer "here", never "away": a wrong push is worse than a missed one.
5
+ import { execFile } from "node:child_process";
6
+ /** No input for this long counts as away, matching the phone's own idle threshold (Paseo's PRESENCE_THRESHOLD_MS). */
7
+ export const IDLE_MS = 180_000;
8
+ const CACHE_MS = 10_000;
9
+ const TIMEOUT_MS = 2_000;
10
+ const MAX_BUFFER = 4 * 1024 * 1024;
11
+ const run = (cmd, args) => new Promise((resolve, reject) => {
12
+ execFile(cmd, args, { timeout: TIMEOUT_MS, maxBuffer: MAX_BUFFER }, (error, stdout) => error ? reject(error) : resolve(stdout));
13
+ });
14
+ /**
15
+ * Whether the console session's dictionary carries `CGSSessionScreenIsLocked` true.
16
+ * Measured on this Mac (Darwin 27, unlocked): the key is absent altogether, not `<false/>`, so only
17
+ * a `<true/>` inside the console user's entry is a lock. Nothing parses as a lock means no lock.
18
+ */
19
+ export function screenLocked(plist) {
20
+ const start = plist.indexOf("<key>IOConsoleUsers</key>");
21
+ if (start < 0)
22
+ return false;
23
+ const end = plist.indexOf("</array>", start);
24
+ const body = plist.slice(start, end < 0 ? plist.length : end);
25
+ for (const entry of body.split("<dict>").slice(1)) {
26
+ const close = entry.indexOf("</dict>");
27
+ const session = close < 0 ? entry : entry.slice(0, close);
28
+ if (!/<key>kCGSSessionOnConsoleKey<\/key>\s*<true\s*\/>/.test(session))
29
+ continue;
30
+ return /<key>CGSSessionScreenIsLocked<\/key>\s*<true\s*\/>/.test(session);
31
+ }
32
+ return false;
33
+ }
34
+ /** `HIDIdleTime` from `ioreg -c IOHIDSystem`, in milliseconds, or null when the line is missing. */
35
+ export function hidIdleMs(ioreg) {
36
+ const match = /"HIDIdleTime"\s*=\s*(\d+)/.exec(ioreg);
37
+ return match ? Number(match[1]) / 1_000_000 : null;
38
+ }
39
+ /** Both readings are `ioreg`; the answer is cached so a poll every few seconds probes once per CACHE_MS. */
40
+ export class MacAway {
41
+ runner;
42
+ platform;
43
+ now;
44
+ log;
45
+ at = -Infinity;
46
+ value = null;
47
+ pending = null;
48
+ /** The last probe failed; cleared by a probe that answers. One log line per failing streak. */
49
+ failed = false;
50
+ constructor(opts = {}) {
51
+ this.runner = opts.runner ?? run;
52
+ this.platform = opts.platform ?? process.platform;
53
+ this.now = opts.now ?? Date.now;
54
+ this.log = opts.log;
55
+ }
56
+ async away() {
57
+ if (this.platform !== "darwin")
58
+ return false;
59
+ const now = this.now();
60
+ if (this.value !== null && now - this.at < CACHE_MS)
61
+ return this.value;
62
+ // One probe at a time: a second caller rides the in-flight one instead of reading the old value.
63
+ this.at = now;
64
+ this.pending ??= this.probe().then(value => { this.value = value; return value; }).finally(() => { this.pending = null; });
65
+ return this.pending;
66
+ }
67
+ async probe() {
68
+ try {
69
+ const [lock, hid] = await Promise.all([
70
+ this.runner("/usr/sbin/ioreg", ["-n", "Root", "-d1", "-a"]),
71
+ this.runner("/usr/sbin/ioreg", ["-c", "IOHIDSystem"]),
72
+ ]);
73
+ const idle = hidIdleMs(hid);
74
+ this.failed = false;
75
+ return screenLocked(lock) || (idle !== null && idle >= IDLE_MS);
76
+ }
77
+ catch (error) {
78
+ if (!this.failed) {
79
+ this.failed = true;
80
+ this.log?.(`away: probe failed (${error instanceof Error ? error.message : String(error)}) — treated as here`);
81
+ }
82
+ return false;
83
+ }
84
+ }
85
+ }
@@ -0,0 +1,295 @@
1
+ // Client-only Remote Control transport. Never register or attach a desktop worker.
2
+ import { execFileSync } from "node:child_process";
3
+ import fs from "node:fs";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import { randomUUID } from "node:crypto";
7
+ import { setTimeout as delay } from "node:timers/promises";
8
+ export const DESKTOP_BLOCKED = "Claude 앱에서 열려 있는 세션에 지금은 보낼 수 없습니다.";
9
+ export const LOGIN_EXPIRED = "이 컴퓨터의 Claude 로그인이 만료되었습니다. Claude 앱을 한 번 열어 주십시오.";
10
+ export const LOGIN_REFUSED = "이 컴퓨터의 Claude 로그인이 거절되었습니다 (401). Claude 앱을 한 번 열어 주십시오.";
11
+ class BridgeNetworkError extends Error {
12
+ }
13
+ /**
14
+ * This Mac's Claude login cannot be used (expired, unreadable, or refused): nothing reaches any
15
+ * Claude app session until Claude Code refreshes it, which the host never does itself. A state of
16
+ * the whole host, not of one session, so phones are told once rather than per request.
17
+ */
18
+ export class BridgeAuthError extends Error {
19
+ }
20
+ /** While the login is expired, the keychain is read again at most this often (a child process each time). */
21
+ export const AUTH_REREAD_MS = 30_000;
22
+ export function object(value) {
23
+ return value !== null && typeof value === "object" && !Array.isArray(value) ? value : null;
24
+ }
25
+ export function loadBridgeAuth() {
26
+ if (process.platform !== "darwin")
27
+ throw new Error(DESKTOP_BLOCKED);
28
+ try {
29
+ 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"] })));
30
+ const oauth = object(credentials?.claudeAiOauth);
31
+ const account = object(object(JSON.parse(fs.readFileSync(path.join(os.homedir(), ".claude.json"), "utf8")))?.oauthAccount);
32
+ if (typeof oauth?.accessToken !== "string" || typeof oauth.expiresAt !== "number" || typeof account?.organizationUuid !== "string")
33
+ throw new Error();
34
+ return { accessToken: oauth.accessToken, expiresAt: oauth.expiresAt, organizationUuid: account.organizationUuid };
35
+ }
36
+ catch {
37
+ throw new BridgeAuthError("이 컴퓨터의 Claude 로그인 정보를 읽을 수 없습니다.");
38
+ }
39
+ }
40
+ /** `slash_commands` minus `terminal_slash_commands`, which phone UIs should hide (SDK init docs). */
41
+ export function slashCommands(init) {
42
+ if (!Array.isArray(init.slash_commands))
43
+ return null;
44
+ const terminal = new Set(Array.isArray(init.terminal_slash_commands) ? init.terminal_slash_commands : []);
45
+ return init.slash_commands.filter((c) => typeof c === "string" && !!c && !terminal.has(c));
46
+ }
47
+ export class BridgeClient {
48
+ fetchFn;
49
+ loadAuth;
50
+ cached = null;
51
+ readAt = 0;
52
+ now;
53
+ constructor(options = {}) {
54
+ this.fetchFn = options.fetch ?? fetch;
55
+ this.loadAuth = options.auth ?? loadBridgeAuth;
56
+ this.now = options.now ?? Date.now;
57
+ }
58
+ /**
59
+ * The keychain read is a synchronous child process; doing it per request blocked the host's
60
+ * event loop under the 3 s state poll until relay pings timed out (2026-10-12). Kept in memory
61
+ * and reread only near expiry or after a 401 (Claude Code refreshes the token on its own) — and
62
+ * near or past expiry at most every AUTH_REREAD_MS: an expired login otherwise meant one keychain
63
+ * read per request, every poll, for as long as it stayed expired (2026-10-15).
64
+ */
65
+ auth() {
66
+ const now = this.now();
67
+ const stale = !this.cached || this.refused || this.cached.expiresAt - now < 5 * 60_000;
68
+ if (stale && (!this.cached || now - this.readAt >= AUTH_REREAD_MS)) {
69
+ this.readAt = now;
70
+ this.cached = this.loadAuth();
71
+ this.refused = false;
72
+ }
73
+ return this.cached;
74
+ }
75
+ /** The API answered 401 to the token in `cached`; cleared when the keychain is read again. */
76
+ refused = false;
77
+ async request(id, suffix, options) {
78
+ const auth = this.auth();
79
+ if (this.refused)
80
+ throw new BridgeAuthError(LOGIN_REFUSED);
81
+ if (auth.expiresAt <= this.now())
82
+ throw new BridgeAuthError(LOGIN_EXPIRED);
83
+ let response;
84
+ try {
85
+ // The bridge treats a prompt as the user's own only from a person's app platform
86
+ // (ios, android, web_claude_ai, desktop_app in CLI 2.1.296); any claude_code_* platform
87
+ // is demoted to "a message from another Claude session". These prompts are typed on a phone.
88
+ response = await this.fetchFn(`https://api.anthropic.com/v1/code/sessions/${encodeURIComponent(id)}${suffix}`, {
89
+ ...options, redirect: "error", headers: { Authorization: `Bearer ${auth.accessToken}`, "anthropic-version": "2023-06-01", "anthropic-client-platform": "ios", "x-organization-uuid": auth.organizationUuid, "Content-Type": "application/json", "User-Agent": "RippleRemote/0.8.10" },
90
+ });
91
+ }
92
+ catch {
93
+ throw new BridgeNetworkError("Claude 앱과 연결하지 못했습니다.");
94
+ }
95
+ if (!response.ok) {
96
+ await response.body?.cancel();
97
+ if (response.status === 401) {
98
+ // Refused: the keychain is read again (Claude Code may have refreshed it) — at once if the
99
+ // last read is AUTH_REREAD_MS old, else then; until then requests fail here without a call.
100
+ if (this.now() - this.readAt >= AUTH_REREAD_MS)
101
+ this.cached = null;
102
+ else
103
+ this.refused = true;
104
+ throw new BridgeAuthError(LOGIN_REFUSED);
105
+ }
106
+ // Do not expose server bodies (which may echo credentials), or retry rejected writes.
107
+ throw new Error(`Claude 앱이 메시지를 받지 않았습니다 (${response.status}).`);
108
+ }
109
+ return response;
110
+ }
111
+ async post(id, payload) {
112
+ const response = await this.request(id, "/events", { method: "POST", body: JSON.stringify({ session_id: id, events: [{ payload }] }), signal: AbortSignal.timeout(15_000) });
113
+ await response.body?.cancel();
114
+ }
115
+ send(id, uuid, content) {
116
+ return this.post(id, { type: "user", uuid, message: { role: "user", content }, parent_tool_use_id: null });
117
+ }
118
+ interrupt(id) {
119
+ return this.post(id, { type: "control_request", request_id: randomUUID(), request: { subtype: "interrupt" } });
120
+ }
121
+ async session(id) {
122
+ const response = object(await (await this.request(id, "", { method: "GET", signal: AbortSignal.timeout(3000) })).json());
123
+ // CLI 2.1.296's single-session read uses response_shape ?? session.
124
+ const body = object(response?.response_shape ?? response?.session) ?? response;
125
+ const worker_status = body?.worker_status;
126
+ if (worker_status !== "running" && worker_status !== "idle" && worker_status !== "requires_action")
127
+ throw new Error("Remote Control session has no valid worker status.");
128
+ return { worker_status, ...(typeof body?.status === "string" ? { status: body.status } : {}),
129
+ ...(typeof body?.status_bucket === "string" ? { status_bucket: body.status_bucket } : {}),
130
+ ...(typeof body?.connection_status === "string" ? { connection_status: body.connection_status } : {}) };
131
+ }
132
+ async events(id) {
133
+ return object(await (await this.request(id, "/events?sort_order=asc", { signal: AbortSignal.timeout(15_000) })).json()) ?? {};
134
+ }
135
+ /** The session's slash commands, from the CLI's init event, without the terminal-only ones; null when it has none. */
136
+ async commands(id) {
137
+ const list = (await this.events(id)).data;
138
+ for (const value of Array.isArray(list) ? list : []) {
139
+ const payload = object(object(value)?.payload);
140
+ if (payload?.type === "system" && payload.subtype === "init")
141
+ return slashCommands(payload);
142
+ }
143
+ return null;
144
+ }
145
+ /**
146
+ * Authorization requests still open on the session, oldest first. The stream starts at the
147
+ * newest event (`latest` below), so attach and every reconnect replay this list from the start
148
+ * cursor instead — otherwise an approval requested before the stream connected never cards.
149
+ * A request followed by its control_response (answered from another client) or
150
+ * control_cancel_request (withdrawn) is not open and is left out.
151
+ */
152
+ async openRequests(id) {
153
+ const list = (await this.events(id)).data;
154
+ const open = new Map();
155
+ for (const value of Array.isArray(list) ? list : []) {
156
+ const item = object(value);
157
+ const payload = object(item?.payload);
158
+ if (!payload || typeof payload.request_id !== "string")
159
+ continue;
160
+ // Stored prompts carry the same source as stream frames; accept a missing one, never a foreign one.
161
+ const source = item?.source;
162
+ if (source !== undefined && source !== "worker")
163
+ continue;
164
+ const requestId = payload.request_id;
165
+ if (payload.type === "control_request") {
166
+ const request = object(payload.request);
167
+ const input = object(request?.input);
168
+ if (request?.subtype === "can_use_tool" && typeof request.tool_name === "string" && input && typeof request.tool_use_id === "string") {
169
+ open.set(requestId, { requestId, tool: request.tool_name, input, toolUseId: request.tool_use_id });
170
+ }
171
+ }
172
+ else if (payload.type === "control_response" || payload.type === "control_cancel_request") {
173
+ open.delete(requestId);
174
+ }
175
+ }
176
+ return [...open.values()];
177
+ }
178
+ /** The newest durable event's number; the stream starts there instead of replaying the whole session. */
179
+ async latest(id, signal) {
180
+ const body = object(await (await this.request(id, "/events?sort_order=desc&limit=1", { signal })).json());
181
+ const first = Array.isArray(body?.data) ? object(body.data[0]) : null;
182
+ return sequenceOf(first?.sequence_num) ?? 0;
183
+ }
184
+ async stream(id, signal, handle, onConnect) {
185
+ let sequence = null;
186
+ while (!signal.aborted) {
187
+ if (sequence === null) {
188
+ try {
189
+ sequence = await this.latest(id, signal);
190
+ }
191
+ catch (error) {
192
+ if (signal.aborted)
193
+ return;
194
+ if (!(error instanceof BridgeNetworkError))
195
+ throw error;
196
+ await delay(1000, undefined, { signal }).catch(error => { if (!signal.aborted)
197
+ throw error; });
198
+ continue;
199
+ }
200
+ }
201
+ let response;
202
+ try {
203
+ response = await this.request(id, `/events/stream?from_sequence_num=${sequence}`, { signal });
204
+ }
205
+ catch (error) {
206
+ if (signal.aborted)
207
+ return;
208
+ if (!(error instanceof BridgeNetworkError))
209
+ throw error;
210
+ await delay(1000, undefined, { signal }).catch(error => { if (!signal.aborted)
211
+ throw error; });
212
+ continue;
213
+ }
214
+ if (!response.body)
215
+ throw new Error("Remote Control stream is empty.");
216
+ // Connected (or reconnected): the caller replays approvals already waiting, which the
217
+ // newest-event cursor above would otherwise skip.
218
+ onConnect?.();
219
+ let handling = false;
220
+ try {
221
+ for await (const frame of decodeSse(response.body)) {
222
+ handling = true;
223
+ // Only live permission prompts come from here; the conversation itself is read from the transcript.
224
+ if (frame.event === "catch_up_truncated") {
225
+ handling = false;
226
+ continue;
227
+ }
228
+ const number = sequenceOf(frame.data.sequence_num);
229
+ if (frame.event === "client_event" && number !== undefined) {
230
+ if (number <= sequence) {
231
+ handling = false;
232
+ continue;
233
+ }
234
+ handle(frame);
235
+ sequence = number;
236
+ }
237
+ else if (frame.event === "ephemeral_event")
238
+ handle(frame);
239
+ handling = false;
240
+ }
241
+ }
242
+ catch (error) {
243
+ if (signal.aborted)
244
+ return;
245
+ if (handling)
246
+ throw error;
247
+ // A broken read reconnects; HTTP rejection above is terminal. Writes are never retried.
248
+ }
249
+ await delay(1000, undefined, { signal }).catch(error => { if (!signal.aborted)
250
+ throw error; });
251
+ }
252
+ }
253
+ }
254
+ /** The server sends sequence numbers as decimal strings ("9136"); tests and older shapes use numbers. */
255
+ function sequenceOf(value) {
256
+ const number = typeof value === "string" && /^\d+$/.test(value) ? Number(value) : value;
257
+ return typeof number === "number" && Number.isSafeInteger(number) && number >= 0 ? number : undefined;
258
+ }
259
+ export async function* decodeSse(body) {
260
+ const reader = body.getReader();
261
+ const decoder = new TextDecoder();
262
+ let pending = "", event = "", data = [];
263
+ try {
264
+ for (;;) {
265
+ const chunk = await reader.read();
266
+ if (chunk.done)
267
+ return;
268
+ pending += decoder.decode(chunk.value, { stream: true });
269
+ if (pending.length > 4 * 1024 * 1024)
270
+ throw new Error("Remote Control frame too large.");
271
+ let end;
272
+ while ((end = pending.indexOf("\n")) >= 0) {
273
+ const line = pending.slice(0, end).replace(/\r$/, "");
274
+ pending = pending.slice(end + 1);
275
+ if (line.startsWith("event:"))
276
+ event = line.slice(6).trim();
277
+ else if (line.startsWith("data:"))
278
+ data.push(line.slice(5).trimStart());
279
+ else if (!line) {
280
+ if (data.length) {
281
+ const parsed = object(JSON.parse(data.join("\n")));
282
+ if (parsed)
283
+ yield { event, data: parsed };
284
+ }
285
+ event = "";
286
+ data = [];
287
+ }
288
+ }
289
+ }
290
+ }
291
+ finally {
292
+ await reader.cancel().catch(() => undefined);
293
+ reader.releaseLock();
294
+ }
295
+ }
@@ -0,0 +1,11 @@
1
+ // The composer's "+" menu beyond attachments: compact the conversation, or start fresh.
2
+ // Pure (no platform imports) so the send behaviour is unit-tested without the native runtime.
3
+ export const COMPOSER_MENU = [
4
+ { id: "compact", ko: "대화 압축", en: "Compact conversation" },
5
+ { id: "restart", ko: "새로 시작", en: "Start fresh", confirm: { ko: "지금 대화를 마치고 같은 설정으로 새 세션을 시작합니다.", en: "End this conversation and start fresh with the same settings." } },
6
+ ];
7
+ /** The message an action sends. The host turns it into the SDK slash command; "/clear" is the
8
+ * fresh-session path it already handles, and "/compact" passes through to the CLI/bridge. */
9
+ export function composerCommand(id) {
10
+ return id === "compact" ? "/compact" : "/clear";
11
+ }
@@ -0,0 +1,102 @@
1
+ // Host settings and the pairing token, under ~/.clauderipple/remote (0600). The token is the
2
+ // only secret: whoever holds it can run Claude Code on this machine, so it is never logged,
3
+ // never put in a URL the server sees (the pairing link carries it in the #fragment), and
4
+ // compared in constant time.
5
+ import { randomBytes, timingSafeEqual } from "node:crypto";
6
+ import os from "node:os";
7
+ import path from "node:path";
8
+ import { ConfigStore, adminPort, configPath as routerConfigPath } from "../../router/src/config.js";
9
+ import { readJson, writeFileAtomic } from "./fsutil.js";
10
+ export const DEFAULT_PORT = 8797;
11
+ /** ClaudeRipple's own relay: what phone access turns back on with after it was switched off. */
12
+ export const DEFAULT_RELAY = "https://ripple-relay.clauderipple.workers.dev";
13
+ export function remoteHome() {
14
+ return process.env.RIPPLE_REMOTE_HOME ?? path.join(os.homedir(), ".clauderipple", "remote");
15
+ }
16
+ export function newToken() {
17
+ return randomBytes(32).toString("base64url");
18
+ }
19
+ /**
20
+ * Where the router's admin API is listening, read from the router's own config. The admin API sits
21
+ * beside the proxy listener: the router derives its port as `admin.port ?? listen.port + 1`, so a
22
+ * default install is 8791 while this Mac (listen 8791) is 8792. Hardcoding 8792 only worked on this
23
+ * machine; on a default Windows install the host hit a dead port (`/api/status: HTTP 404`).
24
+ */
25
+ export function routerAdminUrl(configFile = routerConfigPath()) {
26
+ return `http://127.0.0.1:${adminPort(new ConfigStore(configFile).get())}`;
27
+ }
28
+ /** `persist: false` reads without writing the generated secrets (for commands that only print). */
29
+ export function loadConfig(dir = remoteHome(), persist = true) {
30
+ const file = path.join(dir, "host.json");
31
+ const stored = readJson(file) ?? {};
32
+ const cfg = {
33
+ token: typeof stored.token === "string" && stored.token.length >= 32 ? stored.token : newToken(),
34
+ bind: typeof stored.bind === "string" ? stored.bind : "127.0.0.1",
35
+ port: typeof stored.port === "number" ? stored.port : DEFAULT_PORT,
36
+ adminUrl: typeof stored.adminUrl === "string" ? stored.adminUrl : routerAdminUrl(),
37
+ ...(typeof stored.claudePath === "string" ? { claudePath: stored.claudePath } : {}),
38
+ ...(typeof stored.relay === "string" ? { relay: stored.relay } : {}),
39
+ serverId: typeof stored.serverId === "string" && /^[\w-]{16,64}$/.test(stored.serverId) ? stored.serverId : randomBytes(16).toString("base64url"),
40
+ relaySecret: typeof stored.relaySecret === "string" && stored.relaySecret.length >= 32 ? stored.relaySecret : newToken(),
41
+ };
42
+ if (persist && (cfg.token !== stored.token || cfg.serverId !== stored.serverId || cfg.relaySecret !== stored.relaySecret))
43
+ saveConfig(cfg, dir);
44
+ return cfg;
45
+ }
46
+ export function saveConfig(cfg, dir = remoteHome()) {
47
+ writeFileAtomic(path.join(dir, "host.json"), JSON.stringify(cfg, null, 2) + "\n");
48
+ }
49
+ export function tokenMatches(expected, given) {
50
+ if (typeof given !== "string")
51
+ return false;
52
+ const a = Buffer.from(expected);
53
+ const b = Buffer.from(given);
54
+ return a.length === b.length && timingSafeEqual(a, b);
55
+ }
56
+ const isTailnet = (ip) => {
57
+ const [a, b] = ip.split(".").map(Number);
58
+ return a === 100 && b !== undefined && b >= 64 && b <= 127; // 100.64.0.0/10, Tailscale's CGNAT range
59
+ };
60
+ const isLan = (ip) => /^(10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.)/.test(ip);
61
+ function firstV4(ifaces, pick) {
62
+ for (const list of Object.values(ifaces))
63
+ for (const a of list ?? [])
64
+ if (a.family === "IPv4" && !a.internal && pick(a.address))
65
+ return a.address;
66
+ return null;
67
+ }
68
+ /** The address to listen on, and the one to put in the pairing link. */
69
+ export function resolveBind(bind, ifaces = os.networkInterfaces()) {
70
+ if (bind === "tailscale" || bind === "lan") {
71
+ const ip = firstV4(ifaces, bind === "tailscale" ? isTailnet : isLan);
72
+ if (!ip)
73
+ throw new Error(`no ${bind === "tailscale" ? "Tailscale (100.64.0.0/10)" : "LAN"} address on this machine`);
74
+ return { listen: ip, advertise: ip, reachableFromPhone: true };
75
+ }
76
+ if (bind === "0.0.0.0" || bind === "::") {
77
+ const ip = firstV4(ifaces, isTailnet) ?? firstV4(ifaces, isLan) ?? "127.0.0.1";
78
+ return { listen: bind, advertise: ip, reachableFromPhone: ip !== "127.0.0.1" };
79
+ }
80
+ const loopback = bind === "127.0.0.1" || bind === "::1" || bind === "localhost";
81
+ return { listen: bind, advertise: bind, reachableFromPhone: !loopback };
82
+ }
83
+ export function pairingUrl(advertise, port, token) {
84
+ const host = advertise.includes(":") ? `[${advertise}]` : advertise;
85
+ return `http://${host}:${port}/#pair=${token}`;
86
+ }
87
+ /**
88
+ * The pairing link through the relay. Everything after `#` stays on the phone: browsers never send a
89
+ * fragment, so the relay sees neither the token nor the host's public key, only the page request.
90
+ */
91
+ export function relayPairingUrl(relay, serverId, hostPublicKey, token) {
92
+ const origin = new URL(relay).origin;
93
+ return `${origin}/#pair=${token}&server=${serverId}&hpk=${hostPublicKey}`;
94
+ }
95
+ /** The relay's address, validated: https (or http only for a local `wrangler dev`). */
96
+ export function normalizeRelay(url) {
97
+ const u = new URL(url);
98
+ const local = u.hostname === "localhost" || u.hostname === "127.0.0.1" || u.hostname === "[::1]";
99
+ if (u.protocol !== "https:" && !(u.protocol === "http:" && local))
100
+ throw new Error(`relay must be https:// (http:// only for localhost): ${url}`);
101
+ return u.origin;
102
+ }