claude-inbox 0.2.0 → 0.4.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 (60) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +107 -177
  3. data/exe/claude-inbox +67 -4
  4. data/lib/claude_inbox/actions.rb +91 -0
  5. data/lib/claude_inbox/agents_client.rb +68 -62
  6. data/lib/claude_inbox/app.rb +88 -38
  7. data/lib/claude_inbox/config.rb +24 -0
  8. data/lib/claude_inbox/dialog.rb +3 -1
  9. data/lib/claude_inbox/headless.rb +31 -0
  10. data/lib/claude_inbox/images.rb +37 -6
  11. data/lib/claude_inbox/job_state.rb +25 -10
  12. data/lib/claude_inbox/keymap.rb +1 -3
  13. data/lib/claude_inbox/mod.rb +71 -0
  14. data/lib/claude_inbox/new_session_form.rb +43 -52
  15. data/lib/claude_inbox/painter.rb +31 -0
  16. data/lib/claude_inbox/palette.rb +4 -7
  17. data/lib/claude_inbox/paste.rb +2 -0
  18. data/lib/claude_inbox/peek.rb +2 -2
  19. data/lib/claude_inbox/poller.rb +48 -24
  20. data/lib/claude_inbox/pull_requests.rb +36 -22
  21. data/lib/claude_inbox/rate_limits.rb +1 -3
  22. data/lib/claude_inbox/records.rb +7 -2
  23. data/lib/claude_inbox/remote/http.rb +140 -0
  24. data/lib/claude_inbox/remote/icon.png +0 -0
  25. data/lib/claude_inbox/remote/icon.svg +8 -0
  26. data/lib/claude_inbox/remote/listener.rb +435 -0
  27. data/lib/claude_inbox/remote/page.html +477 -0
  28. data/lib/claude_inbox/remote/pairing.rb +111 -0
  29. data/lib/claude_inbox/remote/pairing_dialog.rb +93 -0
  30. data/lib/claude_inbox/remote/start.rb +201 -0
  31. data/lib/claude_inbox/remote.rb +10 -0
  32. data/lib/claude_inbox/renderer.rb +61 -61
  33. data/lib/claude_inbox/session.rb +7 -21
  34. data/lib/claude_inbox/session_request.rb +151 -0
  35. data/lib/claude_inbox/sessions.rb +0 -1
  36. data/lib/claude_inbox/settings.rb +20 -7
  37. data/lib/claude_inbox/slash_commands.rb +1 -0
  38. data/lib/claude_inbox/snapshot.rb +48 -0
  39. data/lib/claude_inbox/store/entry.rb +0 -2
  40. data/lib/claude_inbox/store/row.rb +7 -3
  41. data/lib/claude_inbox/store.rb +24 -11
  42. data/lib/claude_inbox/subprocess.rb +9 -1
  43. data/lib/claude_inbox/switch.rb +40 -0
  44. data/lib/claude_inbox/terminal.rb +9 -3
  45. data/lib/claude_inbox/text.rb +7 -1
  46. data/lib/claude_inbox/text_buffer.rb +84 -33
  47. data/lib/claude_inbox/trust.rb +23 -0
  48. data/lib/claude_inbox/vt_screen.rb +1 -1
  49. data/lib/claude_inbox/writer_lock.rb +65 -0
  50. data/lib/claude_inbox.rb +1 -10
  51. data/mod/.claude-plugin/plugin.json +20 -0
  52. data/mod/hooks/hooks.json +1 -0
  53. data/mod/hooks/register.tsx +470 -0
  54. data/mod/tests/contract.test.tsx +23 -0
  55. data/mod/tests/pane.test.tsx +199 -0
  56. data/mod/tests/snapshot.fixture.ts +47 -0
  57. data/mod/tests/snooze.test.tsx +31 -0
  58. data/mod/tsconfig.json +3 -0
  59. data/mod/types/index.d.ts +30 -0
  60. metadata +29 -1
@@ -0,0 +1,435 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "json"
5
+ require "socket"
6
+ require_relative "../agents_client"
7
+ require_relative "http"
8
+ require_relative "pairing"
9
+ require_relative "pairing_dialog"
10
+ require_relative "start"
11
+ require_relative "../subprocess"
12
+ require_relative "../text"
13
+
14
+ module ClaudeInbox
15
+ module Remote
16
+ # The opt-in HTTP listener a phone starts sessions through: the port, the
17
+ # connection limits, the static files and the Pairing token, in front of
18
+ # Start. Like the Poller it reaches App only through the queue, and it
19
+ # never writes to the terminal: every failure becomes a response or a
20
+ # notice.
21
+ class Listener
22
+ DEFAULT_PORT = 7433
23
+ DEFAULT_MODES = %w[default auto plan].freeze
24
+ LOCK_PATH = File.join(Dir.home, ".config", "claude-inbox", "listen.lock")
25
+ MAX_CONNECTIONS = 4
26
+ # Per host, so one host's idle sockets can't shut the others out. Two,
27
+ # so a phone loading the page doesn't turn its own requests away.
28
+ MAX_UNAUTHENTICATED = 2
29
+ MAX_TURNED_AWAY = 8
30
+ RETRY_EVERY = 5
31
+ WAITING = %i[in_use held].freeze
32
+ HEAD_TIMEOUT = 10
33
+ LINGER = 2
34
+ RECENT = 5
35
+ # The phone's form. Everything it needs is inline, and it talks to
36
+ # nothing but this listener.
37
+ PAGE = File.read(File.join(__dir__, "page.html"), encoding: Encoding::UTF_8).freeze
38
+ PAGE_TYPE = {
39
+ "Content-Type" => "text/html; charset=utf-8",
40
+ "Content-Security-Policy" => "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; " \
41
+ "img-src blob: data:; connect-src 'self'; manifest-src 'self'; form-action 'none'; frame-ancestors 'none'"
42
+ }.freeze
43
+ # What Add to Home Screen reads to open the page as an app of its own.
44
+ # The icon is drawn by icon.svg: rsvg-convert -w 512 icon.svg -o icon.png
45
+ MANIFEST = JSON.generate(name: "Claude Inbox", start_url: "/", scope: "/", display: "standalone",
46
+ icons: [{src: "/icon.png", sizes: "512x512", type: "image/png"}]).freeze
47
+ ICON = File.binread(File.join(__dir__, "icon.png")).freeze
48
+ # Served to anyone, like the page itself: none of it is a secret.
49
+ FILES = {
50
+ "/" => [PAGE_TYPE, PAGE],
51
+ "/manifest.webmanifest" => [{"Content-Type" => "application/manifest+json"}, MANIFEST],
52
+ "/icon.png" => [{"Content-Type" => "image/png"}, ICON]
53
+ }.freeze
54
+ ROUTES = FILES.transform_values { "GET" }.merge(Start::VERBS).freeze
55
+
56
+ # What `N` and the header chip show, as of one moment. `state` is :off,
57
+ # :listening, :in_use (the port is taken), :held (another inbox has the
58
+ # lock, as `held_by`) or :failed (for good, as `error` says). `urls` and
59
+ # `firewall` are nil until `refresh`.
60
+ Snapshot = Data.define(:state, :port, :lan, :urls, :firewall, :allowed_modes, :recent, :held_by, :fixture, :error) do
61
+ def initialize(error: nil, **) = super
62
+
63
+ # Over a VPN only an address reaches the Mac: multicast DNS, which
64
+ # the .local name needs, stays on the LAN.
65
+ def pairing_url = (lan && urls&.[](1)) || urls&.first
66
+ end
67
+
68
+ # `count` is how many times `via` got `result`, the last at `at`.
69
+ Outcome = Data.define(:at, :via, :result, :count) do
70
+ def initialize(at:, via:, result:, count: 1) = super
71
+ end
72
+
73
+ # App's `listen:` from the flags, else the environment; nil when neither
74
+ # asks for it. --listen-lan wins over --listen, whichever came last.
75
+ def self.options(argv, env)
76
+ flags = argv.filter_map { |arg| arg.match(/\A--listen(-lan)?(?:=(.*))?\z/) }
77
+ lan, port =
78
+ if flags.any?
79
+ flag = flags.reverse.find { |m| m[1] } || flags.last
80
+ [!flag[1].nil?, flag[2]]
81
+ elsif !(value = env["CLAUDE_INBOX_LISTEN"].to_s.strip).empty?
82
+ m = value.match(/\A(?:(lan)(?::(.*))?|(\d+))\z/i)
83
+ raise ArgumentError, "CLAUDE_INBOX_LISTEN is a port, lan or lan:PORT, not #{value}" unless m
84
+ [!m[1].nil?, m[2] || m[3]]
85
+ else
86
+ return nil
87
+ end
88
+ modes = argv.filter_map { |arg| arg[/\A--listen-allow-modes=(.*)\z/, 1] }.last || env["CLAUDE_INBOX_LISTEN_ALLOW_MODES"]
89
+ {port: port_option(port), lan: lan, allowed_modes: modes_option(modes)}
90
+ end
91
+
92
+ def self.port_option(value)
93
+ return DEFAULT_PORT if value.nil?
94
+ raise ArgumentError, "the listen port is a number up to 65535, not #{value}" unless value.match?(/\A\d{1,5}\z/) && value.to_i <= 65_535
95
+ value.to_i
96
+ end
97
+
98
+ def self.modes_option(value)
99
+ return DEFAULT_MODES if value.nil? || value.strip.empty?
100
+ modes = value.split(",").map(&:strip).reject(&:empty?)
101
+ unknown = modes - AgentsClient::PERMISSION_MODES
102
+ raise ArgumentError, "unknown permission mode #{unknown.first}: one of #{AgentsClient::PERMISSION_MODES.join(", ")}" if unknown.any?
103
+ modes.uniq.freeze
104
+ end
105
+ private_class_method :port_option, :modes_option
106
+
107
+ # For tests, --fixture without a listen flag, and anything embedding
108
+ # App: never binds, and answers :off.
109
+ def self.disabled = new(client: nil, store: nil, queue: nil, pairing: nil, port: nil)
110
+
111
+ # `start_options` are Start's own.
112
+ def initialize(queue:, port:, pairing: Pairing.new, lan: false, allowed_modes: DEFAULT_MODES,
113
+ lock_path: LOCK_PATH, fixture: false, retry_every: RETRY_EVERY, **start_options)
114
+ @queue = queue
115
+ @pairing = pairing
116
+ @port = port
117
+ @lan = lan
118
+ @allowed_modes = allowed_modes
119
+ @lock_path = lock_path
120
+ @fixture = fixture
121
+ @retry_every = retry_every
122
+ @start = Start.new(queue: queue, allowed_modes: allowed_modes, fixture: fixture, **start_options)
123
+ @mutex = Mutex.new
124
+ @state = :off
125
+ @recent = []
126
+ @threads = Set.new
127
+ @alive = 0
128
+ @unauthenticated = Hash.new(0)
129
+ @turning_away = 0
130
+ end
131
+
132
+ # Tries again every few seconds while the port is taken or another
133
+ # inbox has the listener, so this one takes over once it is free.
134
+ def start
135
+ return if @port.nil? || @accept || @retry || !WAITING.include?(claim_port)
136
+ @retry = Thread.new { retry_claim }
137
+ end
138
+
139
+ # Runs after the terminal is restored and can't fail, so a bad socket
140
+ # never leaves the terminal raw.
141
+ def stop
142
+ quietly { @retry&.kill }
143
+ quietly { @accept&.kill }
144
+ quietly { @mutex.synchronize { @threads.to_a }.each(&:kill) }
145
+ quietly { @server&.close }
146
+ release_lock
147
+ @mutex.synchronize { @state = :off }
148
+ end
149
+
150
+ def port = @bound_port || @port
151
+
152
+ def snapshot
153
+ @mutex.synchronize do
154
+ Snapshot.new(state: @state, port: port, lan: @lan, urls: @urls, firewall: @firewall, allowed_modes: @allowed_modes,
155
+ recent: @recent.dup.freeze, held_by: @held_by, fixture: @fixture, error: @error)
156
+ end
157
+ end
158
+
159
+ # Looks up the pairing URLs and the firewall for the snapshot. It
160
+ # forks, so it runs off the main thread, when `N` opens; asked for
161
+ # before the port is ours, it runs once it is.
162
+ def refresh
163
+ @refresh_wanted = true
164
+ return unless snapshot.state == :listening
165
+ urls = @pairing.urls(port: port, lan: @lan).freeze
166
+ firewall = @pairing.firewall if @lan
167
+ @mutex.synchronize do
168
+ @urls = urls
169
+ @firewall = firewall
170
+ end
171
+ end
172
+
173
+ # The addresses fork scutil, so they fill in once looked up.
174
+ def pairing_dialog
175
+ in_background { refresh }
176
+ PairingDialog.new(-> { snapshot })
177
+ end
178
+
179
+ def copy_pairing_url
180
+ url = snapshot.pairing_url
181
+ return @queue << [:notice, "still looking up this Mac's addresses"] unless url
182
+ in_background do
183
+ r = Subprocess.capture("osascript", "-e", "on run argv", "-e", "set the clipboard to item 1 of argv", "-e", "end run", url)
184
+ @queue << [:notice, r.success? ? "pairing URL copied" : "couldn't copy: #{r.err.strip}"]
185
+ end
186
+ end
187
+
188
+ def rotate
189
+ in_background do
190
+ @pairing.rotate!
191
+ refresh
192
+ @queue << [:notice, "new token: phones pair again with N"]
193
+ end
194
+ end
195
+
196
+ # One request read from `io` and answered on it. The connection's
197
+ # `slot` stops counting as unauthenticated once the token checks out.
198
+ def handle(io, via:, slot: nil)
199
+ authorized = false
200
+ request = Http.read_head(io, deadline: Http.monotonic + HEAD_TIMEOUT)
201
+ file = route(request)
202
+ return Http.write(io, 200, *file) if file
203
+ authorize(request, via)
204
+ authorized = true
205
+ authenticated(slot) if slot
206
+ Http.write(io, *answer(request, io, via))
207
+ rescue Http::Error => e
208
+ Http.write(io, e.status, Http::JSON_TYPE.merge(e.headers), JSON.generate(e.body))
209
+ rescue => e
210
+ # A message can name a path under the home directory, which is
211
+ # nobody's business before the token checks out.
212
+ Http.write(io, 500, Http::JSON_TYPE, JSON.generate(error: authorized ? Http.utf8(e.message) : "internal error"))
213
+ end
214
+
215
+ private
216
+
217
+ # Without SO_REUSEADDR the bind fails while another program holds the
218
+ # port on any address, rather than sharing it (macOS gives loopback to
219
+ # the more specific bind); the cost is a bind refused during TIME_WAIT.
220
+ def claim_port
221
+ return :held unless take_lock
222
+ server = Socket.new(:INET, :STREAM)
223
+ server.bind(Addrinfo.tcp(@lan ? "0.0.0.0" : "127.0.0.1", @port))
224
+ server.listen(8)
225
+ @server = server
226
+ @bound_port = server.local_address.ip_port
227
+ @mutex.synchronize { @state = :listening }
228
+ @accept = Thread.new { accept_loop }
229
+ refresh_quietly if @refresh_wanted
230
+ :listening
231
+ rescue Errno::EADDRINUSE
232
+ give_up(server, :in_use)
233
+ rescue SystemCallError => e
234
+ give_up(server, :failed, e.message)
235
+ end
236
+
237
+ def retry_claim
238
+ loop do
239
+ sleep @retry_every
240
+ break unless WAITING.include?(claim_port)
241
+ end
242
+ end
243
+
244
+ def give_up(server, state, error = nil)
245
+ quietly { server&.close }
246
+ release_lock
247
+ @mutex.synchronize do
248
+ @state = state
249
+ @error = error && Text.printable(error)
250
+ end
251
+ state
252
+ end
253
+
254
+ # On the retry thread, where nothing would report a failure; the dialog
255
+ # just goes on saying it is looking the addresses up.
256
+ def refresh_quietly
257
+ refresh
258
+ rescue
259
+ nil
260
+ end
261
+
262
+ def take_lock
263
+ FileUtils.mkdir_p(File.dirname(@lock_path))
264
+ file = File.open(@lock_path, File::RDWR | File::CREAT, 0o600)
265
+ if file.flock(File::LOCK_EX | File::LOCK_NB)
266
+ file.truncate(0)
267
+ file.write(Process.pid.to_s)
268
+ file.flush
269
+ @mutex.synchronize { @held_by = nil }
270
+ @lock = file
271
+ else
272
+ holder = file.read.to_i
273
+ file.close
274
+ @mutex.synchronize do
275
+ @state = :held
276
+ @held_by = holder.positive? ? holder : nil
277
+ end
278
+ nil
279
+ end
280
+ end
281
+
282
+ def release_lock
283
+ quietly { @lock&.close }
284
+ @lock = nil
285
+ end
286
+
287
+ # Only closing the socket ends it; anything else, a full file table
288
+ # say, is waited out.
289
+ def accept_loop
290
+ loop do
291
+ sock, addr = @server.accept
292
+ admit(sock, peer(addr))
293
+ rescue IOError
294
+ break
295
+ rescue
296
+ sleep 0.1
297
+ end
298
+ end
299
+
300
+ def peer(addr) = addr.ip_address
301
+
302
+ def admit(sock, via)
303
+ slot = @mutex.synchronize { take_slot(via) }
304
+ return quietly { sock.close } unless slot
305
+ thread = Thread.new { serve(sock, slot) }
306
+ @mutex.synchronize { @threads << thread if thread.alive? }
307
+ rescue ThreadError
308
+ quietly { sock.close }
309
+ free(slot)
310
+ end
311
+
312
+ # Over the cap, a 503 rather than a queue. It lingers like any answer,
313
+ # so it isn't lost to a reset; past a few of those, only a close.
314
+ def take_slot(via)
315
+ if @alive < MAX_CONNECTIONS && @unauthenticated[via] < MAX_UNAUTHENTICATED
316
+ @alive += 1
317
+ @unauthenticated[via] += 1
318
+ {via: via, authenticated: false}
319
+ elsif @turning_away < MAX_TURNED_AWAY
320
+ @turning_away += 1
321
+ {busy: true}
322
+ end
323
+ end
324
+
325
+ def free(slot)
326
+ @mutex.synchronize do
327
+ if slot[:busy] then @turning_away -= 1
328
+ else
329
+ @alive -= 1
330
+ release_unauthenticated(slot[:via]) unless slot[:authenticated]
331
+ end
332
+ end
333
+ end
334
+
335
+ # Under the mutex. A host leaves the hash at zero, so it holds only
336
+ # hosts connected now.
337
+ def release_unauthenticated(via)
338
+ @unauthenticated[via] -= 1
339
+ @unauthenticated.delete(via) if @unauthenticated[via].zero?
340
+ end
341
+
342
+ # `handle` answers every failure it can; what is left is the socket
343
+ # itself going away, and a thread must not report that on stderr.
344
+ def serve(sock, slot)
345
+ if slot[:busy]
346
+ Http.write(sock, 503, Http::JSON_TYPE.merge("Retry-After" => "2"), JSON.generate(error: "busy: try again in a moment"))
347
+ else
348
+ handle(sock, via: slot[:via], slot: slot)
349
+ end
350
+ linger(sock)
351
+ rescue
352
+ nil
353
+ ensure
354
+ quietly { sock.close }
355
+ free(slot)
356
+ @mutex.synchronize { @threads.delete(Thread.current) }
357
+ end
358
+
359
+ def authenticated(slot)
360
+ @mutex.synchronize do
361
+ release_unauthenticated(slot[:via]) unless slot[:authenticated]
362
+ slot[:authenticated] = true
363
+ end
364
+ end
365
+
366
+ # The client closes first: a close on unread bytes resets the answer,
367
+ # and a server-side close leaves the TIME_WAIT that refuses a rebind.
368
+ def linger(sock)
369
+ deadline = Http.monotonic + LINGER
370
+ loop do
371
+ left = deadline - Http.monotonic
372
+ break unless left > 0 && IO.select([sock], nil, nil, left)
373
+ break if sock.read_nonblock(Http::CHUNK, exception: false).nil?
374
+ end
375
+ end
376
+
377
+ # The file a path serves to anyone, or nil for one Start answers.
378
+ def route(request)
379
+ raise Http::Error.new(405, "only GET and POST", headers: {"Allow" => "GET, POST"}) unless %w[GET POST].include?(request.verb)
380
+ raise Http::Error.new(421, "the Host header isn't a name this inbox answers to") unless @pairing.hosts(lan: @lan).include?(host(request))
381
+ verb = ROUTES[request.path]
382
+ raise Http::Error.new(404, "no such path") unless verb
383
+ raise Http::Error.new(405, "#{request.path} takes #{verb}", headers: {"Allow" => verb}) unless request.verb == verb
384
+ FILES[request.path]
385
+ end
386
+
387
+ # The name without its port: a tunnel or a proxy can change the port,
388
+ # and only the name tells this Mac from a site pointed at it.
389
+ def host(request) = request.headers["host"].to_s.downcase.sub(/:\d+\z/, "")
390
+
391
+ def authorize(request, via)
392
+ return if @pairing.matches?(request.headers["authorization"].to_s[/\ABearer +(\S+)\z/i, 1])
393
+ remember(via, "token rejected")
394
+ raise Http::Error.new(401, "missing or wrong token: press N in the inbox and pair again", headers: {"WWW-Authenticate" => "Bearer"})
395
+ end
396
+
397
+ # The same outcome from the same place again moves up with a count, so
398
+ # a host sending bad tokens can't push everything else out of the list.
399
+ # Every start, and every refusal once the token has checked out, is
400
+ # listed for `N`.
401
+ def answer(request, io, via)
402
+ status, headers, body, note = @start.call(request, io, via)
403
+ remember(via, note) if note
404
+ [status, headers, body]
405
+ rescue Http::Error => e
406
+ remember(via, e.note || "refused: #{e.message}")
407
+ raise
408
+ end
409
+
410
+ def remember(via, result)
411
+ result = Text.printable(result)
412
+ @mutex.synchronize do
413
+ same = @recent.find { |o| o.via == via && o.result == result }
414
+ outcome = Outcome.new(at: Time.now, via: via, result: result, count: (same&.count || 0) + 1)
415
+ @recent = (@recent - [same] + [outcome]).last(RECENT)
416
+ end
417
+ end
418
+
419
+ # A notice, not an :error: the next poll clears an :error.
420
+ def in_background
421
+ Thread.new do
422
+ yield
423
+ rescue => e
424
+ @queue << [:notice, Text.printable(e.message)]
425
+ end
426
+ end
427
+
428
+ def quietly
429
+ yield
430
+ rescue IOError, SystemCallError
431
+ nil
432
+ end
433
+ end
434
+ end
435
+ end