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
@@ -3,6 +3,7 @@
3
3
  require "json"
4
4
  require_relative "debug"
5
5
  require_relative "job_state"
6
+ require_relative "session"
6
7
  require_relative "subprocess"
7
8
 
8
9
  module ClaudeInbox
@@ -45,9 +46,11 @@ module ClaudeInbox
45
46
  # that exists disables attach too. So we watch the child's command line
46
47
  # and, the moment it becomes the agents view, terminate it. The user then
47
48
  # lands back in the inbox instead of native agent view.
48
- def attach(id)
49
- pid = Process.spawn(@bin, "attach", id)
50
- watchdog = Thread.new { kill_when_agents_view(pid) }
49
+ # The block, polled with the command line, ends the attach early when it
50
+ # answers true: how a switch request from another front end is honoured.
51
+ def attach(id, &stop)
52
+ pid = Process.spawn(*Subprocess.command(@bin, "attach", id))
53
+ watchdog = Thread.new { kill_when_agents_view(pid, &stop) }
51
54
  _, status = Process.wait2(pid)
52
55
  status
53
56
  ensure
@@ -64,10 +67,7 @@ module ClaudeInbox
64
67
 
65
68
  # Start a background session. Returns its short id.
66
69
  def spawn(prompt:, cwd:, **opts)
67
- argv = self.class.spawn_args(@bin, prompt: prompt, **opts)
68
- r = Subprocess.capture(*argv, chdir: cwd)
69
- raise Error, "claude --bg failed: #{(r.err + r.out).strip}" unless r.success?
70
- r.out[/\b[0-9a-f]{8}\b/] || r.out.strip
70
+ start_bg(self.class.spawn_args(@bin, prompt: prompt, **opts), cwd, "claude --bg")
71
71
  end
72
72
 
73
73
  # How a prompt attaches a file: the same @ mention the CLI's own prompt
@@ -75,18 +75,19 @@ module ClaudeInbox
75
75
  # its path completion escapes them; a quoted path is not recognized.
76
76
  def self.mention(path) = "@" + path.gsub(" ", "\\ ")
77
77
 
78
- # Pure so it can be tested: "default" means leave the flag off.
79
- # --remote-control takes an optional name and eats whatever follows it,
80
- # prompt included, so it goes last.
78
+ # Pure so it can be tested: a setting left nil leaves its flag off. The
79
+ # prompt goes after "--", where a leading dash is text rather than an
80
+ # unknown option, and --remote-control sits just before it: its optional
81
+ # name would otherwise eat the prompt (both checked on 2.1.282).
81
82
  def self.spawn_args(bin, prompt:, model: nil, effort: nil, permission_mode: nil, worktree: false, name: nil, remote: false)
82
- argv = [bin, "--bg", prompt]
83
- argv += ["--model", model] if model && model != "default"
84
- argv += ["--effort", effort] if effort && effort != "default"
85
- argv += ["--permission-mode", permission_mode] if permission_mode && permission_mode != "default"
83
+ argv = [bin, "--bg"]
84
+ {"--model" => model, "--effort" => effort, "--permission-mode" => permission_mode}.each do |flag, value|
85
+ argv += [flag, value] if value
86
+ end
86
87
  argv += ["--name", name] if name && !name.strip.empty?
87
88
  argv << "--worktree" if worktree
88
89
  argv << "--remote-control" if remote
89
- argv
90
+ argv + ["--", prompt]
90
91
  end
91
92
 
92
93
  # Pull a conversation a `claude remote-control` server is serving into
@@ -98,9 +99,7 @@ module ClaudeInbox
98
99
  raise Error, "nothing to adopt yet — send it a message from your phone first" if transcript_empty?(cwd, session_id)
99
100
  Process.kill("TERM", pid)
100
101
  wait_gone(pid)
101
- r = Subprocess.capture(*self.class.adopt_args(@bin, session_id), chdir: cwd)
102
- raise Error, "claude --bg --resume failed: #{(r.err + r.out).strip}" unless r.success?
103
- r.out[/\b[0-9a-f]{8}\b/] || r.out.strip
102
+ start_bg(self.class.adopt_args(@bin, session_id), cwd, "claude --bg --resume")
104
103
  end
105
104
 
106
105
  def self.adopt_args(bin, session_id) = [bin, "--bg", "--resume", session_id, "--remote-control"]
@@ -111,6 +110,45 @@ module ClaudeInbox
111
110
  File.join(home, ".claude", "projects", cwd.gsub(%r{[/.]}, "-"), "#{session_id}.jsonl")
112
111
  end
113
112
 
113
+ # Flags that mark a claude process as one a program drives rather than one
114
+ # you type in: the headless print mode and the SDK's stream protocol.
115
+ HEADLESS_FLAGS = %w[-p --print --input-format --output-format].freeze
116
+
117
+ # pid => origin, given `ps` for the sessions and for their parents. Pure.
118
+ #
119
+ # :remote a Remote Control worker: --sdk-url, or a `claude rc` parent
120
+ # :headless `claude -p "..."` or an SDK stream run. Its parent is
121
+ # whatever shell spawned it, not the claude that asked for
122
+ # it, so only its own command line gives it away
123
+ # :subagent parented by another claude process
124
+ # :terminal everything else, which is a claude you are sitting in
125
+ def self.origins(rows, parent_cmd)
126
+ rows.to_h do |pid, ppid, cmd|
127
+ parent = parent_cmd[ppid].to_s
128
+ origin =
129
+ if cmd.include?("--sdk-url") || parent.match?(/\bclaude rc\b/) then :remote
130
+ elsif headless?(cmd) then :headless
131
+ elsif parent.match?(/(^|\/)claude\b/) then :subagent
132
+ else :terminal
133
+ end
134
+ [pid, origin]
135
+ end
136
+ end
137
+
138
+ # `ps` flattens quoting, so a prompt that mentions a flag reads the same
139
+ # as the flag itself. Nobody types `claude "what does -p do"` into a
140
+ # terminal often enough to matter; the false positive is accepted.
141
+ def self.headless?(cmd) = cmd.split.drop(1).any? { |arg| HEADLESS_FLAGS.include?(arg) }
142
+
143
+ # pid => bridge session id, for the workers a `claude remote-control`
144
+ # server spawned: each is started with `--session-id cse_…`, the same id
145
+ # a background session records as bridgeSessionId. Pure.
146
+ def self.bridge_ids(rows)
147
+ rows.filter_map { |pid, _, cmd| (m = cmd.match(/--session-id[= ](cse_\w+)/)) && [pid, m[1]] }.to_h
148
+ end
149
+
150
+ private
151
+
114
152
  # A worker nobody has messaged yet has an empty file, and the daemon
115
153
  # reports "source session not found" on resuming it.
116
154
  def transcript_empty?(cwd, session_id)
@@ -133,10 +171,6 @@ module ClaudeInbox
133
171
  JSON.parse(json).map { |h| Session.from_hash(h) }
134
172
  end
135
173
 
136
- # Flags that mark a claude process as one a program drives rather than one
137
- # you type in: the headless print mode and the SDK's stream protocol.
138
- HEADLESS_FLAGS = %w[-p --print --input-format --output-format].freeze
139
-
140
174
  # The JSON reports Remote Control workers, local sub-agents and headless
141
175
  # runs as `interactive`, same as a terminal you opened yourself, each named
142
176
  # after its directory. The process tree tells them apart; see `origins`.
@@ -160,39 +194,6 @@ module ClaudeInbox
160
194
  .reject(&:unattended?)
161
195
  end
162
196
 
163
- # pid => origin, given `ps` for the sessions and for their parents. Pure.
164
- #
165
- # :remote a Remote Control worker: --sdk-url, or a `claude rc` parent
166
- # :headless `claude -p "..."` or an SDK stream run. Its parent is
167
- # whatever shell spawned it, not the claude that asked for
168
- # it, so only its own command line gives it away
169
- # :subagent parented by another claude process
170
- # :terminal everything else, which is a claude you are sitting in
171
- def self.origins(rows, parent_cmd)
172
- rows.to_h do |pid, ppid, cmd|
173
- parent = parent_cmd[ppid].to_s
174
- origin =
175
- if cmd.include?("--sdk-url") || parent.match?(/\bclaude rc\b/) then :remote
176
- elsif headless?(cmd) then :headless
177
- elsif parent.match?(/(^|\/)claude\b/) then :subagent
178
- else :terminal
179
- end
180
- [pid, origin]
181
- end
182
- end
183
-
184
- # `ps` flattens quoting, so a prompt that mentions a flag reads the same
185
- # as the flag itself. Nobody types `claude "what does -p do"` into a
186
- # terminal often enough to matter; the false positive is accepted.
187
- def self.headless?(cmd) = cmd.split.drop(1).any? { |arg| HEADLESS_FLAGS.include?(arg) }
188
-
189
- # pid => bridge session id, for the workers a `claude remote-control`
190
- # server spawned: each is started with `--session-id cse_…`, the same id
191
- # a background session records as bridgeSessionId. Pure.
192
- def self.bridge_ids(rows)
193
- rows.filter_map { |pid, _, cmd| (m = cmd.match(/--session-id[= ](cse_\w+)/)) && [pid, m[1]] }.to_h
194
- end
195
-
196
197
  def origins_from(rows)
197
198
  return {} if rows.empty?
198
199
  self.class.origins(rows, ps_commands(rows.map { |_, ppid, _| ppid }.uniq))
@@ -205,8 +206,6 @@ module ClaudeInbox
205
206
  pid, ppid, *cmd = l.split
206
207
  [pid.to_i, ppid.to_i, cmd.join(" ")]
207
208
  }
208
- rescue Errno::ENOENT
209
- []
210
209
  end
211
210
 
212
211
  # pid => command, for the given parent pids.
@@ -220,17 +219,18 @@ module ClaudeInbox
220
219
  }
221
220
  end
222
221
 
223
- private
224
-
225
- def kill_when_agents_view(pid)
222
+ def kill_when_agents_view(pid, &stop)
226
223
  loop do
227
224
  sleep WATCH_INTERVAL
228
225
  cmd = Subprocess.capture("ps", "-o", "command=", "-p", pid.to_s).out
229
226
  break if cmd.empty?
230
- next unless cmd.split[1] == "agents"
227
+ next unless cmd.split[1] == "agents" || stop&.call
231
228
  Debug.log("watchdog saw #{cmd.inspect}")
232
229
  Process.kill("TERM", pid)
233
- sleep 1
230
+ 10.times do
231
+ sleep 0.1
232
+ break if Subprocess.capture("ps", "-o", "pid=", "-p", pid.to_s).out.empty?
233
+ end
234
234
  Process.kill("KILL", pid)
235
235
  break
236
236
  end
@@ -243,6 +243,12 @@ module ClaudeInbox
243
243
  raise Error, "#{argv[1]} failed: #{r.err.strip}" unless r.success?
244
244
  true
245
245
  end
246
+
247
+ def start_bg(argv, cwd, what)
248
+ r = Subprocess.capture(*argv, chdir: cwd)
249
+ raise Error, "#{what} failed: #{(r.err + r.out).strip}" unless r.success?
250
+ r.out[/\b[0-9a-f]{8}\b/] || r.out.strip
251
+ end
246
252
  end
247
253
 
248
254
  # Reads a committed JSON fixture instead of the daemon.
@@ -11,33 +11,43 @@ require_relative "terminal"
11
11
  require_relative "logs"
12
12
  require_relative "peek"
13
13
  require_relative "keymap"
14
+ require_relative "remote"
14
15
  require_relative "mouse"
15
16
  require_relative "new_session_form"
16
17
  require_relative "paste"
17
18
  require_relative "pull_requests"
19
+ require_relative "reaper"
18
20
  require_relative "poller"
19
21
  require_relative "rate_limits"
22
+ require_relative "session_request"
23
+ require_relative "actions"
24
+ require_relative "snapshot"
25
+ require_relative "switch"
20
26
 
21
27
  module ClaudeInbox
22
28
  # Owns the terminal and the key loop. The only class allowed to spawn a
23
29
  # child process that takes over the terminal.
24
30
  class App
25
- # The reaper defaults to off. It is the only thing here that deletes a
26
- # session, so switching it on is `bin/claude-inbox`'s job and nothing
27
- # reaches it by forgetting an argument.
31
+ # The reaper deletes sessions and the listener lets other machines in,
32
+ # so both are off unless `bin/claude-inbox` switches them on.
28
33
  def initialize(client: AgentsClient.new, store: Store.new, pull_requests: PullRequests.new,
29
- rate_limits: RateLimits.new, reaper: Reaper.disabled, out: $stdout, input: $stdin, color: true,
30
- terminal: Terminal.new(out, input))
34
+ rate_limits: RateLimits.new, reaper: Reaper.disabled, snapshot: Snapshot.disabled, switch: Switch.disabled,
35
+ actions: Actions.disabled, listen: nil, out: $stdout, input: $stdin, color: true,
36
+ terminal: Terminal.new(out, input), queue: Queue.new)
31
37
  @client = client
32
38
  @store = store
39
+ @snapshot = snapshot
40
+ @switch = switch
33
41
  @rate_limits = rate_limits
34
42
  @terminal = terminal
35
43
  @color = color
36
44
  @renderer = Renderer.new(color: color)
45
+ @input = input
37
46
  @reader = TTY::Reader.new(input: input, output: out, interrupt: :noop)
38
- @queue = Queue.new
47
+ @queue = queue
39
48
  @poller = Poller.new(client: client, store: store, pull_requests: pull_requests,
40
- reaper: reaper, queue: @queue)
49
+ reaper: reaper, queue: @queue, snapshot: snapshot, actions: actions)
50
+ @listener = listen ? Remote::Listener.new(client: client, store: store, queue: @queue, **listen) : Remote::Listener.disabled
41
51
  @logs = Logs.new(client)
42
52
  @peek = Peek.new(@logs)
43
53
  @selected = nil
@@ -60,12 +70,14 @@ module ClaudeInbox
60
70
  install_traps
61
71
  @terminal.enter
62
72
  @poller.start
73
+ @listener.start
63
74
  @logs.start
64
75
  main_loop
65
76
  ensure
66
77
  @poller.stop
67
78
  @logs.stop
68
79
  @terminal.restore
80
+ @listener.stop
69
81
  end
70
82
 
71
83
  def step(input = nil)
@@ -75,8 +87,10 @@ module ClaudeInbox
75
87
  @resize = false
76
88
  @terminal.resized
77
89
  end
78
- render
79
90
  handle_input(input) if input
91
+ # A paste arrives a character per read and shows nothing until it
92
+ # closes, so a frame per character would only stall a long one.
93
+ render unless @paste.pasting?
80
94
  end
81
95
 
82
96
  private
@@ -104,7 +118,6 @@ module ClaudeInbox
104
118
  kind, *rest = @queue.pop(true)
105
119
  case kind
106
120
  when :sessions
107
- @store.update(rest[0])
108
121
  @last_poll = Time.now
109
122
  @error = nil
110
123
  when :error then @error = rest[0]
@@ -113,6 +126,7 @@ module ClaudeInbox
113
126
  when :attach then attach(rest[0])
114
127
  when :form_started then @modal = nil if @modal.equal?(rest[0])
115
128
  when :form_failed then form_failed(*rest)
129
+ when :remote_started then remote_started(*rest)
116
130
  end
117
131
  end
118
132
  rescue ThreadError
@@ -122,7 +136,19 @@ module ClaudeInbox
122
136
  # ----- main loop --------------------------------------------------------
123
137
 
124
138
  def main_loop
125
- step(@reader.read_keypress(echo: false, raw: false, nonblock: true)) until @quit
139
+ step(@paste.pasting? ? read_pasted : @reader.read_keypress(echo: false, raw: false, nonblock: true)) until @quit
140
+ end
141
+
142
+ # As much of an open paste as has arrived, read straight off the tty:
143
+ # the reader switches echo off and back on around every character, and
144
+ # on a long paste that is most of the wait.
145
+ def read_pasted
146
+ return nil unless @input.wait_readable(0.1)
147
+ out = +""
148
+ while out.size < 65_536 && @input.wait_readable(0) && (c = @input.getc)
149
+ out << c
150
+ end
151
+ out
126
152
  end
127
153
 
128
154
  def handle_input(raw)
@@ -135,24 +161,30 @@ module ClaudeInbox
135
161
  end
136
162
 
137
163
  # The form takes a paste whole, images included; the one-line editors
138
- # take it as typing, so a pasted PR URL lands where it should.
164
+ # take it as typing, so a pasted PR URL lands where it should. Anywhere
165
+ # else a paste is dropped: typed out, its letters would be keys, and a
166
+ # "y" answers a confirm.
139
167
  def handle_paste(text)
140
168
  return @modal.paste(text) if @modal.is_a?(NewSessionForm)
141
- text.each_char { |c| handle_key(c) }
169
+ typing = @modal ? @modal.is_a?(Dialog::Prompt) : @filter_editing
170
+ text.each_char { |c| handle_key(c) } if typing
142
171
  end
143
172
 
144
173
  def render
145
174
  t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
146
175
  now = Time.now
147
- sections = filtered(@store.sections(now))
176
+ all = @store.sections(now)
177
+ @snapshot.write(all, now)
178
+ sections = filtered(all)
148
179
  width, height = @terminal.size
180
+ body_h = Renderer.body_height(height)
149
181
  ensure_selection(sections)
150
182
  @tick += 1
151
183
  view = Renderer::View.new(
152
184
  width: width, height: height, now: now, selected: @selected&.key, top: @top, expanded: @expanded,
153
- peek: @peek.view(sections.row(@selected), height), modal: modal_lines(width), screen: screen_lines(width, height),
185
+ peek: @peek.view(sections.row(@selected), body_h), modal: modal_lines(width), screen: screen_lines(width, body_h),
154
186
  status: status_text(now), usage: @rate_limits.windows(now), filter: @filter, filter_editing: @filter_editing,
155
- tick: @tick / 2, loading: loading_for
187
+ tick: @tick / 2, loading: loading_for, listening: @listener.snapshot
156
188
  )
157
189
  frame = @renderer.frame(sections, view)
158
190
  @row_items = frame.items
@@ -202,8 +234,9 @@ module ClaudeInbox
202
234
 
203
235
  # Guard for attach/stop: refuse politely on a terminal or remote row.
204
236
  def require_actionable
205
- return true if selected_session&.actionable?
206
- if (s = selected_session)&.interactive?
237
+ s = selected_session
238
+ return true if s&.actionable?
239
+ if s&.interactive?
207
240
  notice(s.remote? ? "that's a remote session — Enter adopts it, w opens it at claude.ai/code" : "that's your own terminal — switch to that window")
208
241
  end
209
242
  false
@@ -212,8 +245,16 @@ module ClaudeInbox
212
245
  # Guard for snooze/wake/alias: anything with a key, since those live in
213
246
  # our own store. A terminal you are sitting in is the one exception.
214
247
  def require_storable
215
- return true if @selected&.row? && !selected_session&.terminal?
216
- notice("you're in that terminal right now — nothing to snooze") if selected_session&.terminal?
248
+ s = selected_session
249
+ return true if @selected&.row? && !s&.terminal?
250
+ notice("you're in that terminal right now — nothing to snooze") if s&.terminal?
251
+ false
252
+ end
253
+
254
+ # The edits with no request form, which a second inbox cannot keep.
255
+ def require_writable(verb)
256
+ return true if @store.writable?
257
+ notice("another claude-inbox owns state.json — #{verb} there")
217
258
  false
218
259
  end
219
260
 
@@ -308,12 +349,13 @@ module ClaudeInbox
308
349
  when :refresh then @poller.soon
309
350
  when :toggle_peek then toggle_peek
310
351
  when :new_session then open_new_session
352
+ when :remote_pairing then @modal = @listener.pairing_dialog
311
353
  when :filter then start_filter
312
354
  when :escape then clear_filter
313
355
  end
314
356
  end
315
357
 
316
- def page = [@terminal.size[1] - 2, 1].max
358
+ def page = [Renderer.body_height(@terminal.size[1]), 1].max
317
359
 
318
360
  def move(delta)
319
361
  stops = filtered.selections(@expanded)
@@ -338,7 +380,8 @@ module ClaudeInbox
338
380
  name if Store::FOLDABLE_SECTIONS.include?(name)
339
381
  end
340
382
 
341
- def set_expanded(value, name: current_fold_section)
383
+ def set_expanded(value)
384
+ name = current_fold_section
342
385
  @expanded[name] = value if name
343
386
  end
344
387
 
@@ -375,12 +418,17 @@ module ClaudeInbox
375
418
  notice("settled — u brings it back")
376
419
  end
377
420
 
421
+ # The pane in the attached session may ask for a different one: the
422
+ # attach ends and the next starts without passing through the list. The
423
+ # poller keeps running meanwhile, so the pane's snapshot stays fresh.
378
424
  def attach(id)
379
- @store.acknowledge(id)
380
- @poller.pause
381
- @terminal.release { @client.attach(id) }
382
- ensure
383
- @poller.resume
425
+ while id
426
+ @store.acknowledge(id)
427
+ @switch.clear
428
+ from = @store.sessions.find { |s| s.key == id }&.session_id
429
+ @terminal.release { @client.attach(id) { @switch.requested_for?(from) } }
430
+ id = @switch.take(@store.sessions.select(&:actionable?).map(&:key))
431
+ end
384
432
  end
385
433
 
386
434
  # ----- modals -----------------------------------------------------------
@@ -396,13 +444,13 @@ module ClaudeInbox
396
444
  end
397
445
 
398
446
  def open_alias_editor
399
- return unless require_storable
447
+ return unless require_storable && require_writable("rename")
400
448
  current = @store.alias_for(@selected.key) || ""
401
449
  @modal = Dialog::Prompt.new(:alias, @selected.key, current)
402
450
  end
403
451
 
404
452
  def open_pr_editor
405
- return unless require_storable
453
+ return unless require_storable && require_writable("link a pull request")
406
454
  current = @store.pr_for(@selected.key) || selected_session&.pr&.url || ""
407
455
  @modal = Dialog::Prompt.new(:pr, @selected.key, current)
408
456
  end
@@ -427,14 +475,7 @@ module ClaudeInbox
427
475
 
428
476
  def open_new_session
429
477
  cwd = selected_session&.cwd || Dir.pwd
430
- @modal = NewSessionForm.new(cwd: strip_worktree(cwd), pastel: Pastel.new(enabled: @color))
431
- end
432
-
433
- # A session's cwd may sit inside a worktree another agent is using; carrying
434
- # that into a new prompt would spawn the new agent there too, writing over
435
- # the same files. Fall back to the repo the worktree was cut from.
436
- def strip_worktree(cwd)
437
- cwd.to_s.sub(%r{/\.claude/worktrees/[^/]+(?:/.*)?\z}, "")
478
+ @modal = NewSessionForm.new(cwd: SessionRequest.strip_worktree(cwd), pastel: Pastel.new(enabled: @color))
438
479
  end
439
480
 
440
481
  # `attach:` hands the terminal over as soon as the session starts. Without
@@ -454,6 +495,13 @@ module ClaudeInbox
454
495
  end
455
496
  end
456
497
 
498
+ # Someone at the desk may be mid-thought, so a start from another
499
+ # device never moves the cursor, attaches or closes what is open.
500
+ def remote_started(id, via)
501
+ notice("started #{id} from #{via}")
502
+ @poller.soon
503
+ end
504
+
457
505
  # A spawn failure hands the form back rather than just logging it, so
458
506
  # the composed prompt survives (see NewSessionForm#submission_failed).
459
507
  def form_failed(form, message)
@@ -461,9 +509,9 @@ module ClaudeInbox
461
509
  end
462
510
 
463
511
  # The new-session form takes the whole body; a Dialog is a box over it.
464
- def screen_lines(width, height)
512
+ def screen_lines(width, body_h)
465
513
  return nil unless @modal.is_a?(NewSessionForm)
466
- {lines: @modal.screen(width, height - 2), footer: @modal.footer}
514
+ {lines: @modal.screen(width, body_h), footer: @modal.footer}
467
515
  end
468
516
 
469
517
  def modal_lines(width)
@@ -486,6 +534,8 @@ module ClaudeInbox
486
534
  when :adopt then adopt_session(id)
487
535
  end
488
536
  when :save then save_prompt
537
+ when :copy then @listener.copy_pairing_url
538
+ when :rotate then @listener.rotate
489
539
  end
490
540
  end
491
541
 
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "shellwords"
4
+
5
+ module ClaudeInbox
6
+ # Arguments for every launch. They go before the typed ones, so a typed
7
+ # flag wins.
8
+ module Config
9
+ PATH = File.join(Dir.home, ".config", "claude-inbox", "config")
10
+
11
+ def self.argv(typed, path: PATH)
12
+ args(path) + typed
13
+ end
14
+
15
+ def self.args(path)
16
+ File.readlines(path).flat_map { |line| Shellwords.split(line.sub(/(\A|\s)#.*/, "")) }
17
+ rescue Errno::ENOENT
18
+ []
19
+ rescue ArgumentError => e
20
+ raise ArgumentError, "#{path}: #{e.message}"
21
+ end
22
+ private_class_method :args
23
+ end
24
+ end
@@ -10,6 +10,8 @@ module ClaudeInbox
10
10
  # the details read off the dialog afterwards. `frame(width)` is the box
11
11
  # Renderer overlays. Pure: nothing here touches the store or the client.
12
12
  class Dialog
13
+ WIDTH = 44
14
+
13
15
  attr_reader :kind, :id
14
16
 
15
17
  def initialize(kind, id)
@@ -18,7 +20,7 @@ module ClaudeInbox
18
20
  end
19
21
 
20
22
  def frame(width, caret = nil)
21
- box = [width - 4, 44].min
23
+ box = [width - 4, self.class::WIDTH].min
22
24
  TTY::Box.frame(lines(box - 4, caret).join("\n"), title: {top_left: title}, padding: [0, 1], width: box)
23
25
  .split("\n")
24
26
  end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "actions"
4
+ require_relative "poller"
5
+ require_relative "pull_requests"
6
+ require_relative "reaper"
7
+ require_relative "snapshot"
8
+ require_relative "writer_lock"
9
+
10
+ module ClaudeInbox
11
+ # The poller with no terminal, for a front end that only reads the
12
+ # snapshot: it polls, writes the file and applies the requests. It never
13
+ # reaps, since deleting sessions stays with the inbox you can see, and it
14
+ # exits at once while an inbox holds the writer lock.
15
+ class Headless
16
+ def initialize(client:, store:, pull_requests: PullRequests.new, snapshot: Snapshot.new, actions: Actions.new,
17
+ lock: WriterLock.new, queue: Queue.new)
18
+ @lock = lock
19
+ @queue = queue
20
+ @poller = Poller.new(client: client, store: store, pull_requests: pull_requests, reaper: Reaper.disabled,
21
+ queue: queue, snapshot: snapshot, actions: actions)
22
+ end
23
+
24
+ # False when the lock is held, by an inbox or another headless process.
25
+ def run
26
+ return false unless @lock.take(role: "headless")
27
+ @poller.start
28
+ loop { @queue.pop }
29
+ end
30
+ end
31
+ end
@@ -1,11 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "fileutils"
4
+ require "securerandom"
4
5
  require_relative "subprocess"
5
6
 
6
7
  module ClaudeInbox
7
- # Images a prompt can carry: a dropped file, or whatever is on the
8
- # clipboard. Both come back as a path for a TextBuffer chip to hold.
8
+ # Images a prompt can carry: a dropped file, whatever is on the
9
+ # clipboard, or the bytes a request sent. Each comes back as a path for
10
+ # the prompt to mention.
9
11
  module Images
10
12
  DEFAULT_DIR = File.join(Dir.home, ".config", "claude-inbox", "images")
11
13
  EXTENSIONS = %w[.png .jpg .jpeg .gif .webp .bmp .svg].freeze
@@ -14,9 +16,19 @@ module ClaudeInbox
14
16
 
15
17
  Clipboard = Struct.new(:image, :text)
16
18
 
17
- # Saved images are pruned here, on the way in, because nothing else
18
- # knows when a session stopped needing its image. macOS only: osascript
19
- # ships with the OS, and PNG is the flavor a screenshot puts there.
19
+ class Unsupported < StandardError; end
20
+
21
+ # Bytes from a request carry no file name worth trusting, so the type
22
+ # comes from the magic number at the front.
23
+ MAGIC = {
24
+ ".png" => /\A\x89PNG/n,
25
+ ".jpg" => /\A\xFF\xD8\xFF/n,
26
+ ".gif" => /\AGIF8/n,
27
+ ".webp" => /\ARIFF.{4}WEBP/mn
28
+ }.freeze
29
+
30
+ # macOS only: osascript ships with the OS, and PNG is the flavor a
31
+ # screenshot puts there.
20
32
  def self.from_clipboard(dir: DEFAULT_DIR, now: Time.now, run: Subprocess.method(:capture))
21
33
  FileUtils.mkdir_p(dir)
22
34
  prune(dir, now)
@@ -26,6 +38,23 @@ module ClaudeInbox
26
38
  Clipboard.new(nil, (text.success? && !text.out.empty?) ? text.out : nil)
27
39
  end
28
40
 
41
+ # `index` orders the images one request brings; the random part keeps
42
+ # two requests saving in the same millisecond apart. Readable by the
43
+ # owner only: a photo can be anything.
44
+ def self.save(bytes, dir: DEFAULT_DIR, now: Time.now, index: 1)
45
+ data = bytes.b
46
+ ext = extension(data)
47
+ raise Unsupported, "not a PNG, JPEG, GIF or WebP image" unless ext
48
+ FileUtils.mkdir_p(dir)
49
+ prune(dir, now)
50
+ path = File.join(dir, "#{now.strftime("%Y%m%d-%H%M%S-%L")}-#{index}-#{SecureRandom.hex(3)}#{ext}")
51
+ File.open(path, File::WRONLY | File::CREAT | File::EXCL | File::BINARY, 0o600) { |f| f.write(data) }
52
+ path
53
+ end
54
+
55
+ # ".png", ".jpg", ".gif" or ".webp" by the magic number, or nil.
56
+ def self.extension(bytes) = MAGIC.find { |_, magic| magic.match?(bytes.b) }&.first
57
+
29
58
  CLIPBOARD_PNG = <<~APPLESCRIPT
30
59
  on run argv
31
60
  set png to the clipboard as «class PNGf»
@@ -46,8 +75,10 @@ module ClaudeInbox
46
75
  File.file?(path) ? path : nil
47
76
  end
48
77
 
78
+ # Pruned on the way in, because nothing else knows when a session
79
+ # stopped needing its image.
49
80
  def self.prune(dir, now)
50
- Dir.glob(File.join(dir, "*.png")).each do |f|
81
+ Dir.glob(EXTENSIONS.map { |ext| File.join(dir, "*#{ext}") }).each do |f|
51
82
  File.delete(f) if now - File.mtime(f) > KEEP_FOR
52
83
  rescue SystemCallError
53
84
  nil