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.
- package/CHANGELOG.md +80 -0
- package/README.md +31 -0
- package/dist/app/package.json +1 -1
- package/dist/cli/src/hooks/agent-title.js +7 -4
- package/dist/cli/src/index.js +43 -0
- package/dist/cli/src/remote.js +263 -0
- package/dist/cli/src/schtasks.js +48 -40
- package/dist/cli/src/settings.js +2 -1
- package/dist/mod/hooks/i18n.ts +252 -6
- package/dist/mod/hooks/judge.ts +35 -17
- package/dist/mod/hooks/modes.ts +56 -1
- package/dist/mod/hooks/register.tsx +238 -32
- package/dist/mod/hooks/session.ts +98 -1
- package/dist/mod/hooks/settings.js +7 -3
- package/dist/mod/hooks/settings.ts +9 -4
- package/dist/mod/hooks/stats.ts +25 -1
- package/dist/mod/hooks/ui/agents.tsx +858 -79
- package/dist/mod/hooks/ui/alerts.tsx +20 -17
- package/dist/mod/hooks/ui/art.ts +33 -7
- package/dist/mod/hooks/ui/band.tsx +262 -81
- package/dist/mod/hooks/ui/flat.ts +265 -0
- package/dist/mod/hooks/ui/kit.tsx +70 -55
- package/dist/mod/hooks/ui/look.ts +129 -0
- package/dist/mod/hooks/ui/settings.tsx +71 -46
- package/dist/mod/hooks/ui/setup.tsx +15 -13
- package/dist/mod/hooks/ui/theme.ts +3 -0
- package/dist/mod/hooks/ui/tools.ts +124 -0
- package/dist/mod/hooks/ui/view.ts +116 -3
- package/dist/mod/hooks/workers.ts +33 -6
- package/dist/mod/tests/register.test.tsx +155 -11
- package/dist/mod/tests/ui.test.tsx +375 -27
- package/dist/mod/types/index.d.ts +68 -2
- package/dist/remote/package.json +28 -0
- package/dist/remote/src/away.js +85 -0
- package/dist/remote/src/bridge-client.js +295 -0
- package/dist/remote/src/composer-menu.js +11 -0
- package/dist/remote/src/config.js +102 -0
- package/dist/remote/src/desktop.js +1018 -0
- package/dist/remote/src/e2ee.js +126 -0
- package/dist/remote/src/e2ee.ts +126 -0
- package/dist/remote/src/eventlog.js +298 -0
- package/dist/remote/src/expo-push.js +127 -0
- package/dist/remote/src/folders.js +79 -0
- package/dist/remote/src/fsutil.js +39 -0
- package/dist/remote/src/handoff.js +38 -0
- package/dist/remote/src/host-service.js +168 -0
- package/dist/remote/src/images.js +215 -0
- package/dist/remote/src/local-control.js +138 -0
- package/dist/remote/src/main.js +264 -0
- package/dist/remote/src/permissions.js +124 -0
- package/dist/remote/src/power.js +76 -0
- package/dist/remote/src/processes.js +24 -0
- package/dist/remote/src/protocol.js +32 -0
- package/dist/remote/src/protocol.ts +284 -0
- package/dist/remote/src/push-content.js +32 -0
- package/dist/remote/src/push.js +134 -0
- package/dist/remote/src/receipts.js +111 -0
- package/dist/remote/src/relay.js +438 -0
- package/dist/remote/src/router.js +208 -0
- package/dist/remote/src/sdk-launcher.js +18 -0
- package/dist/remote/src/server.js +706 -0
- package/dist/remote/src/service.js +157 -0
- package/dist/remote/src/sessions.js +1800 -0
- package/dist/remote/src/sidebar-storage.js +211 -0
- package/dist/remote/src/sidebar.js +173 -0
- package/dist/remote/src/tool-details.js +205 -0
- package/dist/remote/src/tool-details.ts +153 -0
- package/dist/remote/src/worktree.js +99 -0
- package/dist/remote/web/app.ts +746 -0
- package/dist/remote/web/conn.ts +193 -0
- package/dist/remote/web/demo.ts +134 -0
- package/dist/remote/web/dom.ts +124 -0
- package/dist/remote/web/esc.ts +4 -0
- package/dist/remote/web/i18n.ts +374 -0
- package/dist/remote/web/icon.svg +1 -0
- package/dist/remote/web/index.html +24 -0
- package/dist/remote/web/manifest.webmanifest +10 -0
- package/dist/remote/web/mascot.ts +60 -0
- package/dist/remote/web/md.ts +78 -0
- package/dist/remote/web/notify.ts +55 -0
- package/dist/remote/web/pushstate.ts +27 -0
- package/dist/remote/web/secure.ts +109 -0
- package/dist/remote/web/sidebar.ts +106 -0
- package/dist/remote/web/style.css +311 -0
- package/dist/remote/web/sw.ts +48 -0
- package/dist/remote/web/timeline.ts +224 -0
- package/dist/remote/web/views.ts +323 -0
- package/dist/router/src/admin.js +227 -4
- package/dist/router/src/config.js +30 -0
- package/dist/router/src/index.js +39 -0
- package/dist/router/src/ingress/translate.js +6 -2
- package/dist/router/src/phone.js +82 -0
- package/dist/router/src/presets.js +3 -0
- package/dist/router/src/providers/anthropic-usage.js +33 -1
- package/dist/router/src/providers/chatgpt/index.js +41 -11
- package/dist/router/src/providers/chatgpt/translate.js +147 -7
- package/dist/router/src/providers/google/index.js +26 -0
- package/dist/router/src/providers/google/prompt-refusal.js +29 -0
- package/dist/router/src/providers/google/usage.js +257 -0
- package/dist/router/src/providers/grok/index.js +18 -0
- package/dist/router/src/providers/grok/usage.js +77 -0
- package/dist/router/src/providers/openai/index.js +29 -5
- package/dist/router/src/providers/openai/translate.js +260 -27
- package/dist/router/src/providers/opencode-usage.js +116 -0
- package/dist/router/src/proxy.js +339 -258
- package/dist/router/src/reasoning.js +157 -0
- package/dist/router/src/remote-host.js +320 -0
- package/dist/router/src/remote-tunnel.js +320 -0
- package/dist/router/src/requestlog.js +48 -39
- package/dist/router/src/thinking.js +20 -9
- package/dist/router/src/usage-history.js +227 -0
- package/dist/router/src/version.js +1 -1
- package/dist/ui/app.js +436 -59
- package/dist/ui/i18n.js +184 -0
- package/dist/ui/index.html +24 -5
- package/dist/ui/style.css +39 -0
- package/docs/ARCHITECTURE.md +164 -12
- package/package.json +7 -1
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
// Wire protocol between ripple-host and the phone client. Types and constants only, no
|
|
2
|
+
// imports: the host also serves this file to the browser (as /src/protocol.ts), so both ends
|
|
3
|
+
// read the same numbers.
|
|
4
|
+
//
|
|
5
|
+
// Every session has an append-only event log. An event's `seq` counts from 1 inside one
|
|
6
|
+
// `epoch`; a new epoch means the log was replaced (never happens in normal operation) and a
|
|
7
|
+
// client must drop what it holds for that session. A client that reconnects asks for
|
|
8
|
+
// everything after the last `seq` it has, so a re-attach is cheap and idempotent.
|
|
9
|
+
|
|
10
|
+
/** Largest WebSocket frame either side sends, in bytes of UTF-8 JSON (Paseo #2610: 8 MB killed the socket). */
|
|
11
|
+
export const FRAME_CAP = 256 * 1024;
|
|
12
|
+
/** A string field longer than this is moved out of the event into a blob fetched on demand. */
|
|
13
|
+
export const INLINE_CAP = 32 * 1024;
|
|
14
|
+
/** Client pings this often; either side calls the link stale after STALE_MS of silence. */
|
|
15
|
+
export const PING_MS = 10_000;
|
|
16
|
+
export const STALE_MS = 30_000;
|
|
17
|
+
/** Reconnect backoff is min(BACKOFF_CAP_MS, attempt × 1 s) and never stops. */
|
|
18
|
+
export const BACKOFF_CAP_MS = 30_000;
|
|
19
|
+
/** A socket that opens but is not ready (relay hello, host ready) within this is abandoned. */
|
|
20
|
+
export const READY_MS = 8_000;
|
|
21
|
+
/** Largest frame the relay forwards: an encrypted FRAME_CAP frame (16-byte tag) with room to spare. */
|
|
22
|
+
export const RELAY_FRAME_CAP = FRAME_CAP + 1024;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The relay's own words (plain text frames; everything else it forwards untouched, encrypted):
|
|
26
|
+
* to the host's control socket: {"relay":"hello"} once accepted, {"relay":"connect","conn":id} per phone;
|
|
27
|
+
* to a phone: {"relay":"open"} once the host's data socket for it is there.
|
|
28
|
+
* The host pings its control socket with the text "ping" and the relay answers "pong".
|
|
29
|
+
*/
|
|
30
|
+
export type RelayMsg = { relay: "hello" } | { relay: "connect"; conn: string } | { relay: "open" };
|
|
31
|
+
/** Close codes the relay uses. `stale`: the host's control socket sent no ping for HOST_SILENT_MS. */
|
|
32
|
+
export const RELAY_CLOSE = { badRequest: 4400, badSecret: 4401, hostOffline: 4404, stale: 4408, replaced: 4409, peerGone: 4410, tooBig: 1009 } as const;
|
|
33
|
+
/**
|
|
34
|
+
* The relay routes phones only to the host's newest control socket, and only while that socket
|
|
35
|
+
* pinged within this long (PING_MS cadence; a missed ping or two is not a verdict).
|
|
36
|
+
*/
|
|
37
|
+
export const HOST_SILENT_MS = STALE_MS + PING_MS;
|
|
38
|
+
|
|
39
|
+
export const PERMISSION_MODES = ["plan", "default", "acceptEdits", "auto", "bypassPermissions"] as const;
|
|
40
|
+
export type PermissionMode = typeof PERMISSION_MODES[number];
|
|
41
|
+
export interface Question { question: string; header: string; options: { label: string; description: string }[]; multiSelect: boolean }
|
|
42
|
+
export interface PermissionContent { requestKind?: "tool" | "plan" | "question"; questions?: Question[] }
|
|
43
|
+
|
|
44
|
+
export type Effort = "none" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max" | "ultra";
|
|
45
|
+
|
|
46
|
+
export type SessionState =
|
|
47
|
+
| "starting" // query spawned, no answer yet
|
|
48
|
+
| "running" // model or tool working
|
|
49
|
+
| "waiting" // a permission request is open
|
|
50
|
+
| "idle" // turn finished, ready for the next message
|
|
51
|
+
| "stopped" // no live query (host restarted, or closed); the next message resumes it
|
|
52
|
+
| "error"; // last turn ended in an error; the next message retries
|
|
53
|
+
|
|
54
|
+
/** How a new session branches the folder it starts in. */
|
|
55
|
+
export type Isolation = "local" | "worktree";
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Why a session wants the person's eye. The phone clears it when it opens that session, except
|
|
59
|
+
* `permission`, which clears only when the approval itself is answered (a phone can look and leave).
|
|
60
|
+
*/
|
|
61
|
+
export type AttentionReason = "permission" | "failed" | "finished";
|
|
62
|
+
export interface Attention { reason: AttentionReason; at: number }
|
|
63
|
+
|
|
64
|
+
/** A string moved out of an event (INLINE_CAP); the client fetches `/api/blob/<session>/<id>`. */
|
|
65
|
+
export interface BlobRef { blob: string; bytes: number; preview: string }
|
|
66
|
+
export type Text = string | BlobRef;
|
|
67
|
+
|
|
68
|
+
/** Image bytes are base64 in the existing chunked blob transport, never in an event. */
|
|
69
|
+
export interface ImagePart { type: "image"; blob?: string; mediaType: string; width?: number; height?: number }
|
|
70
|
+
export interface MessageImages { images?: ImagePart[] }
|
|
71
|
+
export const ATTACHMENT_CAP = 8 * 1024 * 1024;
|
|
72
|
+
export const ATTACHMENT_COUNT = 8;
|
|
73
|
+
export interface Attachment { blob: string; name: string; mediaType: string }
|
|
74
|
+
export type SendBehavior = "steer" | "interrupt";
|
|
75
|
+
export function defaultEffort(efforts: Effort[]): Effort | null { return efforts.includes("high") ? "high" : efforts[0] ?? null; }
|
|
76
|
+
|
|
77
|
+
export interface ToolCall { id: string; name: string; /** one line a person reads: a command, a path */ summary: string; input?: Text; /** Computed before a large input becomes a blob. Counts describe supplied edits, not unseen file context. */ changes?: { added: number; removed: number } }
|
|
78
|
+
|
|
79
|
+
export type EventBody = MessageImages & (
|
|
80
|
+
| { kind: "user"; text: string; clientMsgId: string }
|
|
81
|
+
| { kind: "turn"; model: string; effort: Effort | null }
|
|
82
|
+
/** Streamed text, coalesced by the host (~80 ms). Superseded by the `assistant` event with the same `msg`. */
|
|
83
|
+
| { kind: "delta"; msg: string; text: string }
|
|
84
|
+
/** One thinking block; deltas append, a final block replaces them (never expose signatures). */
|
|
85
|
+
| { kind: "thinking"; msg: string; block: number; text: Text; delta?: boolean }
|
|
86
|
+
| { kind: "assistant"; msg: string; model: string; text: Text; tools: ToolCall[] }
|
|
87
|
+
| { kind: "tool_result"; toolUseId: string; ok: boolean; text: Text }
|
|
88
|
+
/** A subagent ran; its own log is `sub` and loads only when asked (Paseo #5820). */
|
|
89
|
+
| { kind: "subagent"; toolUseId: string; sub: string; description: string; model: string | null; status: "running" | "done" | "failed"; steps: number }
|
|
90
|
+
| ({ kind: "permission"; id: string; tool: string; title: string; summary: string; input: Text; canAlways: boolean } & PermissionContent)
|
|
91
|
+
| { kind: "mode"; mode: PermissionMode }
|
|
92
|
+
| { kind: "permission_decided"; id: string; decision: "allow" | "allow_always" | "deny" | "expired"; reason?: string }
|
|
93
|
+
| { kind: "result"; ok: boolean; error?: string; ms: number; turns: number }
|
|
94
|
+
| { kind: "state"; state: SessionState; detail?: string }
|
|
95
|
+
| { kind: "figures"; figures: Figures }
|
|
96
|
+
| { kind: "notice"; code: NoticeCode; text?: string });
|
|
97
|
+
|
|
98
|
+
export type NoticeCode = "host_restarted" | "interrupted" | "api_retry" | "router_down" | "model_changed" | "resumed" | "desktop_open" | "desktop_resumed" | "desktop_bridge" | "desktop_blocked";
|
|
99
|
+
|
|
100
|
+
export type SessionEvent = EventBody & { seq: number; at: number };
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* A push-worthy moment on a Mac session the host is not tailing: a turn finished (ok/failed) or an
|
|
104
|
+
* approval is waiting. It goes to the pushes only, never into a session log, so it carries just
|
|
105
|
+
* what they read — a bridged session's own result/permission events carry the rest.
|
|
106
|
+
*/
|
|
107
|
+
export type AlertBody = { kind: "permission" } | { kind: "result"; ok: boolean };
|
|
108
|
+
/** What a push reads: a real log event, or an alert for a Mac session the host does not tail. */
|
|
109
|
+
export type PushEvent = EventBody | AlertBody;
|
|
110
|
+
|
|
111
|
+
/** The mod's figures, as the phone shows them under the header. Null means "not known", never zero. */
|
|
112
|
+
export interface Figures {
|
|
113
|
+
effort: Effort | null;
|
|
114
|
+
/** cache read / all input of this session's main-model requests, 0–100 */
|
|
115
|
+
cachePct: number | null;
|
|
116
|
+
/** last request's input / the model's window, 0–100 */
|
|
117
|
+
contextPct: number | null;
|
|
118
|
+
/** Claude subscription 5-hour window used, 0–100 */
|
|
119
|
+
fiveHourPct: number | null;
|
|
120
|
+
fiveHourResetAt: number | null;
|
|
121
|
+
/** model → requests, for every model other than the session's own that served this session */
|
|
122
|
+
workers: { model: string; provider: string; requests: number }[];
|
|
123
|
+
/** last request's input tokens and the window they are read against; absent on older hosts */
|
|
124
|
+
contextTokens?: number | null;
|
|
125
|
+
contextWindow?: number | null;
|
|
126
|
+
/** where the session's latest main-model request went; null when the router has none; absent on older hosts */
|
|
127
|
+
route?: { model: string; provider: string; ok: boolean; status: number | null } | null;
|
|
128
|
+
at: number;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** One provider window from the router's /api/limits, as the desktop band's limits row shows it. */
|
|
132
|
+
export interface RippleLimit {
|
|
133
|
+
/** router provider name, e.g. "anthropic", "chatgpt"; `title` is what the band heads it with */
|
|
134
|
+
provider: string;
|
|
135
|
+
title: string;
|
|
136
|
+
/** account label when a provider pools several; absent for one */
|
|
137
|
+
account?: string;
|
|
138
|
+
kind: "five_hour" | "seven_day" | "scoped" | "window";
|
|
139
|
+
/** the router's own label for scoped/other windows; null for five_hour/seven_day */
|
|
140
|
+
label: string | null;
|
|
141
|
+
usedPct: number;
|
|
142
|
+
windowMinutes: number | null;
|
|
143
|
+
/** epoch ms; null when the router gave none */
|
|
144
|
+
resetAt: number | null;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** What the ClaudeRipple mod is set to on this Mac, host-wide (it is not per session). */
|
|
148
|
+
export interface RippleStatus {
|
|
149
|
+
/** the active operating mode from mod settings; null when none is active */
|
|
150
|
+
mode: { id: string; name: string; since: number | null } | null;
|
|
151
|
+
/** automatic effort on, and the range it is held in */
|
|
152
|
+
effortAuto: boolean;
|
|
153
|
+
effortFloor: Effort | null;
|
|
154
|
+
effortCeiling: Effort | null;
|
|
155
|
+
/** every provider window with a reset still ahead, Claude first */
|
|
156
|
+
limits: RippleLimit[];
|
|
157
|
+
at: number;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export interface SessionSummary {
|
|
161
|
+
id: string;
|
|
162
|
+
origin?: "host" | "desktop";
|
|
163
|
+
readOnly?: boolean;
|
|
164
|
+
title: string;
|
|
165
|
+
/** Desktop sidebar metadata; absent on older hosts. */
|
|
166
|
+
unread?: boolean;
|
|
167
|
+
group?: string;
|
|
168
|
+
order?: number;
|
|
169
|
+
cwd: string;
|
|
170
|
+
model: string;
|
|
171
|
+
effort: Effort | null;
|
|
172
|
+
permissionMode?: PermissionMode;
|
|
173
|
+
state: SessionState;
|
|
174
|
+
/** Main-turn state is authoritative for desktop Remote Control sessions. */
|
|
175
|
+
desktopBridge?: boolean;
|
|
176
|
+
/** Running background workers/shells, separate from the main turn. */
|
|
177
|
+
tasks?: number;
|
|
178
|
+
/** Desktop sessions: when the running main turn began, from the person's message in the transcript.
|
|
179
|
+
* The phone's event window may not reach back that far. Null when unknown; absent on older hosts. */
|
|
180
|
+
turnStartedAt?: number | null;
|
|
181
|
+
/** What those background tasks are, oldest first; absent on older hosts. */
|
|
182
|
+
taskList?: BackgroundTask[];
|
|
183
|
+
/** The main turn is idle but background work (subagents, shells) still runs: the app's dark blink. */
|
|
184
|
+
background?: boolean;
|
|
185
|
+
/** open permission requests */
|
|
186
|
+
pending: number;
|
|
187
|
+
/** The host's archive flag: an archived session leaves the default list but is never deleted
|
|
188
|
+
* (transcripts and files stay). Absent on older hosts. */
|
|
189
|
+
archived?: boolean;
|
|
190
|
+
/** The session wants the person's eye; the phone's `seen` clears all but a permission request.
|
|
191
|
+
* Absent on older hosts; null means nothing is waiting. */
|
|
192
|
+
attention?: Attention | null;
|
|
193
|
+
createdAt: number;
|
|
194
|
+
updatedAt: number;
|
|
195
|
+
lastSeq: number;
|
|
196
|
+
epoch: string;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export interface BackgroundTask { id: string; kind: "agent" | "shell"; label: string; startedAt: number | null }
|
|
200
|
+
|
|
201
|
+
export interface ModelOption { model: string; name: string; provider: string; efforts: Effort[]; usable: boolean }
|
|
202
|
+
export interface ProviderGroup { provider: string; label: string; usable: boolean; models: ModelOption[] }
|
|
203
|
+
|
|
204
|
+
export interface FolderEntry { path: string; name: string; lastUsed?: number; model?: string }
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* `desktopProblem`: why sends, approvals and live state for Claude app (Mac) sessions cannot work
|
|
208
|
+
* right now — this Mac's Claude login, in the host's words — absent when they can.
|
|
209
|
+
*/
|
|
210
|
+
export interface HostInfo { name: string; version: string; routerOk: boolean; routerProblem?: string; desktopProblem?: string;
|
|
211
|
+
/** the mod's mode, effort range and provider limits; absent on older hosts or when the router is down */
|
|
212
|
+
ripple?: RippleStatus }
|
|
213
|
+
|
|
214
|
+
// ---- client → host --------------------------------------------------------------------
|
|
215
|
+
|
|
216
|
+
export type ClientMsg =
|
|
217
|
+
| { t: "hello"; token: string; clientId: string }
|
|
218
|
+
| { t: "ping"; n: number }
|
|
219
|
+
/** `afterSeq` 0 or absent with no epoch: send the tail page. */
|
|
220
|
+
| { t: "attach"; session: string; epoch?: string; afterSeq?: number }
|
|
221
|
+
| { t: "detach"; session: string }
|
|
222
|
+
| { t: "history"; session: string; beforeSeq: number; reqId: string }
|
|
223
|
+
| { t: "create"; clientMsgId: string; cwd: string; model: string; effort: Effort | null; text: string; permissionMode?: PermissionMode; isolation?: Isolation; baseBranch?: string }
|
|
224
|
+
/** The phone is looking at this session now; clears its attention (a permission request stays). */
|
|
225
|
+
| { t: "seen"; session: string }
|
|
226
|
+
/** Archive/unarchive: a flag only, the session's transcripts and files stay. */
|
|
227
|
+
| { t: "archive"; session: string; archived: boolean }
|
|
228
|
+
/** "Mark unread": put the attention back, guessed from what the session is doing now. */
|
|
229
|
+
| { t: "unread"; session: string }
|
|
230
|
+
| { t: "set_mode"; reqId: string; sessionId: string; mode: PermissionMode }
|
|
231
|
+
| { t: "send"; session: string; clientMsgId: string; text: string; behavior?: SendBehavior; attachments?: Attachment[] }
|
|
232
|
+
| { t: "decide"; session: string; id: string; decision: "allow" | "allow_always" | "deny"; reqId?: string; message?: string; answers?: Record<string, string> }
|
|
233
|
+
| { t: "interrupt"; session: string; reqId?: string }
|
|
234
|
+
| { t: "set_model"; session: string; model: string; effort: Effort | null; reqId?: string }
|
|
235
|
+
/** The app is in front (visible) or not. Sent on change and every PING_MS while visible; the host pushes only when nobody is looking. */
|
|
236
|
+
| { t: "presence"; visible: boolean }
|
|
237
|
+
/** Request/response over the same socket, so a relay later forwards one stream and nothing else. */
|
|
238
|
+
| { t: "req"; reqId: string; op: ReqOp };
|
|
239
|
+
|
|
240
|
+
export type ReqOp =
|
|
241
|
+
| { op: "models" }
|
|
242
|
+
| { op: "upload"; session: string; uploadId: string; offset: number; data: string; done: boolean }
|
|
243
|
+
| { op: "folders" }
|
|
244
|
+
| { op: "browse"; path?: string; hidden?: boolean }
|
|
245
|
+
/** Make a folder directly inside `parent`; `name` is one path segment, validated on the host. */
|
|
246
|
+
| { op: "mkdir"; parent: string; name: string }
|
|
247
|
+
/** What a folder's git situation is, for the new-session isolation choice. */
|
|
248
|
+
| { op: "git"; path: string }
|
|
249
|
+
| { op: "blob"; session: string; id: string; offset?: number }
|
|
250
|
+
| { op: "sub"; session: string; sub: string; afterSeq?: number }
|
|
251
|
+
/** Slash command names (no "/") the session accepts, for the composer's "/" menu. */
|
|
252
|
+
| { op: "commands"; session: string }
|
|
253
|
+
/** The host's VAPID public key (base64url), for PushManager.subscribe's applicationServerKey. */
|
|
254
|
+
| { op: "push_key" }
|
|
255
|
+
| { op: "push_subscribe"; subscription: PushSubscriptionJSON; lang: "ko" | "en" }
|
|
256
|
+
| { op: "push_unsubscribe" }
|
|
257
|
+
/** Optional native push key travels inside the authenticated E2EE channel, never via Expo. */
|
|
258
|
+
| { op: "expo_push_subscribe"; token: string; lang: "ko" | "en"; platform?: "ios" | "android"; key?: string }
|
|
259
|
+
| { op: "expo_push_unsubscribe" };
|
|
260
|
+
|
|
261
|
+
/** PushSubscription.toJSON() as the browser gives it. */
|
|
262
|
+
export interface PushSubscriptionJSON { endpoint: string; expirationTime?: number | null; keys: { p256dh: string; auth: string } }
|
|
263
|
+
|
|
264
|
+
export interface BrowseResult { path: string; parent: string | null; home: string; entries: FolderEntry[]; truncated: boolean }
|
|
265
|
+
/** A folder's git situation: whether it is inside a repo, that repo's root, its branch and local branches. */
|
|
266
|
+
export interface GitInfo { isRepo: boolean; root: string | null; branch: string | null; branches: string[] }
|
|
267
|
+
export interface BlobChunk { text: string; offset: number; next: number | null; bytes: number }
|
|
268
|
+
export interface SubPage { events: SessionEvent[]; more: boolean }
|
|
269
|
+
|
|
270
|
+
// ---- host → client --------------------------------------------------------------------
|
|
271
|
+
|
|
272
|
+
export type HostMsg =
|
|
273
|
+
| { t: "ready"; host: HostInfo; sessions: SessionSummary[]; groups?: string[] | undefined }
|
|
274
|
+
| { t: "pong"; n: number }
|
|
275
|
+
| { t: "denied"; reason: "token" }
|
|
276
|
+
/** Events after the client's seq. `more` means another frame follows at once. `tail` means older events exist before `events[0]`. */
|
|
277
|
+
| { t: "events"; session: string; epoch: string; events: SessionEvent[]; more: boolean; tail?: { hasOlder: boolean } }
|
|
278
|
+
| { t: "history"; session: string; reqId: string; events: SessionEvent[]; hasOlder: boolean }
|
|
279
|
+
| { t: "sessions"; sessions: SessionSummary[]; groups?: string[] | undefined }
|
|
280
|
+
| { t: "host"; host: HostInfo }
|
|
281
|
+
| { t: "ack"; clientMsgId: string; session: string }
|
|
282
|
+
| { t: "res"; reqId: string; ok: true; data: unknown }
|
|
283
|
+
| { t: "res"; reqId: string; ok: false; error: string }
|
|
284
|
+
| { t: "error"; code: string; message: string; clientMsgId?: string; session?: string };
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
/** Plain notification copy: preserve link labels and code text, not Markdown layout. */
|
|
3
|
+
export function pushLine(text, max) {
|
|
4
|
+
const plain = text.replace(/!\[([^\]]*)\]\([^)]*\)/g, "$1")
|
|
5
|
+
.replace(/\[([^\]]+)\]\([^)]*\)/g, "$1")
|
|
6
|
+
.replace(/```[^\n]*\n/g, " ").replace(/```/g, " ")
|
|
7
|
+
.replace(/<[^>]*>/g, " ").replace(/^\s{0,3}(?:#{1,6}\s+|>\s*|[-*+]\s+|\d+[.)]\s+)/gm, "")
|
|
8
|
+
.replace(/[*_`~]/g, "").replace(/[\s\u0000-\u001f\u007f]+/g, " ").trim();
|
|
9
|
+
const chars = Array.from(plain);
|
|
10
|
+
return chars.length > max ? chars.slice(0, max - 1).join("") + "…" : plain;
|
|
11
|
+
}
|
|
12
|
+
export function pushContent(event, lang, context, fallback) {
|
|
13
|
+
const title = pushLine(context.title?.trim() || path.basename(context.cwd ?? "") || "Ripple Remote", 60);
|
|
14
|
+
let body = fallback;
|
|
15
|
+
if (event.kind === "permission") {
|
|
16
|
+
const tool = "tool" in event ? event.tool : context.tool;
|
|
17
|
+
const summary = "summary" in event ? event.summary : context.summary;
|
|
18
|
+
const detail = [tool, summary].filter(Boolean).join(": ");
|
|
19
|
+
if (detail)
|
|
20
|
+
body = `${lang === "ko" ? "승인 필요" : "Needs approval"} · ${detail}`;
|
|
21
|
+
}
|
|
22
|
+
else if (event.kind === "result") {
|
|
23
|
+
if (event.ok)
|
|
24
|
+
body = pushLine(context.text ?? "", 180) || fallback;
|
|
25
|
+
else {
|
|
26
|
+
const reason = "error" in event ? event.error : context.error;
|
|
27
|
+
if (reason)
|
|
28
|
+
body = `${lang === "ko" ? "실패" : "Failed"} · ${reason}`;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return { v: 1, title, body: pushLine(body, 180) };
|
|
32
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// Web push to the phone (RFC 8030 delivery, RFC 8291 payload encryption, RFC 8292 VAPID, via the
|
|
2
|
+
// `web-push` package). Three moments, and only when no connected phone looked at that session in
|
|
3
|
+
// the last SEEN_MS (180 s): a permission request is waiting, a turn finished, a turn failed.
|
|
4
|
+
// They arrive as an appended event, or as a Mac-session alert (Sessions.onAlert) for a session the
|
|
5
|
+
// host is not tailing. Tapping the push opens exactly that session (its id is in the payload's
|
|
6
|
+
// session field). The payload is metadata only — session title plus fixed state words, never
|
|
7
|
+
// conversation text.
|
|
8
|
+
//
|
|
9
|
+
// The VAPID key pair is made once and kept under the host's config dir (0600); a new pair would
|
|
10
|
+
// orphan every subscription. Subscriptions are per device (the phone's clientId) and a push
|
|
11
|
+
// service answering 404/410 means the subscription is gone for good, so it is dropped.
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
import webpush from "web-push";
|
|
14
|
+
import { readJson, writeFileAtomic } from "./fsutil.js";
|
|
15
|
+
export const VISIBLE_MS = 30_000;
|
|
16
|
+
/** A session a connected phone looked at within this needs no push (Paseo agent-attention-policy PRESENCE_THRESHOLD_MS). */
|
|
17
|
+
export const SEEN_MS = 180_000;
|
|
18
|
+
export function loadVapid(dir, subject) {
|
|
19
|
+
const file = path.join(dir, "vapid.json");
|
|
20
|
+
const stored = readJson(file);
|
|
21
|
+
if (stored && typeof stored.publicKey === "string" && typeof stored.privateKey === "string") {
|
|
22
|
+
// The subject may change (the https address became known); the keys never do.
|
|
23
|
+
return { publicKey: stored.publicKey, privateKey: stored.privateKey, subject };
|
|
24
|
+
}
|
|
25
|
+
const keys = webpush.generateVAPIDKeys();
|
|
26
|
+
writeFileAtomic(file, JSON.stringify({ publicKey: keys.publicKey, privateKey: keys.privateKey }, null, 2) + "\n");
|
|
27
|
+
return { ...keys, subject };
|
|
28
|
+
}
|
|
29
|
+
/** What a failed send means for the subscription: 404 and 410 say it no longer exists (RFC 8030 §7.3, §8). */
|
|
30
|
+
export const isGone = (e) => {
|
|
31
|
+
const code = e?.statusCode;
|
|
32
|
+
return code === 404 || code === 410;
|
|
33
|
+
};
|
|
34
|
+
export function webPushSender(vapid) {
|
|
35
|
+
return async (sub, payload, opts) => {
|
|
36
|
+
await webpush.sendNotification(sub, payload, {
|
|
37
|
+
vapidDetails: vapid,
|
|
38
|
+
contentEncoding: "aes128gcm",
|
|
39
|
+
urgency: opts.urgency,
|
|
40
|
+
TTL: opts.ttl,
|
|
41
|
+
topic: opts.topic,
|
|
42
|
+
timeout: 15_000,
|
|
43
|
+
});
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
const COPY = {
|
|
47
|
+
ko: {
|
|
48
|
+
permission: "승인을 기다립니다",
|
|
49
|
+
done: "작업을 마쳤습니다",
|
|
50
|
+
failed: "작업이 실패했습니다",
|
|
51
|
+
},
|
|
52
|
+
en: {
|
|
53
|
+
permission: "Waiting for approval",
|
|
54
|
+
done: "Work finished",
|
|
55
|
+
failed: "Work failed",
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
const clip = (s, n) => (s.length > n ? s.slice(0, n - 1) + "…" : s);
|
|
59
|
+
const folderOf = (cwd) => path.basename(cwd) || cwd;
|
|
60
|
+
/** The push for an event or Mac-session alert, or null if it is not one of the three moments.
|
|
61
|
+
* Metadata only: the session title plus fixed state words. Neither Expo/Apple nor the web push
|
|
62
|
+
* service ever receives assistant text, tool input, or error detail. */
|
|
63
|
+
export function pushFor(event, s, lang) {
|
|
64
|
+
const c = COPY[lang];
|
|
65
|
+
const title = clip(s.title || folderOf(s.cwd), 140);
|
|
66
|
+
if (event.kind === "permission") {
|
|
67
|
+
return { title, body: c.permission, session: s.id, tag: `perm-${s.id}` };
|
|
68
|
+
}
|
|
69
|
+
if (event.kind === "result" && event.ok) {
|
|
70
|
+
return { title, body: c.done, session: s.id, tag: `turn-${s.id}` };
|
|
71
|
+
}
|
|
72
|
+
if (event.kind === "result" && !event.ok) {
|
|
73
|
+
return { title, body: c.failed, session: s.id, tag: `turn-${s.id}` };
|
|
74
|
+
}
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
export class Notifier {
|
|
78
|
+
opts;
|
|
79
|
+
file;
|
|
80
|
+
subs;
|
|
81
|
+
constructor(opts) {
|
|
82
|
+
this.opts = opts;
|
|
83
|
+
this.file = path.join(opts.dir, "push.json");
|
|
84
|
+
this.subs = readJson(this.file) ?? {};
|
|
85
|
+
}
|
|
86
|
+
devices() { return Object.keys(this.subs); }
|
|
87
|
+
subscribe(clientId, sub, lang, now = Date.now()) {
|
|
88
|
+
if (!clientId || typeof sub?.endpoint !== "string" || !/^https:\/\//.test(sub.endpoint) || typeof sub.keys?.p256dh !== "string" || typeof sub.keys?.auth !== "string") {
|
|
89
|
+
throw new Error("invalid push subscription");
|
|
90
|
+
}
|
|
91
|
+
// One subscription per device; a device that re-subscribed replaces its old endpoint.
|
|
92
|
+
this.subs[clientId] = { sub: { endpoint: sub.endpoint, keys: { p256dh: sub.keys.p256dh, auth: sub.keys.auth } }, lang: lang === "en" ? "en" : "ko", at: now };
|
|
93
|
+
this.save();
|
|
94
|
+
}
|
|
95
|
+
unsubscribe(clientId) {
|
|
96
|
+
if (!(clientId in this.subs))
|
|
97
|
+
return;
|
|
98
|
+
delete this.subs[clientId];
|
|
99
|
+
this.save();
|
|
100
|
+
}
|
|
101
|
+
/** Called for every appended session event, and for each Mac-session alert (Sessions.onAlert). Resolves when every send finished (tests wait on it). */
|
|
102
|
+
async onEvent(session, event) {
|
|
103
|
+
if (event.kind !== "permission" && event.kind !== "result")
|
|
104
|
+
return;
|
|
105
|
+
if (!Object.keys(this.subs).length || this.opts.seenRecently(session))
|
|
106
|
+
return;
|
|
107
|
+
const s = this.opts.summary(session);
|
|
108
|
+
if (!s)
|
|
109
|
+
return;
|
|
110
|
+
await Promise.all(Object.entries(this.subs).map(async ([clientId, stored]) => {
|
|
111
|
+
const p = pushFor(event, s, stored.lang);
|
|
112
|
+
if (!p)
|
|
113
|
+
return;
|
|
114
|
+
try {
|
|
115
|
+
await this.opts.send(stored.sub, JSON.stringify(p), { urgency: event.kind === "permission" ? "high" : "normal", ttl: event.kind === "permission" ? 3600 : 86_400, topic: p.tag.replace(/[^\w-]/g, "").slice(0, 32) });
|
|
116
|
+
}
|
|
117
|
+
catch (e) {
|
|
118
|
+
if (isGone(e)) {
|
|
119
|
+
// Only drop it if it is still the subscription this send used, not one the phone just replaced.
|
|
120
|
+
if (this.subs[clientId]?.sub.endpoint === stored.sub.endpoint) {
|
|
121
|
+
delete this.subs[clientId];
|
|
122
|
+
this.save();
|
|
123
|
+
}
|
|
124
|
+
this.opts.log?.(`push: subscription for device ${clientId.slice(0, 8)} is gone (${e.statusCode}); removed`);
|
|
125
|
+
}
|
|
126
|
+
else
|
|
127
|
+
this.opts.log?.(`push: send failed for device ${clientId.slice(0, 8)}: ${e.message}`);
|
|
128
|
+
}
|
|
129
|
+
}));
|
|
130
|
+
}
|
|
131
|
+
save() {
|
|
132
|
+
writeFileAtomic(this.file, JSON.stringify(this.subs, null, 1));
|
|
133
|
+
}
|
|
134
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// Durable message receipts: (session, clientMsgId) → pending → completed, on disk under the
|
|
2
|
+
// host home (atomic write + fsync via writeFileAtomic, Paseo `server/message-receipts/index.ts`
|
|
3
|
+
// behavior: a resend of a completed id is acknowledged without running again; a resend that
|
|
4
|
+
// finds a pending receipt left from before a host restart answers `outcome_unknown` instead of
|
|
5
|
+
// running again — the phone shows "전송 결과를 확인할 수 없음 — 다시 보내기 / 무시" for it).
|
|
6
|
+
//
|
|
7
|
+
// The key covers what Paseo digests as ["send", agentId, messageId]: our key is
|
|
8
|
+
// `send:<session>:<clientMsgId>` for turns and `create:<clientMsgId>` for session creation
|
|
9
|
+
// (no session exists yet). In-flight keys of this process are tracked in memory so a concurrent
|
|
10
|
+
// resend rides the first attempt instead of reading its pending receipt as an orphan.
|
|
11
|
+
import { readJson, writeFileAtomic } from "./fsutil.js";
|
|
12
|
+
/** Thrown when a resend finds a pending receipt from before a host restart: the first attempt
|
|
13
|
+
* may or may not have run, so this attempt must not run again. The server answers the phone
|
|
14
|
+
* with error code `outcome_unknown` and this message. */
|
|
15
|
+
export class OutcomeUnknownError extends Error {
|
|
16
|
+
constructor() { super("전송 결과를 확인할 수 없음"); }
|
|
17
|
+
}
|
|
18
|
+
const RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
|
|
19
|
+
function validReceipt(value) {
|
|
20
|
+
const r = value;
|
|
21
|
+
return r !== null && typeof r === "object"
|
|
22
|
+
&& typeof r.key === "string" && typeof r.session === "string"
|
|
23
|
+
&& (r.state === "pending" || r.state === "completed")
|
|
24
|
+
&& Number.isFinite(r.at);
|
|
25
|
+
}
|
|
26
|
+
export class MessageReceipts {
|
|
27
|
+
file;
|
|
28
|
+
now;
|
|
29
|
+
records = new Map();
|
|
30
|
+
flying = new Set();
|
|
31
|
+
constructor(file, now) {
|
|
32
|
+
this.file = file;
|
|
33
|
+
this.now = now;
|
|
34
|
+
}
|
|
35
|
+
static open(file, now = Date.now) {
|
|
36
|
+
const saved = readJson(file) ?? [];
|
|
37
|
+
if (!Array.isArray(saved))
|
|
38
|
+
throw new Error("Invalid message receipts");
|
|
39
|
+
const receipts = new MessageReceipts(file, now);
|
|
40
|
+
for (const record of saved) {
|
|
41
|
+
if (!validReceipt(record) || receipts.records.has(record.key))
|
|
42
|
+
throw new Error("Invalid message receipt");
|
|
43
|
+
receipts.records.set(record.key, record);
|
|
44
|
+
}
|
|
45
|
+
// A pending receipt on disk with no live attempt in this process is an orphan of a restart.
|
|
46
|
+
// Completed entries older than retention are dropped on the next write.
|
|
47
|
+
return receipts;
|
|
48
|
+
}
|
|
49
|
+
/** Disk state only: "completed", "pending", or null when this id was never seen. */
|
|
50
|
+
status(key) {
|
|
51
|
+
return this.records.get(key)?.state ?? null;
|
|
52
|
+
}
|
|
53
|
+
/** True while this process has an attempt for the key in flight (then a pending record is its own). */
|
|
54
|
+
inFlight(key) {
|
|
55
|
+
return this.flying.has(key);
|
|
56
|
+
}
|
|
57
|
+
/** The session a completed receipt acknowledged, for resends that must ack without re-running. */
|
|
58
|
+
sessionOf(key) {
|
|
59
|
+
const record = this.records.get(key);
|
|
60
|
+
return record?.state === "completed" ? record.session : null;
|
|
61
|
+
}
|
|
62
|
+
/** Durably mark the attempt started, before it reaches the SDK. Overwrites nothing completed. */
|
|
63
|
+
begin(key, session) {
|
|
64
|
+
const at = this.now();
|
|
65
|
+
const records = new Map(this.records);
|
|
66
|
+
const kept = new Map();
|
|
67
|
+
for (const [k, record] of records) {
|
|
68
|
+
// Completed entries older than retention go; so do pending ones left orphaned by a restart
|
|
69
|
+
// (the phone resends under a new id, so the orphan would otherwise sit here forever).
|
|
70
|
+
// A resend of the same orphaned id still answers outcome_unknown: it checks status first
|
|
71
|
+
// and never reaches here.
|
|
72
|
+
if (k !== key && record.at < at - RETENTION_MS)
|
|
73
|
+
continue;
|
|
74
|
+
kept.set(k, record);
|
|
75
|
+
}
|
|
76
|
+
kept.set(key, { key, session, state: "pending", at });
|
|
77
|
+
this.persist(kept);
|
|
78
|
+
this.flying.add(key);
|
|
79
|
+
}
|
|
80
|
+
/** The SDK accepted the input: a resend acks this without running again. */
|
|
81
|
+
complete(key, session) {
|
|
82
|
+
const record = this.records.get(key);
|
|
83
|
+
const at = this.now();
|
|
84
|
+
const records = new Map(this.records);
|
|
85
|
+
const kept = new Map();
|
|
86
|
+
for (const [k, r] of records) {
|
|
87
|
+
if (k !== key && r.state === "completed" && r.at < at - RETENTION_MS)
|
|
88
|
+
continue;
|
|
89
|
+
kept.set(k, r);
|
|
90
|
+
}
|
|
91
|
+
kept.set(key, { key, session: session ?? record?.session ?? "", state: "completed", at });
|
|
92
|
+
this.persist(kept);
|
|
93
|
+
this.flying.delete(key);
|
|
94
|
+
}
|
|
95
|
+
/** The attempt was rejected before the SDK saw it: a resend must run fresh, not stay unknown. */
|
|
96
|
+
abandon(key) {
|
|
97
|
+
if (!this.records.has(key)) {
|
|
98
|
+
this.flying.delete(key);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
const records = new Map(this.records);
|
|
102
|
+
records.delete(key);
|
|
103
|
+
this.persist(records);
|
|
104
|
+
this.flying.delete(key);
|
|
105
|
+
}
|
|
106
|
+
persist(records) {
|
|
107
|
+
writeFileAtomic(this.file, JSON.stringify([...records.values()]));
|
|
108
|
+
// Memory must not get ahead of durable state when a write fails.
|
|
109
|
+
this.records = records;
|
|
110
|
+
}
|
|
111
|
+
}
|