claude-inbox 0.2.0 → 0.3.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.
- checksums.yaml +4 -4
- data/README.md +160 -152
- data/exe/claude-inbox +23 -4
- data/lib/claude_inbox/agents_client.rb +57 -55
- data/lib/claude_inbox/app.rb +42 -26
- data/lib/claude_inbox/config.rb +24 -0
- data/lib/claude_inbox/dialog.rb +3 -1
- data/lib/claude_inbox/images.rb +37 -6
- data/lib/claude_inbox/job_state.rb +23 -8
- data/lib/claude_inbox/keymap.rb +1 -1
- data/lib/claude_inbox/new_session_form.rb +29 -39
- data/lib/claude_inbox/painter.rb +32 -0
- data/lib/claude_inbox/palette.rb +4 -7
- data/lib/claude_inbox/peek.rb +2 -2
- data/lib/claude_inbox/poller.rb +4 -3
- data/lib/claude_inbox/pull_requests.rb +5 -5
- data/lib/claude_inbox/records.rb +7 -2
- data/lib/claude_inbox/remote/http.rb +137 -0
- data/lib/claude_inbox/remote/icon.png +0 -0
- data/lib/claude_inbox/remote/icon.svg +8 -0
- data/lib/claude_inbox/remote/listener.rb +436 -0
- data/lib/claude_inbox/remote/page.html +477 -0
- data/lib/claude_inbox/remote/pairing.rb +111 -0
- data/lib/claude_inbox/remote/pairing_dialog.rb +93 -0
- data/lib/claude_inbox/remote/start.rb +202 -0
- data/lib/claude_inbox/remote.rb +10 -0
- data/lib/claude_inbox/renderer.rb +57 -57
- data/lib/claude_inbox/session.rb +7 -21
- data/lib/claude_inbox/session_request.rb +151 -0
- data/lib/claude_inbox/sessions.rb +0 -1
- data/lib/claude_inbox/settings.rb +19 -7
- data/lib/claude_inbox/store.rb +0 -2
- data/lib/claude_inbox/subprocess.rb +9 -1
- data/lib/claude_inbox/terminal.rb +9 -3
- data/lib/claude_inbox/text.rb +7 -1
- data/lib/claude_inbox/trust.rb +23 -0
- data/lib/claude_inbox/vt_screen.rb +1 -1
- data/lib/claude_inbox.rb +1 -1
- metadata +14 -1
|
@@ -0,0 +1,477 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
|
6
|
+
<meta name="color-scheme" content="light dark">
|
|
7
|
+
<meta name="apple-mobile-web-app-capable" content="yes">
|
|
8
|
+
<meta name="mobile-web-app-capable" content="yes">
|
|
9
|
+
<meta name="apple-mobile-web-app-title" content="Claude Inbox">
|
|
10
|
+
<link rel="manifest" href="/manifest.webmanifest">
|
|
11
|
+
<link rel="apple-touch-icon" href="/icon.png">
|
|
12
|
+
<meta name="theme-color" content="#f2f2f7" media="(prefers-color-scheme: light)">
|
|
13
|
+
<meta name="theme-color" content="#000000" media="(prefers-color-scheme: dark)">
|
|
14
|
+
<title>New session · claude-inbox</title>
|
|
15
|
+
<style>
|
|
16
|
+
:root {
|
|
17
|
+
color-scheme: light dark;
|
|
18
|
+
--bg: #f2f2f7; --card: #ffffff; --ink: #1c1c1e; --muted: #6e6e73; --line: #d1d1d6;
|
|
19
|
+
--accent: #0a7385; --accent-ink: #ffffff; --error: #c0281c; --badge-bg: #fff1c2; --badge-ink: #6b4a00;
|
|
20
|
+
}
|
|
21
|
+
@media (prefers-color-scheme: dark) {
|
|
22
|
+
:root {
|
|
23
|
+
--bg: #000000; --card: #1c1c1e; --ink: #f2f2f7; --muted: #98989f; --line: #3a3a3c;
|
|
24
|
+
--accent: #5ccbdc; --accent-ink: #00262c; --error: #ff7b6f; --badge-bg: #3d3000; --badge-ink: #ffd666;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
* { box-sizing: border-box; }
|
|
28
|
+
[hidden] { display: none !important; }
|
|
29
|
+
html { -webkit-text-size-adjust: 100%; }
|
|
30
|
+
body {
|
|
31
|
+
margin: 0; background: var(--bg); color: var(--ink);
|
|
32
|
+
font: 17px/1.4 -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
|
|
33
|
+
padding: max(16px, env(safe-area-inset-top)) max(16px, env(safe-area-inset-right))
|
|
34
|
+
max(32px, env(safe-area-inset-bottom)) max(16px, env(safe-area-inset-left));
|
|
35
|
+
}
|
|
36
|
+
main { max-width: 36rem; margin: 0 auto; }
|
|
37
|
+
header { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 10px; margin-bottom: 8px; }
|
|
38
|
+
h1 { font-size: 28px; margin: 0; }
|
|
39
|
+
.host { color: var(--muted); font-size: 15px; overflow-wrap: anywhere; }
|
|
40
|
+
.badge { background: var(--badge-bg); color: var(--badge-ink); font-size: 13px; font-weight: 600; padding: 2px 10px; border-radius: 999px; }
|
|
41
|
+
label { display: block; font-size: 15px; font-weight: 600; margin: 18px 0 6px; }
|
|
42
|
+
input, select, textarea, button { font: inherit; font-size: 17px; color: inherit; }
|
|
43
|
+
input[type=text], select, textarea {
|
|
44
|
+
display: block; width: 100%; min-height: 44px; padding: 10px 12px; margin: 0;
|
|
45
|
+
border: 1px solid var(--line); border-radius: 10px; background: var(--card);
|
|
46
|
+
}
|
|
47
|
+
textarea { min-height: 9.5em; resize: vertical; }
|
|
48
|
+
select {
|
|
49
|
+
-webkit-appearance: none; appearance: none; padding-right: 36px;
|
|
50
|
+
background: var(--card) url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8'%3E%3Cpath d='M1 1l5 5 5-5' fill='none' stroke='%238e8e93' stroke-width='2'/%3E%3C/svg%3E") no-repeat right 14px center;
|
|
51
|
+
}
|
|
52
|
+
input:focus, select:focus, textarea:focus { outline: 2px solid var(--accent); outline-offset: -1px; }
|
|
53
|
+
button, .button {
|
|
54
|
+
display: inline-flex; align-items: center; justify-content: center; min-height: 44px; padding: 0 16px;
|
|
55
|
+
border: 1px solid var(--line); border-radius: 10px; background: var(--card); color: var(--accent);
|
|
56
|
+
font-weight: 600; text-decoration: none; cursor: pointer;
|
|
57
|
+
}
|
|
58
|
+
button.primary { width: 100%; margin-top: 24px; border: 0; background: var(--accent); color: var(--accent-ink); }
|
|
59
|
+
button:disabled, .button.off { opacity: .45; pointer-events: none; }
|
|
60
|
+
.visually-hidden { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
|
|
61
|
+
.images { display: flex; gap: 8px; align-items: center; margin-top: 8px; overflow-x: auto; }
|
|
62
|
+
.images .button { flex: none; margin: 0; font-size: 17px; }
|
|
63
|
+
figure { position: relative; flex: none; width: 72px; height: 72px; margin: 0; border-radius: 8px; overflow: hidden; background: var(--line); }
|
|
64
|
+
figure img { display: block; width: 100%; height: 100%; object-fit: cover; }
|
|
65
|
+
figcaption { position: absolute; left: 4px; bottom: 4px; padding: 0 5px; border-radius: 4px; background: rgba(0, 0, 0, .6); color: #fff; font-size: 12px; }
|
|
66
|
+
figure button { position: absolute; top: 2px; right: 2px; width: 32px; min-height: 32px; height: 32px; padding: 0; border: 0; border-radius: 50%; background: rgba(0, 0, 0, .6); color: #fff; font-size: 20px; line-height: 1; }
|
|
67
|
+
.toggles { margin-top: 20px; border-top: 1px solid var(--line); }
|
|
68
|
+
.toggle { display: flex; justify-content: space-between; align-items: center; min-height: 48px; margin: 0; font-weight: 400; font-size: 17px; border-bottom: 1px solid var(--line); }
|
|
69
|
+
.toggle input { margin: 0; accent-color: var(--accent); }
|
|
70
|
+
.error { margin: 6px 0 0; color: var(--error); font-size: 15px; }
|
|
71
|
+
.error:empty { display: none; }
|
|
72
|
+
.card { margin-top: 16px; padding: 16px; border-radius: 12px; background: var(--card); }
|
|
73
|
+
.card p { margin: 0 0 12px; }
|
|
74
|
+
.muted { color: var(--muted); font-size: 15px; overflow-wrap: anywhere; }
|
|
75
|
+
.actions { display: flex; flex-wrap: wrap; gap: 8px; }
|
|
76
|
+
code { font: 15px ui-monospace, SFMono-Regular, Menlo, monospace; }
|
|
77
|
+
</style>
|
|
78
|
+
</head>
|
|
79
|
+
<body>
|
|
80
|
+
<main>
|
|
81
|
+
<header>
|
|
82
|
+
<h1>New session</h1>
|
|
83
|
+
<span class="host" id="host"></span>
|
|
84
|
+
<span class="badge" id="fixture" hidden>fixture</span>
|
|
85
|
+
</header>
|
|
86
|
+
|
|
87
|
+
<section id="note" class="card" aria-live="polite">
|
|
88
|
+
<p id="note-text">Asking the inbox…</p>
|
|
89
|
+
<button type="button" id="retry" hidden>Try again</button>
|
|
90
|
+
</section>
|
|
91
|
+
|
|
92
|
+
<section id="pair" class="card" hidden>
|
|
93
|
+
<p id="pair-why">Paste the pairing URL. In the inbox, <b>N</b> shows it and <b>c</b> copies it.</p>
|
|
94
|
+
<input type="text" id="token" inputmode="url" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false" placeholder="http://…:7433/#token" aria-label="Pairing URL or token">
|
|
95
|
+
<p class="error" id="error-token"></p>
|
|
96
|
+
<button type="button" class="primary" id="pair-save">Pair</button>
|
|
97
|
+
</section>
|
|
98
|
+
|
|
99
|
+
<section id="form" hidden>
|
|
100
|
+
<label for="prompt">Prompt</label>
|
|
101
|
+
<textarea id="prompt" placeholder="What should this session do?"></textarea>
|
|
102
|
+
<p class="error" id="error-prompt"></p>
|
|
103
|
+
<div class="images" id="images">
|
|
104
|
+
<label class="button" for="file" id="add-image">Add image</label>
|
|
105
|
+
</div>
|
|
106
|
+
<input type="file" id="file" class="visually-hidden" accept="image/*" multiple>
|
|
107
|
+
<p class="error" id="error-images"></p>
|
|
108
|
+
|
|
109
|
+
<label for="name">Name</label>
|
|
110
|
+
<input type="text" id="name" autocapitalize="off" autocorrect="off" placeholder="(none: claude picks one)">
|
|
111
|
+
<p class="error" id="error-name"></p>
|
|
112
|
+
|
|
113
|
+
<label for="cwd">Directory</label>
|
|
114
|
+
<select id="cwd"></select>
|
|
115
|
+
<input type="text" id="cwd-other" hidden autocapitalize="off" autocorrect="off" spellcheck="false" placeholder="/path/to/project or ~/code/project" aria-label="Another directory">
|
|
116
|
+
<p class="error" id="error-cwd"></p>
|
|
117
|
+
|
|
118
|
+
<label for="model">Model</label>
|
|
119
|
+
<select id="model"></select>
|
|
120
|
+
<p class="error" id="error-model"></p>
|
|
121
|
+
<label for="effort">Effort</label>
|
|
122
|
+
<select id="effort"></select>
|
|
123
|
+
<p class="error" id="error-effort"></p>
|
|
124
|
+
<label for="permission_mode">Permissions</label>
|
|
125
|
+
<select id="permission_mode"></select>
|
|
126
|
+
<p class="error" id="error-permission_mode"></p>
|
|
127
|
+
|
|
128
|
+
<div class="toggles">
|
|
129
|
+
<label class="toggle">Worktree <input type="checkbox" id="worktree" switch></label>
|
|
130
|
+
<label class="toggle">Remote Control <input type="checkbox" id="remote" switch checked></label>
|
|
131
|
+
</div>
|
|
132
|
+
|
|
133
|
+
<button type="button" class="primary" id="start">Start</button>
|
|
134
|
+
<p class="error" id="error-start" aria-live="polite"></p>
|
|
135
|
+
</section>
|
|
136
|
+
|
|
137
|
+
<section id="done" class="card" hidden>
|
|
138
|
+
<p>Started <code id="done-id"></code></p>
|
|
139
|
+
<p class="muted" id="done-where"></p>
|
|
140
|
+
<p class="muted" id="done-later">claude.ai/code lists it once it has registered, in a moment.</p>
|
|
141
|
+
<div class="actions">
|
|
142
|
+
<a class="button" id="done-url" target="_blank" rel="noopener">Open in Claude</a>
|
|
143
|
+
<button type="button" id="new">New</button>
|
|
144
|
+
</div>
|
|
145
|
+
</section>
|
|
146
|
+
</main>
|
|
147
|
+
|
|
148
|
+
<script>
|
|
149
|
+
"use strict";
|
|
150
|
+
// The listener's MAX_IMAGES and MAX_BODY. It refuses a body over the limit
|
|
151
|
+
// without reading it, which a phone still uploading can see as no answer.
|
|
152
|
+
const MAX_IMAGES = 8;
|
|
153
|
+
const MAX_BODY = 16777216;
|
|
154
|
+
const LONG_EDGE = 2000;
|
|
155
|
+
const FIELDS = ["prompt", "name", "cwd", "model", "effort", "permission_mode", "images"];
|
|
156
|
+
const NO_ANSWER = "no answer from the inbox. Is it open with --listen-lan, and is the Mac awake?";
|
|
157
|
+
const REJECTED = "token rejected: press N in the inbox and pair again";
|
|
158
|
+
const TOKEN = /^[A-Za-z0-9_-]{32,}$/;
|
|
159
|
+
|
|
160
|
+
const $ = (id) => document.getElementById(id);
|
|
161
|
+
const saved = {
|
|
162
|
+
get(key) { try { return JSON.parse(localStorage.getItem(key)); } catch { return null; } },
|
|
163
|
+
set(key, value) {
|
|
164
|
+
try { value == null ? localStorage.removeItem(key) : localStorage.setItem(key, JSON.stringify(value)); } catch {}
|
|
165
|
+
}
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
let token = saved.get("token");
|
|
169
|
+
let options = null;
|
|
170
|
+
let images = [];
|
|
171
|
+
let shrinking = Promise.resolve();
|
|
172
|
+
let starting = false;
|
|
173
|
+
let draft = saved.get("draft") || {};
|
|
174
|
+
|
|
175
|
+
function show(view) {
|
|
176
|
+
for (const id of ["note", "pair", "form", "done"]) $(id).hidden = id !== view;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function note(text, retry) {
|
|
180
|
+
$("note-text").textContent = text;
|
|
181
|
+
$("retry").hidden = !retry;
|
|
182
|
+
show("note");
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function showPair(why) {
|
|
186
|
+
$("pair-why").textContent = why || $("pair-why").textContent;
|
|
187
|
+
show("pair");
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async function api(method, path, body, headers) {
|
|
191
|
+
let res, json = null;
|
|
192
|
+
try {
|
|
193
|
+
res = await fetch(path, {method, body, cache: "no-store", headers: {...headers, Authorization: `Bearer ${token}`}});
|
|
194
|
+
json = await res.json();
|
|
195
|
+
} catch {
|
|
196
|
+
if (!res) return {status: 0, error: NO_ANSWER};
|
|
197
|
+
}
|
|
198
|
+
if (res.status === 401) return {status: 401, error: REJECTED};
|
|
199
|
+
// Status 0 whatever the HTTP status: a captive portal answers 200 with a page of its own.
|
|
200
|
+
if (!json || typeof json !== "object" || Array.isArray(json)) return {status: 0, error: `the inbox isn't answering (HTTP ${res.status})`};
|
|
201
|
+
return {status: res.status, json, error: json.error || `HTTP ${res.status}`, field: json.field};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async function load() {
|
|
205
|
+
if (!token) return showPair();
|
|
206
|
+
note("Asking the inbox…");
|
|
207
|
+
const r = await api("GET", "/api/options");
|
|
208
|
+
if (r.status === 401) return showPair(r.error);
|
|
209
|
+
if (r.status !== 200) return note(r.error, true);
|
|
210
|
+
options = r.json;
|
|
211
|
+
$("fixture").hidden = !options.fixture;
|
|
212
|
+
fill();
|
|
213
|
+
show("form");
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Filled again on a new pairing URL or after a 401, with a draft open:
|
|
217
|
+
// what is on screen then stays, where it is still offered.
|
|
218
|
+
function fill() {
|
|
219
|
+
const dirs = options.dirs || [];
|
|
220
|
+
const refill = $("cwd").options.length > 0;
|
|
221
|
+
const last = refill ? cwd() : saved.get("cwd");
|
|
222
|
+
const typed = (refill && $("cwd").value === "") || (Boolean(last) && !dirs.some((d) => d.path === last));
|
|
223
|
+
$("cwd").replaceChildren(...dirs.map((d) => new Option(d.label, d.path)), new Option("another directory…", ""));
|
|
224
|
+
$("cwd").value = typed || !dirs.length ? "" : last || dirs[0].path;
|
|
225
|
+
if (typed) $("cwd-other").value = last;
|
|
226
|
+
for (const [id, list] of [["model", options.models], ["effort", options.efforts], ["permission_mode", options.permission_modes]]) {
|
|
227
|
+
const picked = $(id).value;
|
|
228
|
+
$(id).replaceChildren(...(list || []).map((v) => new Option(v, v)));
|
|
229
|
+
if ((list || []).includes(picked)) $(id).value = picked;
|
|
230
|
+
}
|
|
231
|
+
relabel();
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// "default" means what the settings for that directory say, as the n form
|
|
235
|
+
// shows it. The listener caps the mode it resolves to, so in a directory
|
|
236
|
+
// that defaults to acceptEdits, "default" can't start anything from here.
|
|
237
|
+
// The page's own pick away from it is undone where "default" is allowed.
|
|
238
|
+
function relabel() {
|
|
239
|
+
const dir = (options.dirs || []).find((d) => d.path === $("cwd").value);
|
|
240
|
+
$("cwd-other").hidden = $("cwd").value !== "";
|
|
241
|
+
for (const id of ["model", "effort", "permission_mode"]) {
|
|
242
|
+
const option = [...$(id).options].find((o) => o.value === "default");
|
|
243
|
+
if (!option) continue;
|
|
244
|
+
const set = dir && dir.defaults && dir.defaults[id];
|
|
245
|
+
const refused = id === "permission_mode" && Boolean(set) && !(options.permission_modes || []).includes(set);
|
|
246
|
+
option.textContent = !set ? "default" : refused ? `default (${set}: not from a phone)` : `default (${set})`;
|
|
247
|
+
option.disabled = refused;
|
|
248
|
+
const select = $(id);
|
|
249
|
+
if (refused && select.value === "default") {
|
|
250
|
+
const allowed = [...select.options].find((o) => !o.disabled);
|
|
251
|
+
if (allowed) [select.value, select.dataset.forced] = [allowed.value, "1"];
|
|
252
|
+
} else if (!refused && select.dataset.forced) {
|
|
253
|
+
select.value = "default";
|
|
254
|
+
delete select.dataset.forced;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
// On, since you are away from the desk, unless /config or the project
|
|
258
|
+
// turns Remote Control off there. A flip of your own stays put.
|
|
259
|
+
if (!$("remote").dataset.flipped) $("remote").checked = !(dir && dir.defaults && dir.defaults.remote === "no");
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function cwd() {
|
|
263
|
+
return $("cwd").value || $("cwd-other").value.trim();
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function saveDraft() {
|
|
267
|
+
draft.prompt = $("prompt").value;
|
|
268
|
+
draft.name = $("name").value;
|
|
269
|
+
saved.set("draft", draft);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function fieldError(field, message) {
|
|
273
|
+
const el = $(`error-${FIELDS.includes(field) ? field : "start"}`);
|
|
274
|
+
el.textContent = message;
|
|
275
|
+
el.scrollIntoView({block: "center", behavior: "smooth"});
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function clearErrors() {
|
|
279
|
+
for (const el of document.querySelectorAll(".error")) el.textContent = "";
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// A photo is decoded at full size, so one at a time: a phone has room for a
|
|
283
|
+
// single 48-megapixel bitmap, not eight.
|
|
284
|
+
function addImages(files) {
|
|
285
|
+
$("error-images").textContent = "";
|
|
286
|
+
const room = MAX_IMAGES - images.length;
|
|
287
|
+
if (files.length > room) $("error-images").textContent = `at most ${MAX_IMAGES} images`;
|
|
288
|
+
for (const file of files.slice(0, room)) {
|
|
289
|
+
const image = {};
|
|
290
|
+
images.push(image);
|
|
291
|
+
insertToken(images.length);
|
|
292
|
+
shrinking = shrinking
|
|
293
|
+
.then(() => shrink(file))
|
|
294
|
+
.then((url) => { image.url = url; }, () => dropImage(image, `${file.name || "a photo"} couldn't be read`))
|
|
295
|
+
.then(drawImages);
|
|
296
|
+
}
|
|
297
|
+
drawImages();
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function shrink(file) {
|
|
301
|
+
return new Promise((resolve, reject) => {
|
|
302
|
+
const src = URL.createObjectURL(file);
|
|
303
|
+
const img = new Image();
|
|
304
|
+
img.onload = () => {
|
|
305
|
+
const scale = Math.min(1, LONG_EDGE / Math.max(img.naturalWidth, img.naturalHeight, 1));
|
|
306
|
+
const canvas = document.createElement("canvas");
|
|
307
|
+
canvas.width = Math.max(1, Math.round(img.naturalWidth * scale));
|
|
308
|
+
canvas.height = Math.max(1, Math.round(img.naturalHeight * scale));
|
|
309
|
+
const ctx = canvas.getContext("2d");
|
|
310
|
+
ctx.fillStyle = "#fff"; // JPEG has no alpha; a transparent PNG would turn black
|
|
311
|
+
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
|
312
|
+
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
|
|
313
|
+
const url = canvas.toDataURL("image/jpeg", 0.85);
|
|
314
|
+
URL.revokeObjectURL(src);
|
|
315
|
+
canvas.width = canvas.height = 0;
|
|
316
|
+
url.startsWith("data:image/jpeg;base64,") ? resolve(url) : reject(new Error("canvas"));
|
|
317
|
+
};
|
|
318
|
+
img.onerror = () => { URL.revokeObjectURL(src); reject(new Error("decode")); };
|
|
319
|
+
img.src = src;
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function insertToken(n) {
|
|
324
|
+
const p = $("prompt");
|
|
325
|
+
const at = p.selectionEnd ?? p.value.length;
|
|
326
|
+
const before = p.value.slice(0, at), after = p.value.slice(at);
|
|
327
|
+
const text = (/\S$/.test(before) ? " " : "") + `[Image #${n}]` + (/^\S/.test(after) ? " " : "");
|
|
328
|
+
p.value = before + text + after;
|
|
329
|
+
p.selectionStart = p.selectionEnd = at + text.length;
|
|
330
|
+
saveDraft();
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// The inbox maps [Image #n] to the nth image sent, so the ones after it move up.
|
|
334
|
+
function dropImage(image, why) {
|
|
335
|
+
const k = images.indexOf(image) + 1;
|
|
336
|
+
if (!k) return;
|
|
337
|
+
images.splice(k - 1, 1);
|
|
338
|
+
$("prompt").value = $("prompt").value.replace(/ ?\[Image #(\d+)\]/gi, (match, n) =>
|
|
339
|
+
Number(n) === k ? "" : Number(n) > k ? match.replace(n, n - 1) : match);
|
|
340
|
+
saveDraft();
|
|
341
|
+
if (why) $("error-images").textContent = why;
|
|
342
|
+
drawImages();
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function drawImages() {
|
|
346
|
+
const figures = images.map((image, i) => {
|
|
347
|
+
const figure = document.createElement("figure");
|
|
348
|
+
if (image.url) figure.append(Object.assign(new Image(), {src: image.url, alt: `Image #${i + 1}`}));
|
|
349
|
+
const caption = document.createElement("figcaption");
|
|
350
|
+
caption.textContent = image.url ? `#${i + 1}` : "…";
|
|
351
|
+
const remove = document.createElement("button");
|
|
352
|
+
remove.type = "button";
|
|
353
|
+
remove.textContent = "×";
|
|
354
|
+
remove.setAttribute("aria-label", `Remove image ${i + 1}`);
|
|
355
|
+
remove.disabled = starting;
|
|
356
|
+
remove.addEventListener("click", () => dropImage(image));
|
|
357
|
+
figure.append(caption, remove);
|
|
358
|
+
return figure;
|
|
359
|
+
});
|
|
360
|
+
$("images").replaceChildren($("add-image"), ...figures);
|
|
361
|
+
$("file").disabled = starting || images.length >= MAX_IMAGES;
|
|
362
|
+
$("add-image").classList.toggle("off", $("file").disabled);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// Not crypto.randomUUID: over plain http to an address it doesn't exist.
|
|
366
|
+
function newKey() {
|
|
367
|
+
return Array.from(crypto.getRandomValues(new Uint8Array(16)), (b) => b.toString(16).padStart(2, "0")).join("");
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
async function start() {
|
|
371
|
+
const button = $("start");
|
|
372
|
+
if (button.disabled) return;
|
|
373
|
+
clearErrors();
|
|
374
|
+
button.disabled = starting = true;
|
|
375
|
+
drawImages();
|
|
376
|
+
button.textContent = images.length ? "Preparing images…" : "Starting…";
|
|
377
|
+
try {
|
|
378
|
+
while (images.some((image) => !image.url)) await shrinking;
|
|
379
|
+
const remote = $("remote").checked;
|
|
380
|
+
const fields = {
|
|
381
|
+
prompt: $("prompt").value, name: $("name").value, cwd: cwd(),
|
|
382
|
+
model: $("model").value, effort: $("effort").value, permission_mode: $("permission_mode").value,
|
|
383
|
+
worktree: $("worktree").checked, remote: remote
|
|
384
|
+
};
|
|
385
|
+
const body = JSON.stringify({...fields, images: images.map((image) => ({data: image.url.slice(image.url.indexOf(",") + 1)}))});
|
|
386
|
+
const size = new Blob([body]).size;
|
|
387
|
+
if (size > MAX_BODY) {
|
|
388
|
+
return fieldError("images", `these come to ${(size / 1048576).toFixed(1)} MB and the inbox takes ${MAX_BODY / 1048576} MB: remove one`);
|
|
389
|
+
}
|
|
390
|
+
// Kept with the draft for this request alone, so a retry after a lost
|
|
391
|
+
// answer gets that answer, and an edited one is a start of its own.
|
|
392
|
+
// Compared as text: crypto.subtle, like randomUUID, needs https.
|
|
393
|
+
const sent = JSON.stringify({...fields, images: images.map((image) => `${image.url.length}:${image.url.slice(-24)}`)});
|
|
394
|
+
if (!draft.key || draft.sent !== sent) Object.assign(draft, {key: newKey(), sent});
|
|
395
|
+
saveDraft();
|
|
396
|
+
saved.set("cwd", cwd());
|
|
397
|
+
button.textContent = "Starting…";
|
|
398
|
+
const r = await api("POST", "/api/sessions", body, {"Content-Type": "application/json", "Idempotency-Key": draft.key});
|
|
399
|
+
if (r.status === 201 || r.status === 200) return started(r.json, remote);
|
|
400
|
+
// An answer means the listener has let the key go, unless it is still starting.
|
|
401
|
+
if (r.status !== 0 && r.status !== 409) Object.assign(draft, {key: null, sent: null});
|
|
402
|
+
saveDraft();
|
|
403
|
+
if (r.status === 401) return showPair(r.error);
|
|
404
|
+
fieldError(r.field, r.error);
|
|
405
|
+
} finally {
|
|
406
|
+
button.disabled = starting = false;
|
|
407
|
+
button.textContent = "Start";
|
|
408
|
+
drawImages();
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function started(session, remote) {
|
|
413
|
+
draft = {};
|
|
414
|
+
saved.set("draft", null);
|
|
415
|
+
images = [];
|
|
416
|
+
drawImages();
|
|
417
|
+
$("prompt").value = "";
|
|
418
|
+
$("name").value = "";
|
|
419
|
+
$("done-id").textContent = session.id;
|
|
420
|
+
$("done-where").textContent = [session.name, session.cwd].filter(Boolean).join(" · ");
|
|
421
|
+
const link = $("done-url");
|
|
422
|
+
link.hidden = !/^https:\/\//.test(session.url || "");
|
|
423
|
+
if (!link.hidden) link.href = session.url;
|
|
424
|
+
$("done-later").hidden = !(remote && link.hidden);
|
|
425
|
+
show("done");
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function pair() {
|
|
429
|
+
const text = $("token").value.trim();
|
|
430
|
+
const sent = text.includes("#") ? text.slice(text.lastIndexOf("#") + 1) : text;
|
|
431
|
+
if (!TOKEN.test(sent)) {
|
|
432
|
+
$("error-token").textContent = "that isn't a pairing URL: it ends in # and the token";
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
$("error-token").textContent = "";
|
|
436
|
+
$("token").value = "";
|
|
437
|
+
token = sent;
|
|
438
|
+
saved.set("token", token);
|
|
439
|
+
load();
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// Also on hashchange: a pairing URL opened over this page changes only the
|
|
443
|
+
// fragment, which doesn't load the page again.
|
|
444
|
+
function takeFragment() {
|
|
445
|
+
if (location.hash.length < 2) return false;
|
|
446
|
+
const sent = location.hash.slice(1);
|
|
447
|
+
// Out of Safari's address bar and history; a home-screen app shows neither.
|
|
448
|
+
if (!navigator.standalone) history.replaceState(null, "", location.pathname + location.search);
|
|
449
|
+
// A stray or elided (abcd…wxyz) fragment leaves a good token alone.
|
|
450
|
+
if (!TOKEN.test(sent)) return false;
|
|
451
|
+
token = sent;
|
|
452
|
+
saved.set("token", token);
|
|
453
|
+
return true;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
$("host").textContent = location.host;
|
|
457
|
+
takeFragment();
|
|
458
|
+
window.addEventListener("hashchange", () => { if (takeFragment()) load(); });
|
|
459
|
+
// The images themselves aren't kept, and a token left behind would name the next one added.
|
|
460
|
+
$("prompt").value = (draft.prompt || "").replace(/ ?\[Image #\d+\]/gi, "");
|
|
461
|
+
$("name").value = draft.name || "";
|
|
462
|
+
$("prompt").addEventListener("input", saveDraft);
|
|
463
|
+
$("name").addEventListener("input", saveDraft);
|
|
464
|
+
$("cwd").addEventListener("change", () => { saved.set("cwd", cwd() || null); relabel(); });
|
|
465
|
+
$("cwd-other").addEventListener("input", () => saved.set("cwd", cwd() || null));
|
|
466
|
+
for (const id of ["model", "effort", "permission_mode"]) $(id).addEventListener("change", () => delete $(id).dataset.forced);
|
|
467
|
+
$("remote").addEventListener("change", () => { $("remote").dataset.flipped = "1"; });
|
|
468
|
+
$("file").addEventListener("change", (e) => { addImages([...e.target.files]); e.target.value = ""; });
|
|
469
|
+
$("start").addEventListener("click", start);
|
|
470
|
+
$("retry").addEventListener("click", load);
|
|
471
|
+
$("pair-save").addEventListener("click", pair);
|
|
472
|
+
$("token").addEventListener("keydown", (e) => { if (e.key === "Enter") pair(); });
|
|
473
|
+
$("new").addEventListener("click", () => { show("form"); $("prompt").focus(); });
|
|
474
|
+
load();
|
|
475
|
+
</script>
|
|
476
|
+
</body>
|
|
477
|
+
</html>
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "openssl"
|
|
4
|
+
require "securerandom"
|
|
5
|
+
require "socket"
|
|
6
|
+
require_relative "../records"
|
|
7
|
+
require_relative "../subprocess"
|
|
8
|
+
|
|
9
|
+
module ClaudeInbox
|
|
10
|
+
module Remote
|
|
11
|
+
# The token a phone pairs with, and the names and addresses it can reach
|
|
12
|
+
# this Mac by. Those are looked up again rather than kept: macOS renames
|
|
13
|
+
# the Bonjour host after a clash, and joining another network brings
|
|
14
|
+
# another address.
|
|
15
|
+
class Pairing
|
|
16
|
+
DEFAULT_PATH = File.join(Dir.home, ".config", "claude-inbox", "listen.json")
|
|
17
|
+
FIREWALL = "/usr/libexec/ApplicationFirewall/socketfilterfw"
|
|
18
|
+
FIREWALL_STATES = {"0" => :off, "1" => :on, "2" => :block_all}.freeze
|
|
19
|
+
NAME_TTL = 10
|
|
20
|
+
|
|
21
|
+
# The Bonjour name, which is the one a phone on the same Wi-Fi resolves.
|
|
22
|
+
def self.local_host_name
|
|
23
|
+
r = Subprocess.capture("scutil", "--get", "LocalHostName")
|
|
24
|
+
name = r.out.strip if r.success?
|
|
25
|
+
(name.nil? || name.empty?) ? Socket.gethostname.sub(/\.local\z/i, "") : name
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Ethernet and Wi-Fi (en*) first: the first private address is the one
|
|
29
|
+
# `c` copies, and a VM's bridge is no use to a phone.
|
|
30
|
+
def self.addresses(interfaces = Socket.getifaddrs)
|
|
31
|
+
interfaces.select { |i| i.addr&.ip? }.sort_by.with_index { |i, n| [i.name.start_with?("en") ? 0 : 1, n] }.map(&:addr)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# :off, :on or :block_all; nil without macOS's application firewall.
|
|
35
|
+
def self.firewall_state
|
|
36
|
+
return nil unless File.executable?(FIREWALL)
|
|
37
|
+
r = Subprocess.capture(FIREWALL, "--getglobalstate")
|
|
38
|
+
FIREWALL_STATES[r.out[/State = (\d)/, 1]] if r.success?
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def initialize(path: DEFAULT_PATH, local_name: -> { Pairing.local_host_name }, hostname: -> { Socket.gethostname },
|
|
42
|
+
addresses: -> { Pairing.addresses }, firewall: -> { Pairing.firewall_state },
|
|
43
|
+
clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
|
|
44
|
+
@path = path
|
|
45
|
+
@lookup_name = local_name
|
|
46
|
+
@hostname = hostname
|
|
47
|
+
@addresses = addresses
|
|
48
|
+
@firewall = firewall
|
|
49
|
+
@clock = clock
|
|
50
|
+
@mutex = Mutex.new
|
|
51
|
+
@names = Mutex.new
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def token = @mutex.synchronize { @token ||= stored || issue }
|
|
55
|
+
|
|
56
|
+
def rotate! = @mutex.synchronize { @token = issue }
|
|
57
|
+
|
|
58
|
+
# Compared as digests, which are always the same length, so the time
|
|
59
|
+
# taken says nothing about how much of a guess was right.
|
|
60
|
+
def matches?(sent)
|
|
61
|
+
return false unless sent.is_a?(String) && !sent.empty?
|
|
62
|
+
OpenSSL.fixed_length_secure_compare(digest(sent), digest(token))
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# The token rides in the fragment, which a browser never sends, so it
|
|
66
|
+
# stays out of the request line and any log of it.
|
|
67
|
+
def urls(port:, lan:)
|
|
68
|
+
secret = token
|
|
69
|
+
hosts = lan ? ["#{local_name}.local", *ipv4s.select(&:ipv4_private?).map(&:ip_address)] : ["127.0.0.1"]
|
|
70
|
+
hosts.map { |host| "http://#{host}:#{port}/##{secret}" }
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Names a request's Host header may carry, lower case and without the
|
|
74
|
+
# port. Anything else is a page on another site whose name was pointed
|
|
75
|
+
# at this machine.
|
|
76
|
+
def hosts(lan:)
|
|
77
|
+
names = %w[127.0.0.1 localhost [::1]]
|
|
78
|
+
names += [local_name, "#{local_name}.local", @hostname.call, *ipv4s.map(&:ip_address)] if lan
|
|
79
|
+
names.map(&:downcase).uniq
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def firewall = @firewall.call
|
|
83
|
+
|
|
84
|
+
private
|
|
85
|
+
|
|
86
|
+
# Asked again at most every NAME_TTL seconds: often enough to follow a
|
|
87
|
+
# rename, and a stream of requests can't make each one fork scutil.
|
|
88
|
+
def local_name
|
|
89
|
+
@names.synchronize do
|
|
90
|
+
now = @clock.call
|
|
91
|
+
@local = [@lookup_name.call, now] unless @local && now - @local[1] < NAME_TTL
|
|
92
|
+
@local[0]
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def ipv4s = @addresses.call.select { |a| a.ipv4? && !a.ipv4_loopback? }
|
|
97
|
+
|
|
98
|
+
def stored
|
|
99
|
+
data = Records.read(@path)
|
|
100
|
+
secret = data["token"] if data.is_a?(Hash)
|
|
101
|
+
secret if secret.is_a?(String) && secret.length >= 32
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def issue
|
|
105
|
+
SecureRandom.urlsafe_base64(32).tap { |secret| Records.save(@path, {"token" => secret}, perm: 0o600) }
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def digest(text) = OpenSSL::Digest.digest("SHA256", text)
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../dialog"
|
|
4
|
+
require_relative "../text"
|
|
5
|
+
|
|
6
|
+
module ClaudeInbox
|
|
7
|
+
module Remote
|
|
8
|
+
# `N`: whether the listener is up, and how a phone reaches it.
|
|
9
|
+
class PairingDialog < Dialog
|
|
10
|
+
WIDTH = 76
|
|
11
|
+
FIREWALL = {
|
|
12
|
+
off: "firewall: off",
|
|
13
|
+
on: "firewall: on · allow ruby when macOS asks",
|
|
14
|
+
block_all: "firewall: blocking all incoming connections · nothing reaches this"
|
|
15
|
+
}.freeze
|
|
16
|
+
|
|
17
|
+
def initialize(snapshot)
|
|
18
|
+
super(:pairing, nil)
|
|
19
|
+
@snapshot = snapshot
|
|
20
|
+
@asking = false
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def title = " Pair a phone "
|
|
24
|
+
|
|
25
|
+
def lines(width, _caret)
|
|
26
|
+
s = @snapshot.call
|
|
27
|
+
out = [" " + state_line(s)]
|
|
28
|
+
out += (s.state == :listening) ? listening_lines(s) : advice(s, width)
|
|
29
|
+
if s.recent.any?
|
|
30
|
+
out << "" << " recent:"
|
|
31
|
+
out += s.recent.reverse.map { |o| " #{o.at.strftime("%H:%M")} #{o.via} #{o.result}#{" ×#{o.count}" if o.count > 1}" }
|
|
32
|
+
end
|
|
33
|
+
out << ""
|
|
34
|
+
out += key_lines(s)
|
|
35
|
+
out.map { |line| Text.truncate(line, width) }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# => :cancel, :copy, :rotate, or nil
|
|
39
|
+
def press(name, key)
|
|
40
|
+
if @asking
|
|
41
|
+
@asking = false
|
|
42
|
+
return (key == "y") ? :rotate : nil
|
|
43
|
+
end
|
|
44
|
+
return :cancel if name == :escape || key == "q"
|
|
45
|
+
return nil unless @snapshot.call.state == :listening
|
|
46
|
+
case key
|
|
47
|
+
when "c" then :copy
|
|
48
|
+
when "r"
|
|
49
|
+
@asking = true
|
|
50
|
+
nil
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def state_line(s)
|
|
57
|
+
host = "#{s.lan ? "0.0.0.0" : "127.0.0.1"}:#{s.port}"
|
|
58
|
+
line =
|
|
59
|
+
case s.state
|
|
60
|
+
when :listening then "listening on #{host}#{" · LAN, cleartext" if s.lan}"
|
|
61
|
+
when :in_use then "#{host} in use"
|
|
62
|
+
when :held then "another inbox#{" (pid #{s.held_by})" if s.held_by} is listening"
|
|
63
|
+
when :failed then "#{host}: the listener failed"
|
|
64
|
+
else "off: start with --listen or --listen-lan"
|
|
65
|
+
end
|
|
66
|
+
s.fixture ? "#{line} · fixture" : line
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def listening_lines(s)
|
|
70
|
+
urls = s.urls ? s.urls.map { |url| " " + elide(url) } : [" looking up this Mac's addresses…"]
|
|
71
|
+
out = [""] + urls + ["", " phone may use: #{s.allowed_modes.join(", ")}"]
|
|
72
|
+
out << " " + FIREWALL.fetch(s.firewall) if s.lan && s.firewall
|
|
73
|
+
out
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def elide(url) = url.sub(/(?<=#)(.{4}).+(.{4})\z/, "\\1…\\2")
|
|
77
|
+
|
|
78
|
+
def advice(s, width)
|
|
79
|
+
case s.state
|
|
80
|
+
when :in_use then ["", " trying again every few seconds; or pick another port with", " --listen=PORT"]
|
|
81
|
+
when :held then ["", " this one takes over once that one quits"]
|
|
82
|
+
when :failed then ["", *Text.wrap(s.error.to_s, width - 2).map { |l| " " + l }, "", " restart the inbox once that is fixed"]
|
|
83
|
+
else ["", " or set CLAUDE_INBOX_LISTEN=lan in your shell; the README's", " \"Starting sessions from your phone\" has the rest"]
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def key_lines(s)
|
|
88
|
+
return [" paired phones get 401 until they pair again —", " rotate? y/n"] if @asking
|
|
89
|
+
(s.state == :listening) ? [" c copy r new token esc close"] : [" esc close"]
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|