clauderipple 0.9.5 → 0.11.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 (163) hide show
  1. package/CHANGELOG.md +118 -0
  2. package/README.es.md +45 -0
  3. package/README.ja.md +17 -0
  4. package/README.ko.md +31 -0
  5. package/README.md +70 -0
  6. package/README.pt-BR.md +45 -0
  7. package/README.zh-CN.md +17 -0
  8. package/dist/app/assets/icon.icns +0 -0
  9. package/dist/app/assets/icon.ico +0 -0
  10. package/dist/app/assets/icon.png +0 -0
  11. package/dist/app/assets/trayDirectTemplate.png +0 -0
  12. package/dist/app/assets/trayDirectTemplate@2x.png +0 -0
  13. package/dist/app/assets/trayDownTemplate.png +0 -0
  14. package/dist/app/assets/trayDownTemplate@2x.png +0 -0
  15. package/dist/app/assets/trayTemplate.png +0 -0
  16. package/dist/app/assets/trayTemplate@2x.png +0 -0
  17. package/dist/app/assets/trayWarnTemplate.png +0 -0
  18. package/dist/app/assets/trayWarnTemplate@2x.png +0 -0
  19. package/dist/app/assets/trayWin.png +0 -0
  20. package/dist/app/assets/trayWin@2x.png +0 -0
  21. package/dist/app/assets/trayWinDirect.png +0 -0
  22. package/dist/app/assets/trayWinDirect@2x.png +0 -0
  23. package/dist/app/assets/trayWinDown.png +0 -0
  24. package/dist/app/assets/trayWinDown@2x.png +0 -0
  25. package/dist/app/assets/trayWinWarn.png +0 -0
  26. package/dist/app/assets/trayWinWarn@2x.png +0 -0
  27. package/dist/app/dist/claude-desktop.js +67 -0
  28. package/dist/app/dist/main.js +423 -110
  29. package/dist/app/dist/preload.js +21 -0
  30. package/dist/app/dist/protocol.js +121 -0
  31. package/dist/app/dist/status-model.js +87 -0
  32. package/dist/app/dist/window.js +133 -0
  33. package/dist/app/package.json +1 -1
  34. package/dist/cli/src/awake-setup.js +314 -0
  35. package/dist/cli/src/bundle.js +9 -5
  36. package/dist/cli/src/direct.js +159 -0
  37. package/dist/cli/src/doctor.js +205 -0
  38. package/dist/cli/src/hooks/agent-title.js +7 -4
  39. package/dist/cli/src/index.js +227 -31
  40. package/dist/cli/src/launchd.js +29 -20
  41. package/dist/cli/src/launcher.js +215 -0
  42. package/dist/cli/src/power.js +182 -0
  43. package/dist/cli/src/registration.js +157 -0
  44. package/dist/cli/src/remote.js +263 -0
  45. package/dist/cli/src/schtasks.js +50 -40
  46. package/dist/cli/src/settings.js +26 -1
  47. package/dist/cli/src/tray.js +5 -2
  48. package/dist/mod/hooks/i18n.ts +473 -227
  49. package/dist/mod/hooks/judge.ts +35 -17
  50. package/dist/mod/hooks/modes.ts +56 -1
  51. package/dist/mod/hooks/register.tsx +237 -32
  52. package/dist/mod/hooks/session.ts +102 -1
  53. package/dist/mod/hooks/settings.js +7 -3
  54. package/dist/mod/hooks/settings.ts +9 -4
  55. package/dist/mod/hooks/stats.ts +25 -1
  56. package/dist/mod/hooks/ui/agents.tsx +903 -79
  57. package/dist/mod/hooks/ui/alerts.tsx +20 -17
  58. package/dist/mod/hooks/ui/art.ts +33 -7
  59. package/dist/mod/hooks/ui/band.tsx +262 -81
  60. package/dist/mod/hooks/ui/flat.ts +265 -0
  61. package/dist/mod/hooks/ui/kit.tsx +70 -55
  62. package/dist/mod/hooks/ui/look.ts +129 -0
  63. package/dist/mod/hooks/ui/settings.tsx +71 -46
  64. package/dist/mod/hooks/ui/setup.tsx +15 -13
  65. package/dist/mod/hooks/ui/theme.ts +3 -0
  66. package/dist/mod/hooks/ui/tools.ts +124 -0
  67. package/dist/mod/hooks/ui/view.ts +120 -4
  68. package/dist/mod/hooks/workers.ts +33 -6
  69. package/dist/mod/tests/register.test.tsx +155 -11
  70. package/dist/mod/tests/ui.test.tsx +408 -27
  71. package/dist/mod/types/index.d.ts +66 -2
  72. package/dist/remote/package.json +28 -0
  73. package/dist/remote/src/away.js +85 -0
  74. package/dist/remote/src/bridge-client.js +336 -0
  75. package/dist/remote/src/composer-menu.js +11 -0
  76. package/dist/remote/src/config.js +102 -0
  77. package/dist/remote/src/desktop.js +1018 -0
  78. package/dist/remote/src/e2ee.js +126 -0
  79. package/dist/remote/src/e2ee.ts +126 -0
  80. package/dist/remote/src/eventlog.js +298 -0
  81. package/dist/remote/src/expo-push.js +127 -0
  82. package/dist/remote/src/folders.js +79 -0
  83. package/dist/remote/src/fsutil.js +39 -0
  84. package/dist/remote/src/handoff.js +38 -0
  85. package/dist/remote/src/host-service.js +168 -0
  86. package/dist/remote/src/images.js +215 -0
  87. package/dist/remote/src/local-control.js +138 -0
  88. package/dist/remote/src/main.js +264 -0
  89. package/dist/remote/src/permissions.js +124 -0
  90. package/dist/remote/src/power.js +76 -0
  91. package/dist/remote/src/processes.js +24 -0
  92. package/dist/remote/src/protocol.js +32 -0
  93. package/dist/remote/src/protocol.ts +284 -0
  94. package/dist/remote/src/push-content.js +32 -0
  95. package/dist/remote/src/push.js +134 -0
  96. package/dist/remote/src/receipts.js +111 -0
  97. package/dist/remote/src/relay.js +438 -0
  98. package/dist/remote/src/router.js +208 -0
  99. package/dist/remote/src/sdk-launcher.js +18 -0
  100. package/dist/remote/src/server.js +706 -0
  101. package/dist/remote/src/service.js +157 -0
  102. package/dist/remote/src/sessions.js +1800 -0
  103. package/dist/remote/src/sidebar-storage.js +211 -0
  104. package/dist/remote/src/sidebar.js +173 -0
  105. package/dist/remote/src/tool-details.js +205 -0
  106. package/dist/remote/src/tool-details.ts +153 -0
  107. package/dist/remote/src/worktree.js +99 -0
  108. package/dist/remote/web/app.ts +746 -0
  109. package/dist/remote/web/conn.ts +193 -0
  110. package/dist/remote/web/demo.ts +134 -0
  111. package/dist/remote/web/dom.ts +124 -0
  112. package/dist/remote/web/esc.ts +4 -0
  113. package/dist/remote/web/i18n.ts +374 -0
  114. package/dist/remote/web/icon.svg +1 -0
  115. package/dist/remote/web/index.html +24 -0
  116. package/dist/remote/web/manifest.webmanifest +10 -0
  117. package/dist/remote/web/mascot.ts +60 -0
  118. package/dist/remote/web/md.ts +78 -0
  119. package/dist/remote/web/notify.ts +55 -0
  120. package/dist/remote/web/pushstate.ts +27 -0
  121. package/dist/remote/web/secure.ts +109 -0
  122. package/dist/remote/web/sidebar.ts +106 -0
  123. package/dist/remote/web/style.css +311 -0
  124. package/dist/remote/web/sw.ts +48 -0
  125. package/dist/remote/web/timeline.ts +224 -0
  126. package/dist/remote/web/views.ts +323 -0
  127. package/dist/router/src/admin.js +331 -5
  128. package/dist/router/src/awake-win.js +590 -0
  129. package/dist/router/src/awake.js +432 -0
  130. package/dist/router/src/config.js +94 -0
  131. package/dist/router/src/health.js +86 -3
  132. package/dist/router/src/hold.js +107 -0
  133. package/dist/router/src/index.js +108 -6
  134. package/dist/router/src/ingress/server.js +26 -4
  135. package/dist/router/src/ingress/translate.js +6 -2
  136. package/dist/router/src/phone.js +82 -0
  137. package/dist/router/src/power.js +107 -0
  138. package/dist/router/src/providers/anthropic-usage.js +33 -1
  139. package/dist/router/src/providers/google/antigravity.js +73 -10
  140. package/dist/router/src/providers/google/index.js +191 -14
  141. package/dist/router/src/providers/google/prompt-refusal.js +29 -0
  142. package/dist/router/src/providers/google/thoughtSignatures.js +13 -0
  143. package/dist/router/src/providers/google/transport.js +1 -1
  144. package/dist/router/src/providers/google/usage.js +302 -0
  145. package/dist/router/src/providers/grok/index.js +18 -0
  146. package/dist/router/src/providers/grok/usage.js +77 -0
  147. package/dist/router/src/providers/openai/translate.js +13 -2
  148. package/dist/router/src/providers/opencode-usage.js +116 -0
  149. package/dist/router/src/providers/retry.js +4 -1
  150. package/dist/router/src/proxy.js +371 -261
  151. package/dist/router/src/remote-host.js +320 -0
  152. package/dist/router/src/remote-tunnel.js +320 -0
  153. package/dist/router/src/requestlog.js +48 -39
  154. package/dist/router/src/tether-win.js +211 -0
  155. package/dist/router/src/tether.js +712 -0
  156. package/dist/router/src/usage-history.js +227 -0
  157. package/dist/router/src/version.js +1 -1
  158. package/dist/ui/app.js +1083 -59
  159. package/dist/ui/i18n.js +670 -69
  160. package/dist/ui/index.html +33 -6
  161. package/dist/ui/style.css +177 -0
  162. package/docs/ARCHITECTURE.md +402 -14
  163. package/package.json +8 -2
@@ -0,0 +1,126 @@
1
+ // End-to-end encryption between the phone and the host, so the relay forwards bytes it cannot read
2
+ // or forge. No imports: the host also serves this file to the browser (as /src/e2ee.ts), and
3
+ // both ends pass in tweetnacl (`nacl`) themselves, so the same lines run on both sides.
4
+ //
5
+ // Handshake (one per connection):
6
+ // phone → host text {"e2ee":"hello","v":1,"pk":<phone ephemeral X25519 public key>}
7
+ // host → phone text {"e2ee":"ready","v":1,"n":<nonce>,"box":box(<host ephemeral public key>, n, phone eph pk, host static sk)}
8
+ // The phone knows the host's static public key from the pairing QR (URL fragment, never sent to
9
+ // the relay), so only the real host can make a `box` that opens: the relay cannot stand in for the
10
+ // host. Both sides then use crypto_box_beforenm(peer ephemeral pk, own ephemeral sk) as the key for
11
+ // this connection — ephemeral on both sides, so a host key stolen later does not open old traffic.
12
+ // The phone proves nothing here; it proves itself with the pairing token, inside the channel.
13
+ //
14
+ // Frames after the handshake are binary: XSalsa20-Poly1305 (crypto_box_afternm) with a nonce that
15
+ // is not sent but counted — byte 0 the direction (1 host→phone, 2 phone→host), bytes 16–23 the
16
+ // frame number. A dropped, repeated, reordered or altered frame fails to open, and the link closes.
17
+ export const E2EE_VERSION = 1;
18
+ /** Bytes crypto_box adds to a frame (the Poly1305 tag). */
19
+ export const BOX_OVERHEAD = 16;
20
+ const HOST_TO_PHONE = 1;
21
+ const PHONE_TO_HOST = 2;
22
+ export function toB64u(bytes) {
23
+ let s = "";
24
+ for (const b of bytes)
25
+ s += String.fromCharCode(b);
26
+ return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
27
+ }
28
+ export function fromB64u(text) {
29
+ if (!/^[A-Za-z0-9_-]*$/.test(text) || text.length % 4 === 1)
30
+ throw new Error("bad base64url");
31
+ const s = atob(text.replace(/-/g, "+").replace(/_/g, "/") + "=".repeat((4 - (text.length % 4)) % 4));
32
+ const out = new Uint8Array(s.length);
33
+ for (let i = 0; i < s.length; i++)
34
+ out[i] = s.charCodeAt(i);
35
+ return out;
36
+ }
37
+ function key32(text) {
38
+ if (typeof text !== "string")
39
+ throw new Error("handshake: missing key");
40
+ let k;
41
+ try {
42
+ k = fromB64u(text);
43
+ }
44
+ catch {
45
+ throw new Error("handshake: bad key");
46
+ }
47
+ if (k.length !== 32)
48
+ throw new Error("handshake: bad key length");
49
+ return k;
50
+ }
51
+ function nonceFor(direction, n) {
52
+ const nonce = new Uint8Array(24);
53
+ nonce[0] = direction;
54
+ const view = new DataView(nonce.buffer);
55
+ view.setUint32(16, Math.floor(n / 0x1_0000_0000));
56
+ view.setUint32(20, n >>> 0);
57
+ return nonce;
58
+ }
59
+ /** One direction-aware encrypted link. Throws on anything that does not open. */
60
+ export class Channel {
61
+ nacl;
62
+ key;
63
+ sendDir;
64
+ recvDir;
65
+ sent = 0;
66
+ received = 0;
67
+ enc = new TextEncoder();
68
+ dec = new TextDecoder("utf-8", { fatal: true });
69
+ constructor(nacl, key, side) {
70
+ this.nacl = nacl;
71
+ this.key = key;
72
+ this.sendDir = side === "host" ? HOST_TO_PHONE : PHONE_TO_HOST;
73
+ this.recvDir = side === "host" ? PHONE_TO_HOST : HOST_TO_PHONE;
74
+ }
75
+ seal(text) {
76
+ return this.nacl.box.after(this.enc.encode(text), nonceFor(this.sendDir, this.sent++), this.key);
77
+ }
78
+ open(frame) {
79
+ const plain = this.nacl.box.open.after(frame, nonceFor(this.recvDir, this.received), this.key);
80
+ if (!plain)
81
+ throw new Error("e2ee: frame did not open (altered, replayed or out of order)");
82
+ this.received++;
83
+ return this.dec.decode(plain);
84
+ }
85
+ }
86
+ export function phoneHello(nacl) {
87
+ const eph = nacl.box.keyPair();
88
+ return { text: JSON.stringify({ e2ee: "hello", v: E2EE_VERSION, pk: toB64u(eph.publicKey) }), eph };
89
+ }
90
+ /** Host side: answer a phone's hello. Throws on a malformed hello. */
91
+ export function hostAccept(nacl, hostStatic, helloText) {
92
+ let hello;
93
+ try {
94
+ hello = JSON.parse(helloText);
95
+ }
96
+ catch {
97
+ throw new Error("handshake: not JSON");
98
+ }
99
+ if (hello.e2ee !== "hello" || hello.v !== E2EE_VERSION)
100
+ throw new Error("handshake: not a hello");
101
+ const phonePk = key32(hello.pk);
102
+ const eph = nacl.box.keyPair();
103
+ const n = nacl.randomBytes(24);
104
+ const sealed = nacl.box(eph.publicKey, n, phonePk, hostStatic.secretKey);
105
+ const channel = new Channel(nacl, nacl.box.before(phonePk, eph.secretKey), "host");
106
+ return { text: JSON.stringify({ e2ee: "ready", v: E2EE_VERSION, n: toB64u(n), box: toB64u(sealed) }), channel };
107
+ }
108
+ /** Phone side: check the host's answer against the host key from pairing. Throws if it is not that host. */
109
+ export function phoneFinish(nacl, eph, hostPublicKey, readyText) {
110
+ let ready;
111
+ try {
112
+ ready = JSON.parse(readyText);
113
+ }
114
+ catch {
115
+ throw new Error("handshake: not JSON");
116
+ }
117
+ if (ready.e2ee !== "ready" || ready.v !== E2EE_VERSION || typeof ready.n !== "string" || typeof ready.box !== "string")
118
+ throw new Error("handshake: not a ready");
119
+ const n = fromB64u(ready.n);
120
+ if (n.length !== 24)
121
+ throw new Error("handshake: bad nonce");
122
+ const hostEph = nacl.box.open(fromB64u(ready.box), n, hostPublicKey, eph.secretKey);
123
+ if (!hostEph || hostEph.length !== 32)
124
+ throw new Error("handshake: the answer is not from the paired host");
125
+ return new Channel(nacl, nacl.box.before(hostEph, eph.secretKey), "phone");
126
+ }
@@ -0,0 +1,126 @@
1
+ // End-to-end encryption between the phone and the host, so the relay forwards bytes it cannot read
2
+ // or forge. No imports: the host also serves this file to the browser (as /src/e2ee.ts), and
3
+ // both ends pass in tweetnacl (`nacl`) themselves, so the same lines run on both sides.
4
+ //
5
+ // Handshake (one per connection):
6
+ // phone → host text {"e2ee":"hello","v":1,"pk":<phone ephemeral X25519 public key>}
7
+ // host → phone text {"e2ee":"ready","v":1,"n":<nonce>,"box":box(<host ephemeral public key>, n, phone eph pk, host static sk)}
8
+ // The phone knows the host's static public key from the pairing QR (URL fragment, never sent to
9
+ // the relay), so only the real host can make a `box` that opens: the relay cannot stand in for the
10
+ // host. Both sides then use crypto_box_beforenm(peer ephemeral pk, own ephemeral sk) as the key for
11
+ // this connection — ephemeral on both sides, so a host key stolen later does not open old traffic.
12
+ // The phone proves nothing here; it proves itself with the pairing token, inside the channel.
13
+ //
14
+ // Frames after the handshake are binary: XSalsa20-Poly1305 (crypto_box_afternm) with a nonce that
15
+ // is not sent but counted — byte 0 the direction (1 host→phone, 2 phone→host), bytes 16–23 the
16
+ // frame number. A dropped, repeated, reordered or altered frame fails to open, and the link closes.
17
+
18
+ export interface KeyPair { publicKey: Uint8Array; secretKey: Uint8Array }
19
+ /** The slice of tweetnacl used here. */
20
+ export interface Nacl {
21
+ box: {
22
+ (msg: Uint8Array, nonce: Uint8Array, publicKey: Uint8Array, secretKey: Uint8Array): Uint8Array;
23
+ keyPair(): KeyPair;
24
+ before(publicKey: Uint8Array, secretKey: Uint8Array): Uint8Array;
25
+ after(msg: Uint8Array, nonce: Uint8Array, key: Uint8Array): Uint8Array;
26
+ open: { after(box: Uint8Array, nonce: Uint8Array, key: Uint8Array): Uint8Array | null } & ((box: Uint8Array, nonce: Uint8Array, publicKey: Uint8Array, secretKey: Uint8Array) => Uint8Array | null);
27
+ };
28
+ randomBytes(n: number): Uint8Array;
29
+ }
30
+
31
+ export const E2EE_VERSION = 1;
32
+ /** Bytes crypto_box adds to a frame (the Poly1305 tag). */
33
+ export const BOX_OVERHEAD = 16;
34
+ const HOST_TO_PHONE = 1;
35
+ const PHONE_TO_HOST = 2;
36
+
37
+ export function toB64u(bytes: Uint8Array): string {
38
+ let s = "";
39
+ for (const b of bytes) s += String.fromCharCode(b);
40
+ return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
41
+ }
42
+
43
+ export function fromB64u(text: string): Uint8Array {
44
+ if (!/^[A-Za-z0-9_-]*$/.test(text) || text.length % 4 === 1) throw new Error("bad base64url");
45
+ const s = atob(text.replace(/-/g, "+").replace(/_/g, "/") + "=".repeat((4 - (text.length % 4)) % 4));
46
+ const out = new Uint8Array(s.length);
47
+ for (let i = 0; i < s.length; i++) out[i] = s.charCodeAt(i);
48
+ return out;
49
+ }
50
+
51
+ function key32(text: unknown): Uint8Array {
52
+ if (typeof text !== "string") throw new Error("handshake: missing key");
53
+ let k: Uint8Array;
54
+ try { k = fromB64u(text); } catch { throw new Error("handshake: bad key"); }
55
+ if (k.length !== 32) throw new Error("handshake: bad key length");
56
+ return k;
57
+ }
58
+
59
+ function nonceFor(direction: number, n: number): Uint8Array {
60
+ const nonce = new Uint8Array(24);
61
+ nonce[0] = direction;
62
+ const view = new DataView(nonce.buffer);
63
+ view.setUint32(16, Math.floor(n / 0x1_0000_0000));
64
+ view.setUint32(20, n >>> 0);
65
+ return nonce;
66
+ }
67
+
68
+ /** One direction-aware encrypted link. Throws on anything that does not open. */
69
+ export class Channel {
70
+ private nacl: Nacl;
71
+ private key: Uint8Array;
72
+ private sendDir: number;
73
+ private recvDir: number;
74
+ private sent = 0;
75
+ private received = 0;
76
+ private enc = new TextEncoder();
77
+ private dec = new TextDecoder("utf-8", { fatal: true });
78
+
79
+ constructor(nacl: Nacl, key: Uint8Array, side: "host" | "phone") {
80
+ this.nacl = nacl;
81
+ this.key = key;
82
+ this.sendDir = side === "host" ? HOST_TO_PHONE : PHONE_TO_HOST;
83
+ this.recvDir = side === "host" ? PHONE_TO_HOST : HOST_TO_PHONE;
84
+ }
85
+
86
+ seal(text: string): Uint8Array {
87
+ return this.nacl.box.after(this.enc.encode(text), nonceFor(this.sendDir, this.sent++), this.key);
88
+ }
89
+
90
+ open(frame: Uint8Array): string {
91
+ const plain = this.nacl.box.open.after(frame, nonceFor(this.recvDir, this.received), this.key);
92
+ if (!plain) throw new Error("e2ee: frame did not open (altered, replayed or out of order)");
93
+ this.received++;
94
+ return this.dec.decode(plain);
95
+ }
96
+ }
97
+
98
+ export function phoneHello(nacl: Nacl): { text: string; eph: KeyPair } {
99
+ const eph = nacl.box.keyPair();
100
+ return { text: JSON.stringify({ e2ee: "hello", v: E2EE_VERSION, pk: toB64u(eph.publicKey) }), eph };
101
+ }
102
+
103
+ /** Host side: answer a phone's hello. Throws on a malformed hello. */
104
+ export function hostAccept(nacl: Nacl, hostStatic: KeyPair, helloText: string): { text: string; channel: Channel } {
105
+ let hello: { e2ee?: unknown; v?: unknown; pk?: unknown };
106
+ try { hello = JSON.parse(helloText) as typeof hello; } catch { throw new Error("handshake: not JSON"); }
107
+ if (hello.e2ee !== "hello" || hello.v !== E2EE_VERSION) throw new Error("handshake: not a hello");
108
+ const phonePk = key32(hello.pk);
109
+ const eph = nacl.box.keyPair();
110
+ const n = nacl.randomBytes(24);
111
+ const sealed = nacl.box(eph.publicKey, n, phonePk, hostStatic.secretKey);
112
+ const channel = new Channel(nacl, nacl.box.before(phonePk, eph.secretKey), "host");
113
+ return { text: JSON.stringify({ e2ee: "ready", v: E2EE_VERSION, n: toB64u(n), box: toB64u(sealed) }), channel };
114
+ }
115
+
116
+ /** Phone side: check the host's answer against the host key from pairing. Throws if it is not that host. */
117
+ export function phoneFinish(nacl: Nacl, eph: KeyPair, hostPublicKey: Uint8Array, readyText: string): Channel {
118
+ let ready: { e2ee?: unknown; v?: unknown; n?: unknown; box?: unknown };
119
+ try { ready = JSON.parse(readyText) as typeof ready; } catch { throw new Error("handshake: not JSON"); }
120
+ if (ready.e2ee !== "ready" || ready.v !== E2EE_VERSION || typeof ready.n !== "string" || typeof ready.box !== "string") throw new Error("handshake: not a ready");
121
+ const n = fromB64u(ready.n);
122
+ if (n.length !== 24) throw new Error("handshake: bad nonce");
123
+ const hostEph = nacl.box.open(fromB64u(ready.box), n, hostPublicKey, eph.secretKey);
124
+ if (!hostEph || hostEph.length !== 32) throw new Error("handshake: the answer is not from the paired host");
125
+ return new Channel(nacl, nacl.box.before(hostEph, eph.secretKey), "phone");
126
+ }
@@ -0,0 +1,298 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { closeSync, ftruncateSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, writeFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { readJson, writeFileAtomic } from "./fsutil.js";
5
+ import { FRAME_CAP, INLINE_CAP } from "./protocol.js";
6
+ import { changeCount, toolInput } from "./tool-details.js";
7
+ const LINE_CAP = 64 * 1024;
8
+ const PAGE_CAP = FRAME_CAP - 4096;
9
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
10
+ function serialized(event) { return JSON.stringify(event) + "\n"; }
11
+ function fitLine(event) {
12
+ let line = serialized(event);
13
+ if (Buffer.byteLength(line) <= LINE_CAP)
14
+ return line;
15
+ // Only display strings are lossy: text, input, summary, description, title,
16
+ // error, detail, reason and blob previews. Routing/identity fields stay intact.
17
+ const fields = new Set(["text", "input", "summary", "description", "title", "error", "detail", "reason", "preview"]);
18
+ const candidates = [];
19
+ function collect(value) {
20
+ if (Array.isArray(value)) {
21
+ for (const child of value)
22
+ collect(child);
23
+ return;
24
+ }
25
+ if (value === null || typeof value !== "object")
26
+ return;
27
+ const object = value;
28
+ for (const [key, child] of Object.entries(object)) {
29
+ if (typeof child === "string" && fields.has(key) && child.length > 1)
30
+ candidates.push({ object, key, text: child });
31
+ else if (typeof child === "object")
32
+ collect(child);
33
+ }
34
+ }
35
+ collect(event);
36
+ candidates.sort((a, b) => Buffer.byteLength(JSON.stringify(b.text)) - Buffer.byteLength(JSON.stringify(a.text)));
37
+ for (const { object, key, text } of candidates) {
38
+ object[key] = "…";
39
+ line = serialized(event);
40
+ if (Buffer.byteLength(line) > LINE_CAP)
41
+ continue;
42
+ let low = 0;
43
+ let high = text.length - 1;
44
+ while (low < high) {
45
+ const mid = Math.ceil((low + high) / 2);
46
+ object[key] = prefix(text, mid) + "…";
47
+ if (Buffer.byteLength(serialized(event)) <= LINE_CAP)
48
+ low = mid;
49
+ else
50
+ high = mid - 1;
51
+ }
52
+ object[key] = prefix(text, low) + "…";
53
+ return serialized(event);
54
+ }
55
+ throw new RangeError("Event structure exceeds 64 KiB even after truncating display strings");
56
+ }
57
+ function prefix(text, length) {
58
+ const last = text.charCodeAt(length - 1);
59
+ return text.slice(0, last >= 0xd800 && last <= 0xdbff ? length - 1 : length);
60
+ }
61
+ export class EventLog {
62
+ epoch;
63
+ dir;
64
+ segmentBytes;
65
+ now;
66
+ entries = [];
67
+ segments = [];
68
+ finalized = new Set();
69
+ closed = false;
70
+ constructor(opts, epoch) {
71
+ this.dir = opts.dir;
72
+ this.segmentBytes = opts.segmentBytes ?? 8 * 1024 * 1024;
73
+ this.now = opts.now ?? Date.now;
74
+ this.epoch = epoch;
75
+ if (!Number.isFinite(this.segmentBytes) || this.segmentBytes <= 0)
76
+ throw new RangeError("segmentBytes must be positive");
77
+ }
78
+ static open(opts) {
79
+ mkdirSync(opts.dir, { recursive: true, mode: 0o700 });
80
+ const names = readdirSync(opts.dir).filter(name => /^events-\d{6,}\.jsonl$/.test(name))
81
+ .sort((a, b) => Number(a.slice(7, -6)) - Number(b.slice(7, -6)));
82
+ const metaFile = join(opts.dir, "meta.json");
83
+ let meta = readJson(metaFile);
84
+ if (meta === null) {
85
+ if (names.length)
86
+ throw new Error("Event log has segments but no epoch");
87
+ meta = { epoch: randomUUID() };
88
+ writeFileAtomic(metaFile, JSON.stringify(meta));
89
+ }
90
+ if (typeof meta.epoch !== "string" || !UUID.test(meta.epoch))
91
+ throw new Error("Invalid event log epoch");
92
+ const log = new EventLog(opts, meta.epoch);
93
+ try {
94
+ for (const [i, name] of names.entries())
95
+ log.scan(name, i === names.length - 1);
96
+ if (!log.segments.length)
97
+ log.newSegment();
98
+ return log;
99
+ }
100
+ catch (error) {
101
+ log.close();
102
+ throw error;
103
+ }
104
+ }
105
+ get lastSeq() { return this.entries.length; }
106
+ newSegment() {
107
+ const number = (this.segments.at(-1)?.number ?? 0) + 1;
108
+ const file = join(this.dir, `events-${String(number).padStart(6, "0")}.jsonl`);
109
+ const segment = { fd: openSync(file, "ax+", 0o600), bytes: 0, number };
110
+ this.segments.push(segment);
111
+ return segment;
112
+ }
113
+ scan(name, last) {
114
+ const file = join(this.dir, name);
115
+ const data = readFileSync(file);
116
+ const segment = { fd: openSync(file, "a+", 0o600), bytes: data.length, number: Number(name.slice(7, -6)) };
117
+ this.segments.push(segment);
118
+ let offset = 0;
119
+ while (offset < data.length) {
120
+ const end = data.indexOf(10, offset);
121
+ if (end === -1) {
122
+ if (!last)
123
+ throw new Error(`Unterminated event in ${name}`);
124
+ break;
125
+ }
126
+ let event;
127
+ try {
128
+ event = JSON.parse(data.toString("utf8", offset, end));
129
+ }
130
+ catch (error) {
131
+ if (!last || end !== data.length - 1)
132
+ throw error;
133
+ break;
134
+ }
135
+ if (event === null || event.seq !== this.lastSeq + 1 || typeof event.at !== "number" || typeof event.kind !== "string") {
136
+ throw new Error(`Invalid event at ${name}:${offset}`);
137
+ }
138
+ this.index(event, segment, offset, end + 1 - offset);
139
+ offset = end + 1;
140
+ }
141
+ if (offset !== data.length) {
142
+ ftruncateSync(segment.fd, offset);
143
+ segment.bytes = offset;
144
+ }
145
+ }
146
+ index(event, segment, offset, length) {
147
+ const entry = { seq: event.seq, segment, offset, length, kind: event.kind };
148
+ if (event.kind === "assistant" || event.kind === "delta") {
149
+ entry.msg = event.msg;
150
+ if (event.kind === "assistant")
151
+ this.finalized.add(event.msg);
152
+ }
153
+ this.entries.push(entry);
154
+ }
155
+ append(body) {
156
+ this.assertOpen();
157
+ const event = { ...structuredClone(body), seq: this.lastSeq + 1, at: this.now() };
158
+ const blobs = [];
159
+ function externalize(text) {
160
+ if (typeof text !== "string" || text.length <= INLINE_CAP)
161
+ return text;
162
+ const id = randomUUID();
163
+ blobs.push({ id, text });
164
+ const ref = { blob: id, bytes: Buffer.byteLength(text), preview: prefix(text, 400) };
165
+ return ref;
166
+ }
167
+ if (event.kind === "assistant") {
168
+ event.text = externalize(event.text);
169
+ for (const tool of event.tools) {
170
+ if (["Edit", "MultiEdit", "Write"].includes(tool.name) && !tool.changes)
171
+ tool.changes = changeCount(tool.name, toolInput(tool.input));
172
+ if (tool.input !== undefined)
173
+ tool.input = externalize(tool.input);
174
+ }
175
+ }
176
+ else if (event.kind === "tool_result" || event.kind === "thinking")
177
+ event.text = externalize(event.text);
178
+ else if (event.kind === "permission")
179
+ event.input = externalize(event.input);
180
+ const line = fitLine(event);
181
+ for (const { id, text } of blobs)
182
+ writeFileAtomic(join(this.dir, "blobs", `${id}.txt`), text);
183
+ let segment = this.segments.at(-1);
184
+ const length = Buffer.byteLength(line);
185
+ if (segment.bytes > 0 && segment.bytes + length > this.segmentBytes)
186
+ segment = this.newSegment();
187
+ const offset = segment.bytes;
188
+ try {
189
+ writeFileSync(segment.fd, line, "utf8");
190
+ }
191
+ catch (error) {
192
+ // Do not allow a partial failed append to poison the next event in this process.
193
+ ftruncateSync(segment.fd, offset);
194
+ throw error;
195
+ }
196
+ segment.bytes += length;
197
+ this.index(event, segment, offset, length);
198
+ return event;
199
+ }
200
+ assertOpen() { if (this.closed)
201
+ throw new Error("Event log is closed"); }
202
+ visible(entry) {
203
+ return entry.kind !== "delta" || entry.msg === undefined || !this.finalized.has(entry.msg);
204
+ }
205
+ read(entry) {
206
+ const buffer = Buffer.allocUnsafe(entry.length);
207
+ let bytes = 0;
208
+ while (bytes < buffer.length) {
209
+ const n = readSync(entry.segment.fd, buffer, bytes, buffer.length - bytes, entry.offset + bytes);
210
+ if (n === 0)
211
+ throw new Error("Event log was truncated while open");
212
+ bytes += n;
213
+ }
214
+ return JSON.parse(buffer.toString("utf8"));
215
+ }
216
+ page(start, direction, maxBytes) {
217
+ this.assertOpen();
218
+ if (Number.isNaN(maxBytes) || maxBytes < 0)
219
+ throw new RangeError("maxBytes must be nonnegative");
220
+ const events = [];
221
+ let bytes = 0;
222
+ let remaining = false;
223
+ for (let i = start; i >= 0 && i < this.entries.length; i += direction) {
224
+ const entry = this.entries[i];
225
+ if (!this.visible(entry))
226
+ continue;
227
+ if (events.length && bytes + entry.length > maxBytes) {
228
+ remaining = true;
229
+ break;
230
+ }
231
+ events.push(this.read(entry));
232
+ bytes += entry.length;
233
+ }
234
+ if (direction === -1)
235
+ events.reverse();
236
+ return { events, remaining };
237
+ }
238
+ readAfter(afterSeq, maxBytes = PAGE_CAP) {
239
+ const { events, remaining } = this.page(Math.max(0, Math.floor(afterSeq)), 1, maxBytes);
240
+ return { events, more: remaining };
241
+ }
242
+ tail(maxBytes = PAGE_CAP) {
243
+ const { events, remaining } = this.page(this.entries.length - 1, -1, maxBytes);
244
+ return { events, hasOlder: remaining };
245
+ }
246
+ before(beforeSeq, maxBytes = PAGE_CAP) {
247
+ const start = Math.min(this.entries.length - 1, Math.ceil(beforeSeq) - 2);
248
+ const { events, remaining } = this.page(start, -1, maxBytes);
249
+ return { events, hasOlder: remaining };
250
+ }
251
+ /** The last nonempty assistant text of this turn, not a previous turn or a tool result.
252
+ * Walk the in-memory index so even a long chain of tool events needs only relevant disk reads. */
253
+ lastAssistantText(beforeSeq = this.lastSeq + 1) {
254
+ this.assertOpen();
255
+ for (let i = Math.min(this.entries.length, beforeSeq - 1) - 1; i >= 0; i--) {
256
+ const entry = this.entries[i];
257
+ if (entry.kind === "turn" || entry.kind === "user" || entry.kind === "result")
258
+ return "";
259
+ if (entry.kind !== "assistant")
260
+ continue;
261
+ const event = this.read(entry);
262
+ if (event.kind !== "assistant")
263
+ continue;
264
+ const text = typeof event.text === "string" ? event.text : event.text.preview;
265
+ if (text.trim())
266
+ return text;
267
+ }
268
+ return "";
269
+ }
270
+ /** Content-addressed, so repeated SDK snapshots do not duplicate large image bytes. */
271
+ image(base64) {
272
+ this.assertOpen();
273
+ const hash = createHash("sha256").update(base64).digest("hex");
274
+ const id = `${hash.slice(0, 8)}-${hash.slice(8, 12)}-${hash.slice(12, 16)}-${hash.slice(16, 20)}-${hash.slice(20, 32)}`;
275
+ writeFileAtomic(join(this.dir, "blobs", `${id}.txt`), base64);
276
+ return id;
277
+ }
278
+ blob(id) {
279
+ this.assertOpen();
280
+ if (!UUID.test(id))
281
+ return null;
282
+ try {
283
+ return readFileSync(join(this.dir, "blobs", `${id}.txt`), "utf8");
284
+ }
285
+ catch (error) {
286
+ if (error.code === "ENOENT")
287
+ return null;
288
+ throw error;
289
+ }
290
+ }
291
+ close() {
292
+ if (this.closed)
293
+ return;
294
+ this.closed = true;
295
+ for (const segment of this.segments)
296
+ closeSync(segment.fd);
297
+ }
298
+ }
@@ -0,0 +1,127 @@
1
+ // Only generic fallback copy and encrypted content cross the native push provider boundary.
2
+ import { createCipheriv, randomBytes } from "node:crypto";
3
+ import path from "node:path";
4
+ import { pushContent } from "./push-content.js";
5
+ import { readJson, writeFileAtomic } from "./fsutil.js";
6
+ export function validPushKey(key) {
7
+ return typeof key === "string" && /^[A-Za-z0-9_-]{43}$/.test(key) && Buffer.from(key, "base64url").toString("base64url") === key;
8
+ }
9
+ /** RFC 8439 / CryptoKit combined format: nonce(12) ‖ ciphertext ‖ tag(16), no AAD. */
10
+ export function encryptPush(key, content) {
11
+ if (!validPushKey(key))
12
+ throw new Error("invalid push key");
13
+ const nonce = randomBytes(12);
14
+ const cipher = createCipheriv("chacha20-poly1305", Buffer.from(key, "base64url"), nonce, { authTagLength: 16 });
15
+ const ciphertext = Buffer.concat([cipher.update(JSON.stringify(content), "utf8"), cipher.final()]);
16
+ return Buffer.concat([nonce, ciphertext, cipher.getAuthTag()]).toString("base64url");
17
+ }
18
+ export const validExpoToken = (token) => /^(ExponentPushToken|ExpoPushToken)\[[A-Za-z0-9_-]+\]$/.test(token);
19
+ export function expoBody(event, lang) {
20
+ if (event.kind === "permission")
21
+ return lang === "ko" ? "승인을 기다립니다" : "Waiting for approval";
22
+ if (event.kind !== "result")
23
+ return null;
24
+ if (event.ok)
25
+ return lang === "ko" ? "작업을 마쳤습니다" : "Work finished";
26
+ return lang === "ko" ? "작업이 실패했습니다" : "Work failed";
27
+ }
28
+ /** Inject only external boundaries: HTTP and the wait for Expo's asynchronous receipts. */
29
+ export function expoSender(fetcher = fetch, wait = () => new Promise(resolve => { setTimeout(resolve, 15_000).unref(); })) {
30
+ return async (payload) => {
31
+ const response = await fetcher("https://exp.host/--/api/v2/push/send", {
32
+ method: "POST", headers: { "Content-Type": "application/json" },
33
+ body: JSON.stringify(payload), signal: AbortSignal.timeout(15_000),
34
+ });
35
+ if (!response.ok)
36
+ throw new Error(`Expo push HTTP ${response.status}`);
37
+ const json = await response.json();
38
+ const ticket = json.data;
39
+ if (ticket?.details?.error === "DeviceNotRegistered")
40
+ return "gone";
41
+ if (ticket?.status !== "ok" || !ticket.id)
42
+ throw new Error(ticket?.message ?? "invalid Expo push ticket");
43
+ // Receipt errors can arrive after an accepted ticket. Poll for up to 15 minutes without
44
+ // blocking the session's append (main calls onEvent asynchronously).
45
+ for (let attempt = 0; attempt < 60; attempt++) {
46
+ await wait();
47
+ const receiptResponse = await fetcher("https://exp.host/--/api/v2/push/getReceipts", {
48
+ method: "POST", headers: { "Content-Type": "application/json" },
49
+ body: JSON.stringify({ ids: [ticket.id] }), signal: AbortSignal.timeout(15_000),
50
+ });
51
+ if (!receiptResponse.ok)
52
+ throw new Error(`Expo receipts HTTP ${receiptResponse.status}`);
53
+ const receiptJson = await receiptResponse.json();
54
+ const receipt = receiptJson.data?.[ticket.id];
55
+ if (!receipt)
56
+ continue;
57
+ if (receipt.details?.error === "DeviceNotRegistered")
58
+ return "gone";
59
+ if (receipt.status !== "ok")
60
+ throw new Error(receipt.message ?? "Expo receipt failed");
61
+ return "sent";
62
+ }
63
+ throw new Error("Expo receipt timed out");
64
+ };
65
+ }
66
+ export class ExpoNotifier {
67
+ file;
68
+ tokens;
69
+ opts;
70
+ constructor(opts) {
71
+ this.opts = opts;
72
+ this.file = path.join(opts.dir, "expo-push.json");
73
+ this.tokens = readJson(this.file) ?? {};
74
+ }
75
+ devices() { return Object.keys(this.tokens); }
76
+ subscribe(clientId, token, lang, platform, key) {
77
+ if (!clientId || typeof token !== "string" || !validExpoToken(token))
78
+ throw new Error("invalid Expo push token");
79
+ if (key !== undefined && (!validPushKey(key) || (platform !== "ios" && platform !== "android")))
80
+ throw new Error("invalid push key or platform");
81
+ this.tokens[clientId] = { token, lang: lang === "en" ? "en" : "ko", ...(key && platform ? { key, platform } : {}) };
82
+ this.save();
83
+ }
84
+ unsubscribe(clientId) {
85
+ if (!(clientId in this.tokens))
86
+ return;
87
+ delete this.tokens[clientId];
88
+ this.save();
89
+ }
90
+ async onEvent(session, event) {
91
+ if (this.opts.seenRecently(session))
92
+ return;
93
+ // Resolve once per event, lazily: old devices must never cause private context reads.
94
+ let context;
95
+ await Promise.all(Object.entries(this.tokens).map(async ([id, stored]) => {
96
+ const body = expoBody(event, stored.lang);
97
+ if (!body)
98
+ return;
99
+ try {
100
+ let payload = { to: stored.token, title: "Ripple Remote", body, data: { session }, sound: "default", ttl: event.kind === "permission" ? 3600 : 86400 };
101
+ if (stored.key) {
102
+ try {
103
+ context ??= Promise.resolve(this.opts.context?.(session, event) ?? {});
104
+ const e = encryptPush(stored.key, pushContent(event, stored.lang, await context, body));
105
+ payload = stored.platform === "android"
106
+ ? { to: stored.token, data: { session, e, fallback: body }, priority: "high", ttl: payload.ttl }
107
+ : { ...payload, mutableContent: true, data: { session, e } };
108
+ }
109
+ catch {
110
+ // No private exception text in logs; a context/crypto failure must still deliver fallback.
111
+ this.opts.log(`expo push: content unavailable for device ${id.slice(0, 8)}; sending fallback`);
112
+ }
113
+ }
114
+ // An unsubscribe/replacement during context lookup must not send an obsolete key/token.
115
+ if (this.tokens[id] !== stored)
116
+ return;
117
+ const result = await this.opts.send(payload);
118
+ if (result === "gone" && this.tokens[id] === stored)
119
+ this.unsubscribe(id);
120
+ }
121
+ catch (error) {
122
+ this.opts.log(`expo push: send failed for device ${id.slice(0, 8)}: ${error instanceof Error ? error.message : String(error)}`);
123
+ }
124
+ }));
125
+ }
126
+ save() { writeFileAtomic(this.file, JSON.stringify(this.tokens, null, 1)); }
127
+ }