claude-inbox 0.1.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.
Files changed (46) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +185 -386
  3. data/exe/claude-inbox +23 -5
  4. data/lib/claude_inbox/agents_client.rb +104 -44
  5. data/lib/claude_inbox/app.rb +114 -62
  6. data/lib/claude_inbox/config.rb +24 -0
  7. data/lib/claude_inbox/dialog.rb +10 -4
  8. data/lib/claude_inbox/images.rb +37 -6
  9. data/lib/claude_inbox/job_state.rb +32 -9
  10. data/lib/claude_inbox/keymap.rb +2 -2
  11. data/lib/claude_inbox/logs.rb +5 -2
  12. data/lib/claude_inbox/new_session_form.rb +64 -52
  13. data/lib/claude_inbox/painter.rb +32 -0
  14. data/lib/claude_inbox/palette.rb +4 -9
  15. data/lib/claude_inbox/peek.rb +4 -4
  16. data/lib/claude_inbox/poller.rb +9 -14
  17. data/lib/claude_inbox/pull_requests.rb +5 -5
  18. data/lib/claude_inbox/rate_limits.rb +19 -7
  19. data/lib/claude_inbox/reaper.rb +13 -16
  20. data/lib/claude_inbox/records.rb +7 -2
  21. data/lib/claude_inbox/remote/http.rb +137 -0
  22. data/lib/claude_inbox/remote/icon.png +0 -0
  23. data/lib/claude_inbox/remote/icon.svg +8 -0
  24. data/lib/claude_inbox/remote/listener.rb +436 -0
  25. data/lib/claude_inbox/remote/page.html +477 -0
  26. data/lib/claude_inbox/remote/pairing.rb +111 -0
  27. data/lib/claude_inbox/remote/pairing_dialog.rb +93 -0
  28. data/lib/claude_inbox/remote/start.rb +202 -0
  29. data/lib/claude_inbox/remote.rb +10 -0
  30. data/lib/claude_inbox/renderer.rb +135 -113
  31. data/lib/claude_inbox/session.rb +19 -19
  32. data/lib/claude_inbox/session_request.rb +151 -0
  33. data/lib/claude_inbox/sessions.rb +2 -3
  34. data/lib/claude_inbox/settings.rb +19 -7
  35. data/lib/claude_inbox/slash_commands.rb +6 -6
  36. data/lib/claude_inbox/store/row.rb +3 -3
  37. data/lib/claude_inbox/store.rb +11 -4
  38. data/lib/claude_inbox/subprocess.rb +12 -3
  39. data/lib/claude_inbox/terminal.rb +9 -3
  40. data/lib/claude_inbox/text.rb +13 -11
  41. data/lib/claude_inbox/text_buffer.rb +3 -2
  42. data/lib/claude_inbox/theme.rb +5 -4
  43. data/lib/claude_inbox/trust.rb +23 -0
  44. data/lib/claude_inbox/vt_screen.rb +1 -3
  45. data/lib/claude_inbox.rb +1 -1
  46. metadata +15 -2
data/exe/claude-inbox CHANGED
@@ -9,25 +9,43 @@ if ARGV.include?("--version")
9
9
  end
10
10
 
11
11
  require "claude_inbox/app"
12
+ require "claude_inbox/config"
13
+ require "tmpdir"
14
+
15
+ fixture = ARGV.include?("--fixture")
16
+ # A fixture run takes the listen flags, for trying the API with curl, but
17
+ # not the environment or the config file, which are for the real inbox.
18
+ begin
19
+ argv = fixture ? ARGV : ClaudeInbox::Config.argv(ARGV)
20
+ listen = ClaudeInbox::Remote::Listener.options(argv, fixture ? {} : ENV)
21
+ rescue ArgumentError => e
22
+ abort "claude-inbox: #{e.message}"
23
+ end
12
24
 
13
25
  # Crash traces go here instead of the alt screen when set.
14
26
  $stderr.reopen(ENV["CLAUDE_INBOX_STDERR"], "a") if ENV["CLAUDE_INBOX_STDERR"]
15
27
 
16
28
  opts = {}
17
- if (i = ARGV.index("--fixture"))
18
- agents = ARGV[i + 1]
29
+ if fixture
30
+ agents = argv[argv.index("--fixture") + 1]
19
31
  fixtures = File.dirname(agents)
20
32
  opts[:client] = ClaudeInbox::FixtureClient.new(agents, logs: File.binread(File.join(fixtures, "logs_raw.txt")))
21
33
  opts[:store] = ClaudeInbox::Store.new(path: nil)
22
- opts[:jobs_dir] = File.join(fixtures, "jobs")
23
34
  opts[:pull_requests] = ClaudeInbox::PullRequests.new(cache_path: File.join(fixtures, "gh-pr-status-cache.json"), resolved_path: nil, gh: nil)
35
+ # Its own token, lock and images, so a test inbox can neither rotate the
36
+ # token phones are paired with nor hold the real inbox's lock.
37
+ dir = File.join(Dir.tmpdir, "claude-inbox-fixture")
38
+ opts[:listen] = listen&.merge(fixture: true, pairing: ClaudeInbox::Remote::Pairing.new(path: File.join(dir, "listen.json")),
39
+ lock_path: File.join(dir, "listen.lock"), images_dir: File.join(dir, "images"))
24
40
  else
25
41
  # The only place the reaper is armed. Everything else — tests, --fixture,
26
- # anything embedding App — gets one that deletes nothing.
42
+ # anything embedding App — gets one that deletes nothing. The listener,
43
+ # likewise, comes only from this file, and only when asked for.
27
44
  opts[:client] = ClaudeInbox::AgentsClient.new
28
45
  opts[:store] = ClaudeInbox::Store.new
29
46
  opts[:reaper] = ClaudeInbox::Reaper.new(opts[:client], opts[:store])
47
+ opts[:listen] = listen
30
48
  end
31
- opts[:color] = false if ARGV.include?("--no-color")
49
+ opts[:color] = false if argv.include?("--no-color")
32
50
 
33
51
  ClaudeInbox::App.new(**opts).run
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "json"
4
4
  require_relative "debug"
5
+ require_relative "job_state"
5
6
  require_relative "subprocess"
6
7
 
7
8
  module ClaudeInbox
@@ -10,8 +11,11 @@ module ClaudeInbox
10
11
  class AgentsClient
11
12
  class Error < StandardError; end
12
13
 
13
- def initialize(bin: "claude")
14
+ attr_reader :jobs_dir
15
+
16
+ def initialize(bin: "claude", jobs_dir: JobState::DEFAULT_DIR)
14
17
  @bin = bin
18
+ @jobs_dir = jobs_dir
15
19
  end
16
20
 
17
21
  # => Array<Session>
@@ -42,7 +46,7 @@ module ClaudeInbox
42
46
  # and, the moment it becomes the agents view, terminate it. The user then
43
47
  # lands back in the inbox instead of native agent view.
44
48
  def attach(id)
45
- pid = Process.spawn(@bin, "attach", id)
49
+ pid = Process.spawn(*Subprocess.command(@bin, "attach", id))
46
50
  watchdog = Thread.new { kill_when_agents_view(pid) }
47
51
  _, status = Process.wait2(pid)
48
52
  status
@@ -60,10 +64,7 @@ module ClaudeInbox
60
64
 
61
65
  # Start a background session. Returns its short id.
62
66
  def spawn(prompt:, cwd:, **opts)
63
- argv = self.class.spawn_args(@bin, prompt: prompt, **opts)
64
- r = Subprocess.capture(*argv, chdir: cwd)
65
- raise Error, "claude --bg failed: #{(r.err + r.out).strip}" unless r.success?
66
- r.out[/\b[0-9a-f]{8}\b/] || r.out.strip
67
+ start_bg(self.class.spawn_args(@bin, prompt: prompt, **opts), cwd, "claude --bg")
67
68
  end
68
69
 
69
70
  # How a prompt attaches a file: the same @ mention the CLI's own prompt
@@ -71,45 +72,44 @@ module ClaudeInbox
71
72
  # its path completion escapes them; a quoted path is not recognized.
72
73
  def self.mention(path) = "@" + path.gsub(" ", "\\ ")
73
74
 
74
- # Pure so it can be tested: "default" means leave the flag off.
75
- def self.spawn_args(bin, prompt:, model: nil, effort: nil, permission_mode: nil, worktree: false, name: nil)
76
- argv = [bin, "--bg", prompt]
77
- argv += ["--model", model] if model && model != "default"
78
- argv += ["--effort", effort] if effort && effort != "default"
79
- argv += ["--permission-mode", permission_mode] if permission_mode && permission_mode != "default"
75
+ # Pure so it can be tested: a setting left nil leaves its flag off. The
76
+ # prompt goes after "--", where a leading dash is text rather than an
77
+ # unknown option, and --remote-control sits just before it: its optional
78
+ # name would otherwise eat the prompt (both checked on 2.1.282).
79
+ def self.spawn_args(bin, prompt:, model: nil, effort: nil, permission_mode: nil, worktree: false, name: nil, remote: false)
80
+ argv = [bin, "--bg"]
81
+ {"--model" => model, "--effort" => effort, "--permission-mode" => permission_mode}.each do |flag, value|
82
+ argv += [flag, value] if value
83
+ end
80
84
  argv += ["--name", name] if name && !name.strip.empty?
81
85
  argv << "--worktree" if worktree
82
- argv
86
+ argv << "--remote-control" if remote
87
+ argv + ["--", prompt]
83
88
  end
84
89
 
85
- def parse(json)
86
- JSON.parse(json).map { |h| Session.from_hash(h) }
90
+ # Pull a conversation a `claude remote-control` server is serving into
91
+ # the daemon, as a background session under the same id. Returns the
92
+ # short id. The worker goes first: while it is open, a resume only makes
93
+ # a copy. The server marks the session failed and leaves it be; the
94
+ # resumed session gets a bridge of its own.
95
+ def adopt(session_id:, cwd:, pid:)
96
+ raise Error, "nothing to adopt yet — send it a message from your phone first" if transcript_empty?(cwd, session_id)
97
+ Process.kill("TERM", pid)
98
+ wait_gone(pid)
99
+ start_bg(self.class.adopt_args(@bin, session_id), cwd, "claude --bg --resume")
87
100
  end
88
101
 
89
- # Flags that mark a claude process as one a program drives rather than one
90
- # you type in: the headless print mode and the SDK's stream protocol.
91
- HEADLESS_FLAGS = %w[-p --print --input-format --output-format].freeze
102
+ def self.adopt_args(bin, session_id) = [bin, "--bg", "--resume", session_id, "--remote-control"]
92
103
 
93
- # The JSON reports Remote Control workers, local sub-agents and headless
94
- # runs as `interactive`, same as a terminal you opened yourself, each named
95
- # after its directory. The process tree tells them apart; see `origins`.
96
- # Everything but a terminal and a remote worker is dropped here rather than
97
- # merely flagged: attach lands on the session that asked for it, so there
98
- # is nothing useful to show or act on directly.
99
- def classify_origins(sessions)
100
- pids = sessions.select { |s| s.interactive? && s.pid }.map(&:pid)
101
- return sessions if pids.empty?
102
- assign_origins(sessions, origins_by_pid(pids))
104
+ # Where the CLI keeps a directory's transcripts: every slash and dot in
105
+ # the path becomes a dash.
106
+ def self.transcript_path(cwd, session_id, home: Dir.home)
107
+ File.join(home, ".claude", "projects", cwd.gsub(%r{[/.]}, "-"), "#{session_id}.jsonl")
103
108
  end
104
109
 
105
- # Tags each interactive session with where it is driven from (pid =>
106
- # origin, terminal when unlisted) and drops the unattended ones, whose
107
- # parent is the row worth showing.
108
- def assign_origins(sessions, origins)
109
- sessions
110
- .map { |s| s.interactive? ? s.with(origin: origins.fetch(s.pid, :terminal)) : s }
111
- .reject(&:unattended?)
112
- end
110
+ # Flags that mark a claude process as one a program drives rather than one
111
+ # you type in: the headless print mode and the SDK's stream protocol.
112
+ HEADLESS_FLAGS = %w[-p --print --input-format --output-format].freeze
113
113
 
114
114
  # pid => origin, given `ps` for the sessions and for their parents. Pure.
115
115
  #
@@ -137,12 +137,63 @@ module ClaudeInbox
137
137
  # terminal often enough to matter; the false positive is accepted.
138
138
  def self.headless?(cmd) = cmd.split.drop(1).any? { |arg| HEADLESS_FLAGS.include?(arg) }
139
139
 
140
- def origins_by_pid(pids)
140
+ # pid => bridge session id, for the workers a `claude remote-control`
141
+ # server spawned: each is started with `--session-id cse_…`, the same id
142
+ # a background session records as bridgeSessionId. Pure.
143
+ def self.bridge_ids(rows)
144
+ rows.filter_map { |pid, _, cmd| (m = cmd.match(/--session-id[= ](cse_\w+)/)) && [pid, m[1]] }.to_h
145
+ end
146
+
147
+ private
148
+
149
+ # A worker nobody has messaged yet has an empty file, and the daemon
150
+ # reports "source session not found" on resuming it.
151
+ def transcript_empty?(cwd, session_id)
152
+ path = self.class.transcript_path(cwd, session_id)
153
+ File.exist?(path) && File.zero?(path)
154
+ end
155
+
156
+ def wait_gone(pid, timeout: 5)
157
+ deadline = Time.now + timeout
158
+ while Time.now < deadline
159
+ Process.kill(0, pid)
160
+ sleep 0.1
161
+ end
162
+ raise Error, "the worker #{pid} would not exit"
163
+ rescue Errno::ESRCH
164
+ nil
165
+ end
166
+
167
+ def parse(json)
168
+ JSON.parse(json).map { |h| Session.from_hash(h) }
169
+ end
170
+
171
+ # The JSON reports Remote Control workers, local sub-agents and headless
172
+ # runs as `interactive`, same as a terminal you opened yourself, each named
173
+ # after its directory. The process tree tells them apart; see `origins`.
174
+ # Everything but a terminal and a remote worker is dropped here rather than
175
+ # merely flagged: attach lands on the session that asked for it, so there
176
+ # is nothing useful to show or act on directly.
177
+ def classify_origins(sessions)
178
+ pids = sessions.select { |s| s.interactive? && s.pid }.map(&:pid)
179
+ return sessions if pids.empty?
141
180
  rows = ps_rows(pids)
181
+ assign_origins(sessions, origins_from(rows), self.class.bridge_ids(rows))
182
+ end
183
+
184
+ # Tags each interactive session with where it is driven from (pid =>
185
+ # origin, terminal when unlisted) and the bridge a remote one is served
186
+ # over, and drops the unattended ones, whose parent is the row worth
187
+ # showing.
188
+ def assign_origins(sessions, origins, bridges = {})
189
+ sessions
190
+ .map { |s| s.interactive? ? s.with(origin: origins.fetch(s.pid, :terminal), bridge_id: bridges[s.pid]) : s }
191
+ .reject(&:unattended?)
192
+ end
193
+
194
+ def origins_from(rows)
142
195
  return {} if rows.empty?
143
196
  self.class.origins(rows, ps_commands(rows.map { |_, ppid, _| ppid }.uniq))
144
- rescue Errno::ENOENT
145
- {}
146
197
  end
147
198
 
148
199
  def ps_rows(pids)
@@ -152,6 +203,8 @@ module ClaudeInbox
152
203
  pid, ppid, *cmd = l.split
153
204
  [pid.to_i, ppid.to_i, cmd.join(" ")]
154
205
  }
206
+ rescue Errno::ENOENT
207
+ []
155
208
  end
156
209
 
157
210
  # pid => command, for the given parent pids.
@@ -165,8 +218,6 @@ module ClaudeInbox
165
218
  }
166
219
  end
167
220
 
168
- private
169
-
170
221
  def kill_when_agents_view(pid)
171
222
  loop do
172
223
  sleep WATCH_INTERVAL
@@ -188,22 +239,29 @@ module ClaudeInbox
188
239
  raise Error, "#{argv[1]} failed: #{r.err.strip}" unless r.success?
189
240
  true
190
241
  end
242
+
243
+ def start_bg(argv, cwd, what)
244
+ r = Subprocess.capture(*argv, chdir: cwd)
245
+ raise Error, "#{what} failed: #{(r.err + r.out).strip}" unless r.success?
246
+ r.out[/\b[0-9a-f]{8}\b/] || r.out.strip
247
+ end
191
248
  end
192
249
 
193
250
  # Reads a committed JSON fixture instead of the daemon.
194
251
  class FixtureClient < AgentsClient
195
- def initialize(path, logs: nil, origins: {})
196
- super()
252
+ def initialize(path, logs: nil, origins: {}, bridges: {})
253
+ super(jobs_dir: File.join(File.dirname(path), "jobs"))
197
254
  @path = path
198
255
  @logs = logs
199
256
  @origins = origins
257
+ @bridges = bridges
200
258
  end
201
259
 
202
260
  # Fixture rows carry no process tree; tag each interactive row from the
203
261
  # pid => origin map the test hands in, otherwise as a terminal, then drop
204
262
  # the unattended ones same as the real client does.
205
263
  def list
206
- assign_origins(parse(File.read(@path)), @origins)
264
+ assign_origins(parse(File.read(@path)), @origins, @bridges)
207
265
  end
208
266
 
209
267
  def logs(_id) = @logs
@@ -219,6 +277,8 @@ module ClaudeInbox
219
277
  "deadbeef"
220
278
  end
221
279
 
280
+ def adopt(session_id:, cwd:, pid:) = "adop7ed0"
281
+
222
282
  def rm(_id) = true
223
283
  end
224
284
  end
@@ -11,32 +11,38 @@ 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"
20
23
 
21
24
  module ClaudeInbox
22
25
  # Owns the terminal and the key loop. The only class allowed to spawn a
23
26
  # child process that takes over the terminal.
24
27
  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.
28
- def initialize(client: AgentsClient.new, store: Store.new, pull_requests: PullRequests.new, jobs_dir: JobState::DEFAULT_DIR,
29
- rate_limits: RateLimits.new, reaper: Reaper.disabled, out: $stdout, input: $stdin, color: true)
28
+ # The reaper deletes sessions and the listener lets other machines in,
29
+ # so both are off unless `bin/claude-inbox` switches them on.
30
+ def initialize(client: AgentsClient.new, store: Store.new, pull_requests: PullRequests.new,
31
+ rate_limits: RateLimits.new, reaper: Reaper.disabled, listen: nil, out: $stdout, input: $stdin, color: true,
32
+ terminal: Terminal.new(out, input), queue: Queue.new)
30
33
  @client = client
31
34
  @store = store
32
35
  @rate_limits = rate_limits
33
- @terminal = Terminal.new(out, input)
36
+ @terminal = terminal
34
37
  @color = color
35
38
  @renderer = Renderer.new(color: color)
36
39
  @reader = TTY::Reader.new(input: input, output: out, interrupt: :noop)
37
- @queue = Queue.new
38
- @poller = Poller.new(client: client, store: store, pull_requests: pull_requests, jobs_dir: jobs_dir,
40
+ @queue = queue
41
+ @poller = Poller.new(client: client, store: store, pull_requests: pull_requests,
39
42
  reaper: reaper, queue: @queue)
43
+ @listener = listen ? Remote::Listener.new(client: client, store: store, queue: @queue, jobs_dir: client.jobs_dir, **listen) : Remote::Listener.disabled
44
+ @logs = Logs.new(client)
45
+ @peek = Peek.new(@logs)
40
46
  @selected = nil
41
47
  @row_items = []
42
48
  @list_width = nil
@@ -47,7 +53,6 @@ module ClaudeInbox
47
53
  @tick = 0
48
54
  @modal = nil
49
55
  @filter = nil
50
- @status = "starting…"
51
56
  @last_poll = nil
52
57
  @quit = false
53
58
  @resize = false
@@ -58,13 +63,25 @@ module ClaudeInbox
58
63
  install_traps
59
64
  @terminal.enter
60
65
  @poller.start
61
- @logs = Logs.new(@client)
62
- @peek = Peek.new(@logs)
66
+ @listener.start
67
+ @logs.start
63
68
  main_loop
64
69
  ensure
65
70
  @poller.stop
66
- @logs&.stop
71
+ @logs.stop
67
72
  @terminal.restore
73
+ @listener.stop
74
+ end
75
+
76
+ def step(input = nil)
77
+ drain_queue
78
+ @logs.tick
79
+ if @resize
80
+ @resize = false
81
+ @terminal.resized
82
+ end
83
+ render
84
+ handle_input(input) if input
68
85
  end
69
86
 
70
87
  private
@@ -99,6 +116,9 @@ module ClaudeInbox
99
116
  when :notice then notice(rest[0])
100
117
  when :select then @pending_select = rest[0]
101
118
  when :attach then attach(rest[0])
119
+ when :form_started then @modal = nil if @modal.equal?(rest[0])
120
+ when :form_failed then form_failed(*rest)
121
+ when :remote_started then remote_started(*rest)
102
122
  end
103
123
  end
104
124
  rescue ThreadError
@@ -108,17 +128,7 @@ module ClaudeInbox
108
128
  # ----- main loop --------------------------------------------------------
109
129
 
110
130
  def main_loop
111
- until @quit
112
- drain_queue
113
- @logs.tick
114
- if @resize
115
- @resize = false
116
- @terminal.resized
117
- end
118
- render
119
- key = @reader.read_keypress(echo: false, raw: false, nonblock: true)
120
- handle_input(key) if key
121
- end
131
+ step(@reader.read_keypress(echo: false, raw: false, nonblock: true)) until @quit
122
132
  end
123
133
 
124
134
  def handle_input(raw)
@@ -131,10 +141,13 @@ module ClaudeInbox
131
141
  end
132
142
 
133
143
  # The form takes a paste whole, images included; the one-line editors
134
- # take it as typing, so a pasted PR URL lands where it should.
144
+ # take it as typing, so a pasted PR URL lands where it should. Anywhere
145
+ # else a paste is dropped: typed out, its letters would be keys, and a
146
+ # "y" answers a confirm.
135
147
  def handle_paste(text)
136
148
  return @modal.paste(text) if @modal.is_a?(NewSessionForm)
137
- text.each_char { |c| handle_key(c) }
149
+ typing = @modal ? @modal.is_a?(Dialog::Prompt) : @filter_editing
150
+ text.each_char { |c| handle_key(c) } if typing
138
151
  end
139
152
 
140
153
  def render
@@ -142,18 +155,16 @@ module ClaudeInbox
142
155
  now = Time.now
143
156
  sections = filtered(@store.sections(now))
144
157
  width, height = @terminal.size
158
+ body_h = Renderer.body_height(height)
145
159
  ensure_selection(sections)
146
- peek = @peek.view(sections.row(@selected), height)
147
160
  @tick += 1
148
- frame = @renderer.frame(
149
- sections, width: width, height: height, now: now,
150
- selected: @selected&.key, top: @top, expanded: @expanded,
151
- peek: peek&.lines, peek_title: peek&.title, peek_subtitle: peek&.subtitle,
152
- modal: modal_lines(width), screen: screen_lines(width, height), status: status_text(now), usage: @rate_limits.label(now),
153
- filter: @filter, filter_editing: @filter_editing, tick: @tick / 2,
154
- loading: loading_for
161
+ view = Renderer::View.new(
162
+ width: width, height: height, now: now, selected: @selected&.key, top: @top, expanded: @expanded,
163
+ peek: @peek.view(sections.row(@selected), body_h), modal: modal_lines(width), screen: screen_lines(width, body_h),
164
+ status: status_text(now), usage: @rate_limits.windows(now), filter: @filter, filter_editing: @filter_editing,
165
+ tick: @tick / 2, loading: loading_for, listening: @listener.snapshot
155
166
  )
156
- @items = frame.items.compact
167
+ frame = @renderer.frame(sections, view)
157
168
  @row_items = frame.items
158
169
  @list_width = frame.list_width
159
170
  @top = frame.top
@@ -201,9 +212,10 @@ module ClaudeInbox
201
212
 
202
213
  # Guard for attach/stop: refuse politely on a terminal or remote row.
203
214
  def require_actionable
204
- return true if selected_session&.actionable?
205
- if (s = selected_session)&.interactive?
206
- notice(s.remote? ? "that's a remote session — open it at claude.ai/code" : "that's your own terminal — switch to that window")
215
+ s = selected_session
216
+ return true if s&.actionable?
217
+ if s&.interactive?
218
+ 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")
207
219
  end
208
220
  false
209
221
  end
@@ -211,8 +223,9 @@ module ClaudeInbox
211
223
  # Guard for snooze/wake/alias: anything with a key, since those live in
212
224
  # our own store. A terminal you are sitting in is the one exception.
213
225
  def require_storable
214
- return true if @selected&.row? && !selected_session&.terminal?
215
- notice("you're in that terminal right now — nothing to snooze") if selected_session&.terminal?
226
+ s = selected_session
227
+ return true if @selected&.row? && !s&.terminal?
228
+ notice("you're in that terminal right now — nothing to snooze") if s&.terminal?
216
229
  false
217
230
  end
218
231
 
@@ -301,21 +314,23 @@ module ClaudeInbox
301
314
  when :alias then open_alias_editor
302
315
  when :link_pr then open_pr_editor
303
316
  when :open_pr then open_pr
317
+ when :open_remote then open_remote
304
318
  when :stop then open_confirm(:stop)
305
319
  when :delete then open_confirm(:delete)
306
320
  when :refresh then @poller.soon
307
321
  when :toggle_peek then toggle_peek
308
322
  when :new_session then open_new_session
323
+ when :remote_pairing then @modal = @listener.pairing_dialog
309
324
  when :filter then start_filter
310
325
  when :escape then clear_filter
311
326
  end
312
327
  end
313
328
 
314
- def page = [@terminal.size[1] - 2, 1].max
329
+ def page = [Renderer.body_height(@terminal.size[1]), 1].max
315
330
 
316
331
  def move(delta)
317
- return if @items.nil? || @items.empty?
318
332
  stops = filtered.selections(@expanded)
333
+ return if stops.empty?
319
334
  idx = stops.index(@selected) || 0
320
335
  select(stops[(idx + delta).clamp(0, stops.size - 1)])
321
336
  end
@@ -336,7 +351,8 @@ module ClaudeInbox
336
351
  name if Store::FOLDABLE_SECTIONS.include?(name)
337
352
  end
338
353
 
339
- def set_expanded(value, name: current_fold_section)
354
+ def set_expanded(value)
355
+ name = current_fold_section
340
356
  @expanded[name] = value if name
341
357
  end
342
358
 
@@ -350,6 +366,7 @@ module ClaudeInbox
350
366
 
351
367
  def activate
352
368
  return @expanded[@selected.key] = true if @selected&.fold?
369
+ return @modal = Dialog::Confirm.new(:adopt, @selected.key) if selected_session&.remote?
353
370
  attach(@selected.key) if require_actionable
354
371
  end
355
372
 
@@ -404,25 +421,27 @@ module ClaudeInbox
404
421
  @modal = Dialog::Prompt.new(:pr, @selected.key, current)
405
422
  end
406
423
 
407
- # Hands the first PR to the OS browser opener.
408
424
  def open_pr
409
425
  pr = selected_session&.pr
410
426
  return notice("no pull request linked — P sets one") unless pr
427
+ open_in_browser(pr.url, pr.short)
428
+ end
429
+
430
+ def open_remote
431
+ url = selected_session&.remote_url
432
+ return notice("no claude.ai/code page for this session") unless url
433
+ open_in_browser(url, "claude.ai/code")
434
+ end
435
+
436
+ def open_in_browser(url, what)
411
437
  opener = RUBY_PLATFORM.include?("darwin") ? "open" : "xdg-open"
412
- notice("opening #{pr.short}")
413
- in_background { Subprocess.capture(opener, pr.url) }
438
+ notice("opening #{what}")
439
+ in_background { Subprocess.capture(opener, url) }
414
440
  end
415
441
 
416
442
  def open_new_session
417
443
  cwd = selected_session&.cwd || Dir.pwd
418
- @modal = NewSessionForm.new(cwd: strip_worktree(cwd), pastel: Pastel.new(enabled: @color))
419
- end
420
-
421
- # A session's cwd may sit inside a worktree another agent is using; carrying
422
- # that into a new prompt would spawn the new agent there too, writing over
423
- # the same files. Fall back to the repo the worktree was cut from.
424
- def strip_worktree(cwd)
425
- cwd.to_s.sub(%r{/\.claude/worktrees/[^/]+(?:/.*)?\z}, "")
444
+ @modal = NewSessionForm.new(cwd: SessionRequest.strip_worktree(cwd), pastel: Pastel.new(enabled: @color))
426
445
  end
427
446
 
428
447
  # `attach:` hands the terminal over as soon as the session starts. Without
@@ -434,15 +453,31 @@ module ClaudeInbox
434
453
  in_background do
435
454
  id = @client.spawn(**v)
436
455
  @queue << [:notice, "started #{id}"]
456
+ @queue << [:form_started, form]
437
457
  @queue << [:select, id]
438
458
  attach ? @queue << [:attach, id] : @poller.soon
459
+ rescue AgentsClient::Error => e
460
+ @queue << [:form_failed, form, e.message]
439
461
  end
440
462
  end
441
463
 
464
+ # Someone at the desk may be mid-thought, so a start from another
465
+ # device never moves the cursor, attaches or closes what is open.
466
+ def remote_started(id, via)
467
+ notice("started #{id} from #{via}")
468
+ @poller.soon
469
+ end
470
+
471
+ # A spawn failure hands the form back rather than just logging it, so
472
+ # the composed prompt survives (see NewSessionForm#submission_failed).
473
+ def form_failed(form, message)
474
+ @modal.equal?(form) ? form.submission_failed(message) : (@error = message)
475
+ end
476
+
442
477
  # The new-session form takes the whole body; a Dialog is a box over it.
443
- def screen_lines(width, height)
478
+ def screen_lines(width, body_h)
444
479
  return nil unless @modal.is_a?(NewSessionForm)
445
- {lines: @modal.screen(width, height - 2), footer: @modal.footer}
480
+ {lines: @modal.screen(width, body_h), footer: @modal.footer}
446
481
  end
447
482
 
448
483
  def modal_lines(width)
@@ -459,21 +494,26 @@ module ClaudeInbox
459
494
  when :confirm
460
495
  kind, id = @modal.kind, @modal.id
461
496
  @modal = nil
462
- (kind == :stop) ? stop_session(id) : delete_session(id)
497
+ case kind
498
+ when :stop then stop_session(id)
499
+ when :delete then delete_session(id)
500
+ when :adopt then adopt_session(id)
501
+ end
463
502
  when :save then save_prompt
503
+ when :copy then @listener.copy_pairing_url
504
+ when :rotate then @listener.rotate
464
505
  end
465
506
  end
466
507
 
508
+ # `:start`/`:start_and_attach` leave the form as `@modal`, now busy: it
509
+ # closes itself once the spawn is confirmed to have started (`:form_started`)
510
+ # or reopens with the failure and the prompt intact (`:form_failed`).
467
511
  def handle_form_key(name, key)
468
512
  form = @modal
469
513
  case form.press(name, key)
470
514
  when :cancel then @modal = nil
471
- when :start
472
- @modal = nil
473
- start_session(form, attach: false)
474
- when :start_and_attach
475
- @modal = nil
476
- start_session(form, attach: true)
515
+ when :start then start_session(form, attach: false)
516
+ when :start_and_attach then start_session(form, attach: true)
477
517
  end
478
518
  end
479
519
 
@@ -484,6 +524,18 @@ module ClaudeInbox
484
524
  end
485
525
  end
486
526
 
527
+ def adopt_session(key)
528
+ s = session_for(key)
529
+ return unless s&.remote?
530
+ notice("adopting #{s.display_name}…")
531
+ in_background do
532
+ id = @client.adopt(session_id: s.session_id, cwd: s.cwd, pid: s.pid)
533
+ @queue << [:notice, "adopted as #{id}"]
534
+ @queue << [:select, id]
535
+ @queue << [:attach, id]
536
+ end
537
+ end
538
+
487
539
  def delete_session(id)
488
540
  notice("deleting #{id}…")
489
541
  in_background do
@@ -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