claude-inbox 0.4.0 → 0.6.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 (50) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +20 -9
  3. data/exe/claude-inbox +40 -5
  4. data/lib/claude_inbox/actions.rb +11 -8
  5. data/lib/claude_inbox/agents_client.rb +21 -15
  6. data/lib/claude_inbox/app.rb +77 -13
  7. data/lib/claude_inbox/dialog.rb +40 -0
  8. data/lib/claude_inbox/headless.rb +20 -4
  9. data/lib/claude_inbox/images.rb +1 -1
  10. data/lib/claude_inbox/job_state.rb +16 -2
  11. data/lib/claude_inbox/keymap.rb +1 -1
  12. data/lib/claude_inbox/logs.rb +0 -1
  13. data/lib/claude_inbox/mod.rb +36 -1
  14. data/lib/claude_inbox/mouse.rb +1 -1
  15. data/lib/claude_inbox/new_session_form.rb +59 -19
  16. data/lib/claude_inbox/painter.rb +1 -3
  17. data/lib/claude_inbox/palette.rb +1 -1
  18. data/lib/claude_inbox/paste.rb +1 -1
  19. data/lib/claude_inbox/peek.rb +5 -9
  20. data/lib/claude_inbox/poller.rb +5 -9
  21. data/lib/claude_inbox/pull_requests.rb +6 -6
  22. data/lib/claude_inbox/rate_limits.rb +1 -1
  23. data/lib/claude_inbox/reaper.rb +2 -4
  24. data/lib/claude_inbox/remote/http.rb +2 -2
  25. data/lib/claude_inbox/remote/listener.rb +2 -6
  26. data/lib/claude_inbox/remote/page.html +7 -4
  27. data/lib/claude_inbox/remote/pairing.rb +2 -1
  28. data/lib/claude_inbox/remote/start.rb +3 -8
  29. data/lib/claude_inbox/renderer.rb +5 -13
  30. data/lib/claude_inbox/session_request.rb +16 -4
  31. data/lib/claude_inbox/slash_commands.rb +13 -13
  32. data/lib/claude_inbox/snapshot.rb +8 -4
  33. data/lib/claude_inbox/store/entry.rb +4 -5
  34. data/lib/claude_inbox/store/row.rb +0 -2
  35. data/lib/claude_inbox/store/sections.rb +3 -3
  36. data/lib/claude_inbox/store.rb +1 -2
  37. data/lib/claude_inbox/switch.rb +1 -3
  38. data/lib/claude_inbox/terminal.rb +8 -0
  39. data/lib/claude_inbox/text_buffer.rb +4 -9
  40. data/lib/claude_inbox/theme.rb +1 -1
  41. data/lib/claude_inbox/updater.rb +163 -0
  42. data/lib/claude_inbox/writer_lock.rb +1 -8
  43. data/lib/claude_inbox.rb +1 -1
  44. data/mod/hooks/register.tsx +90 -45
  45. data/mod/tests/contract.test.tsx +2 -3
  46. data/mod/tests/pane.test.tsx +87 -8
  47. data/mod/tests/snapshot.fixture.ts +6 -8
  48. data/mod/types/index.d.ts +3 -3
  49. metadata +2 -2
  50. data/lib/claude_inbox/remote.rb +0 -10
@@ -4,7 +4,8 @@ require "json"
4
4
 
5
5
  module ClaudeInbox
6
6
  # What the daemon writes about a background session in
7
- # ~/.claude/jobs/<id>/state.json. The only reader of that file.
7
+ # ~/.claude/jobs/<id>/state.json. The only reader of that file, and the
8
+ # only writer: a new session's color.
8
9
  #
9
10
  # `claude agents --json` reports one word, `state`, and "working" covers two
10
11
  # different situations: the agent is thinking, or the agent is idle and
@@ -50,6 +51,19 @@ module ClaudeInbox
50
51
  nil
51
52
  end
52
53
 
54
+ # The job file is there by the time `claude --bg` returns, and the daemon
55
+ # updates it read-modify-write, so the color stays through work, stop and
56
+ # respawn (checked on 2.1.296). A color is cosmetic: failing to write one
57
+ # must not report a started session as failed.
58
+ # ponytail: a daemon write racing this one can drop the color; re-apply on poll if that shows up.
59
+ def self.write_color(id, color, jobs_dir:)
60
+ path = File.join(jobs_dir, id, "state.json")
61
+ File.write("#{path}.tmp", JSON.generate(JSON.parse(File.read(path)).merge("color" => color)))
62
+ File.rename("#{path}.tmp", path)
63
+ rescue JSON::ParserError, SystemCallError
64
+ nil
65
+ end
66
+
53
67
  attr_reader :pr_urls, :color, :intent, :bridge_id
54
68
 
55
69
  def initialize(hash)
@@ -59,7 +73,7 @@ module ClaudeInbox
59
73
  @tempo = hash["tempo"]
60
74
  @kinds = (hash["fan"] || []).filter_map { |f| f["kind"] }
61
75
  @tasks = (hash["inFlight"] || {})["tasks"].to_i
62
- @pr_urls = (hash["children"] || []).select { |c| c["kind"] == "pr" && c["href"] }.map { |c| c["href"] }
76
+ @pr_urls = (hash["children"] || []).filter_map { |c| c["href"] if c["kind"] == "pr" }
63
77
  @color = hash["color"]
64
78
  @intent = hash["intent"]
65
79
  @bridge_id = hash["bridgeSessionId"]
@@ -27,7 +27,7 @@ module ClaudeInbox
27
27
  "s" => :snooze, "u" => :wake, "a" => :alias, "x" => :settle, "X" => :stop,
28
28
  :ctrl_x => :delete,
29
29
  "o" => :open_pr, "P" => :link_pr, "t" => :toggle_pin, "w" => :open_remote,
30
- "R" => :refresh, "p" => :toggle_peek, "n" => :new_session, "N" => :remote_pairing,
30
+ "R" => :refresh, "U" => :update, "p" => :toggle_peek, "n" => :new_session, "N" => :remote_pairing,
31
31
  :tab => :next_section, :back_tab => :prev_section,
32
32
  "/" => :filter, :escape => :escape,
33
33
  "q" => :quit, :ctrl_c => :quit
@@ -30,7 +30,6 @@ module ClaudeInbox
30
30
  end
31
31
 
32
32
  def want(id)
33
- return if id.nil?
34
33
  @pending = id
35
34
  @pending_at = @clock.call
36
35
  end
@@ -11,6 +11,7 @@ module ClaudeInbox
11
11
  DIR = File.expand_path("../../mod", __dir__)
12
12
  LINK = File.join(Dir.home, ".config", "claude-inbox", "mod")
13
13
  NAME = "inbox-pane"
14
+ SETTINGS = File.join(Dir.home, ".claude", "settings.json")
14
15
  CHECKOUT = File.exist?(File.expand_path("../../.git", __dir__))
15
16
  # What the pane runs headless: a checkout's own, or the gem's from PATH.
16
17
  COMMAND = CHECKOUT ? File.expand_path("../../bin/claude-inbox", __dir__) : nil
@@ -38,7 +39,7 @@ module ClaudeInbox
38
39
  # drops the other folders holding this plugin, such as a worktree's, and
39
40
  # sets the command the pane runs to this copy's. Raises JSON::ParserError
40
41
  # rather than rewrite a settings file it cannot read.
41
- def self.install(settings: File.join(Dir.home, ".claude", "settings.json"), link: LINK, dir: DIR, force: false, command: COMMAND)
42
+ def self.install(settings: SETTINGS, link: LINK, dir: DIR, force: false, command: COMMAND)
42
43
  self.link(link: link, dir: dir, force: true)
43
44
  json = File.exist?(settings) ? JSON.parse(File.read(settings)) : {}
44
45
  env = json["env"] ||= {}
@@ -61,6 +62,40 @@ module ClaudeInbox
61
62
  installed
62
63
  end
63
64
 
65
+ # Whether any folder in CLAUDE_CODE_PLUGIN_DIRS holds the pane, a
66
+ # worktree's included. A settings file it cannot read counts as yes, since
67
+ # install would refuse to touch it.
68
+ def self.installed?(settings: SETTINGS)
69
+ json = File.exist?(settings) ? JSON.parse(File.read(settings)) : {}
70
+ json.dig("env", "CLAUDE_CODE_PLUGIN_DIRS").to_s.split(File::PATH_SEPARATOR).any? { |d| plugin?(d.strip) }
71
+ rescue JSON::ParserError, Errno::EACCES
72
+ true
73
+ end
74
+
75
+ # Asks once, on the first launch that finds the pane missing, whether to
76
+ # add it. Whatever the answer, a marker file keeps it from asking again.
77
+ class Offer
78
+ MARKER = File.join(Dir.home, ".config", "claude-inbox", "pane-offered")
79
+
80
+ def self.disabled = new(marker: nil)
81
+
82
+ def initialize(marker: MARKER, settings: SETTINGS, link: LINK)
83
+ @marker = marker
84
+ @settings = settings
85
+ @link = link
86
+ end
87
+
88
+ # True once: the first time it is asked while the pane is missing.
89
+ def take
90
+ return false if @marker.nil? || File.exist?(@marker) || Mod.installed?(settings: @settings)
91
+ FileUtils.mkdir_p(File.dirname(@marker))
92
+ FileUtils.touch(@marker)
93
+ true
94
+ end
95
+
96
+ def install = Mod.install(settings: @settings, link: @link)
97
+ end
98
+
64
99
  def self.plugin?(folder)
65
100
  JSON.parse(File.read(File.join(folder, ".claude-plugin", "plugin.json")))["name"] == NAME
66
101
  rescue Errno::ENOENT, Errno::ENOTDIR, Errno::EACCES, JSON::ParserError
@@ -14,7 +14,7 @@ module ClaudeInbox
14
14
  # "esc gg" glues onto one read (see App#split_keys), so this scans
15
15
  # rather than matching once.
16
16
  def self.events(raw)
17
- raw.to_s.scan(SEQUENCE).filter_map do |cb, col, row, type|
17
+ raw.scan(SEQUENCE).filter_map do |cb, col, row, type|
18
18
  kind = kind_for(cb.to_i, type)
19
19
  Event.new(kind, row.to_i, col.to_i) if kind
20
20
  end
@@ -1,7 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "pastel"
3
4
  require_relative "agents_client"
4
5
  require_relative "images"
6
+ require_relative "palette"
5
7
  require_relative "session_request"
6
8
  require_relative "settings"
7
9
  require_relative "slash_commands"
@@ -18,6 +20,12 @@ module ClaudeInbox
18
20
  # string on :choice ones — `kind` says which.
19
21
  Field = Struct.new(:key, :label, :kind, :value, :choices)
20
22
 
23
+ # A directory the Directory field offers, shown by its shortest
24
+ # distinct label as the phone's form shows it.
25
+ Place = Struct.new(:path, :label, :description) do
26
+ def to_s = label
27
+ end
28
+
21
29
  # The screen shows what "default" resolves to; `values` makes it nil.
22
30
  DEFAULT = "default"
23
31
 
@@ -27,20 +35,24 @@ module ClaudeInbox
27
35
  # editable field spends these on its own text instead.
28
36
  CYCLE = {:left => -1, :right => 1, "h" => -1, "l" => 1, " " => 1}.freeze
29
37
 
30
- def initialize(cwd:, pastel:, home: Dir.home, clipboard: Images.method(:from_clipboard))
31
- @p = pastel
32
- @theme = Theme.new(enabled: pastel.enabled)
38
+ def initialize(cwd:, color:, dirs: [], home: Dir.home, clipboard: Images.method(:from_clipboard))
39
+ @p = Pastel.new(enabled: color)
40
+ @theme = Theme.new(enabled: color)
41
+ @palette = Palette.new(enabled: color)
33
42
  @home = home
34
43
  @clipboard = clipboard
44
+ @places = dirs.map { |path| Place.new(path, SessionRequest.label(path, dirs), path.sub(/\A#{Regexp.escape(home)}(?=\/|\z)/, "~")) }
45
+ @dir_settled = cwd
35
46
  @fields = [
36
47
  Field.new(:prompt, "Prompt", :multiline, TextBuffer.new),
37
48
  Field.new(:name, "Name", :text, TextBuffer.new),
38
- Field.new(:cwd, "Directory", :text, TextBuffer.new(cwd.to_s)),
49
+ Field.new(:cwd, "Directory", :text, TextBuffer.new(cwd)),
39
50
  Field.new(:model, "Model", :choice, DEFAULT, AgentsClient::MODELS),
40
51
  Field.new(:effort, "Effort", :choice, DEFAULT, AgentsClient::EFFORTS),
41
52
  Field.new(:permission_mode, "Permissions", :choice, DEFAULT, AgentsClient::PERMISSION_MODES),
42
53
  Field.new(:worktree, "Worktree", :choice, "no", %w[no yes]),
43
- Field.new(:remote, "Remote Control", :choice, DEFAULT, [DEFAULT, "no", "yes"])
54
+ Field.new(:remote, "Remote Control", :choice, DEFAULT, [DEFAULT, "no", "yes"]),
55
+ Field.new(:color, "Color", :choice, DEFAULT, AgentsClient::COLORS)
44
56
  ]
45
57
  @focus = 0
46
58
  @error = nil
@@ -66,7 +78,7 @@ module ClaudeInbox
66
78
  @error = nil
67
79
  @candidates = nil
68
80
  return :changed if menu && menu_press(name)
69
- before = command_query
81
+ before = menu_query
70
82
  case name
71
83
  when :escape then return escape_pressed
72
84
  when :ctrl_s then return submit(attach: false)
@@ -80,7 +92,7 @@ module ClaudeInbox
80
92
  else
81
93
  editable? ? focused.value.press(name, raw) : choose(name, raw)
82
94
  end
83
- edited = before && command_query && command_query != before
95
+ edited = before && menu_query && menu_query != before
84
96
  @pick = 0 if edited
85
97
  @dismissed = nil if edited
86
98
  :changed
@@ -103,10 +115,12 @@ module ClaudeInbox
103
115
  :changed
104
116
  end
105
117
 
118
+ # Slash commands while one is being typed in the prompt; the recent and
119
+ # trusted directories once the Directory field is edited.
106
120
  def menu
107
- q = command_query
121
+ q = menu_query
108
122
  return nil if q.nil? || @dismissed == q
109
- found = SlashCommands.match(commands, q)
123
+ found = (focused.key == :cwd) ? places(q) : SlashCommands.match(commands, q)
110
124
  found.empty? ? nil : found
111
125
  end
112
126
 
@@ -144,9 +158,12 @@ module ClaudeInbox
144
158
  out << ""
145
159
  out << " " + field_label(field(:prompt)) + @p.dim(" ⏎ newline")
146
160
  out += prompt_box(field(:prompt), inner_w, prompt_h)
147
- out += menu_rows
161
+ out += menu_rows if focused.key == :prompt
148
162
  out << ""
149
- @fields[1..].each { |f| out << " " + field_label(f) + field_value(f, inner_w - 16) }
163
+ @fields[1..].each do |f|
164
+ out << " " + field_label(f) + field_value(f, inner_w - 16)
165
+ out += menu_rows if f.key == :cwd && focused.key == :cwd
166
+ end
150
167
  out.first(height) + [""] * [height - out.size, 0].max
151
168
  end
152
169
 
@@ -162,6 +179,7 @@ module ClaudeInbox
162
179
  when :choice then [["← → h l", "change"], ["⏎", "next"]]
163
180
  else [["⏎", "next"]]
164
181
  end
182
+ keys.unshift(["^U", "recent"]) if focused.key == :cwd && @places.any?
165
183
  keys += [["^S", "start"], ["^O", "start & open"],
166
184
  ["⇥", (focused.key == :cwd) ? "complete / next" : "next"], ["esc", "cancel"]]
167
185
  hints(keys)
@@ -179,9 +197,25 @@ module ClaudeInbox
179
197
 
180
198
  private
181
199
 
182
- def command_query
183
- return nil unless focused.key == :prompt
184
- focused.value.head[/(?:\A|\s)\/(\S*)\z/, 1]
200
+ def menu_query
201
+ case focused.key
202
+ when :prompt then focused.value.head[/(?:\A|\s)\/(\S*)\z/, 1]
203
+ when :cwd then focused.value.to_s unless focused.value.to_s == @dir_settled
204
+ end
205
+ end
206
+
207
+ def places(query)
208
+ q = query.downcase
209
+ found = @places.select { |d| d.path.downcase.include?(q) || d.description.downcase.include?(q) }
210
+ starts, rest = found.partition { |d| d.label.downcase.start_with?(q) }
211
+ starts + rest
212
+ end
213
+
214
+ # A path put in the field by a pick or a completion rather than typed,
215
+ # so the menu stays shut until the next edit.
216
+ def settle_dir(path)
217
+ field(:cwd).value.replace(path)
218
+ @dir_settled = path
185
219
  end
186
220
 
187
221
  # Project commands live under the Directory field's path, so they
@@ -228,14 +262,18 @@ module ClaudeInbox
228
262
  when :up, :ctrl_p then @pick = (@pick - 1) % menu.size
229
263
  when :down, :ctrl_n then @pick = (@pick + 1) % menu.size
230
264
  when :tab, :return, :enter then accept(picked)
231
- when :escape then @dismissed = command_query
265
+ when :escape then @dismissed = menu_query
232
266
  else return false
233
267
  end
234
268
  true
235
269
  end
236
270
 
237
- def accept(cmd)
238
- focused.value.replace_before(command_query.grapheme_clusters.size + 1, "#{cmd} ")
271
+ def accept(item)
272
+ if focused.key == :cwd
273
+ settle_dir(item.path)
274
+ else
275
+ focused.value.replace_before(menu_query.grapheme_clusters.size + 1, "#{item} ")
276
+ end
239
277
  @pick = 0
240
278
  end
241
279
 
@@ -285,6 +323,7 @@ module ClaudeInbox
285
323
  f.choices.map { |c|
286
324
  text = (c == DEFAULT) ? default_text(f) : c
287
325
  if c == f.value then (on ? @theme.pill(" #{text} ", :cyan) : @theme.cyan_bold(" #{text} "))
326
+ elsif f.key == :color then @palette.paint(" #{text} ", c)
288
327
  else @p.dim(" #{text} ")
289
328
  end
290
329
  }.join(" ")
@@ -292,6 +331,7 @@ module ClaudeInbox
292
331
  end
293
332
 
294
333
  def default_text(f)
334
+ return "none" if f.key == :color
295
335
  resolved = defaults[f.key]
296
336
  resolved ? "#{resolved} (settings)" : "auto (cli default)"
297
337
  end
@@ -327,10 +367,10 @@ module ClaudeInbox
327
367
  matches = Dir.glob(listing ? "#{base}/*" : "#{base}*").select { |d| File.directory?(d) }.sort
328
368
  return nil if matches.empty?
329
369
  if matches.size == 1
330
- focused.value.replace("#{matches.first}/")
370
+ settle_dir("#{matches.first}/")
331
371
  else
332
372
  @candidates = matches.map { |d| File.basename(d) }
333
- focused.value.replace(common_prefix(matches))
373
+ settle_dir(common_prefix(matches))
334
374
  end
335
375
  :changed
336
376
  end
@@ -18,9 +18,7 @@ module ClaudeInbox
18
18
  next if @prev[i] == line
19
19
  buf << TTY::Cursor.move_to(0, i) << line
20
20
  end
21
- if @prev.size > lines.size
22
- (lines.size...@prev.size).each { |i| buf << TTY::Cursor.move_to(0, i) << TTY::Cursor.clear_line }
23
- end
21
+ (lines.size...@prev.size).each { |i| buf << TTY::Cursor.move_to(0, i) << TTY::Cursor.clear_line }
24
22
  @out.print buf unless buf.empty?
25
23
  @out.flush
26
24
  @prev = lines
@@ -22,7 +22,7 @@ module ClaudeInbox
22
22
 
23
23
  RESET = "\e[39m"
24
24
 
25
- def initialize(enabled: true)
25
+ def initialize(enabled:)
26
26
  @enabled = enabled
27
27
  end
28
28
 
@@ -24,7 +24,7 @@ module ClaudeInbox
24
24
 
25
25
  def feed(raw)
26
26
  out = []
27
- rest = raw.to_s
27
+ rest = raw
28
28
  until rest.empty?
29
29
  if @open
30
30
  body, close, rest = rest.partition(CLOSE)
@@ -16,7 +16,6 @@ module ClaudeInbox
16
16
  @logs = logs
17
17
  @open = false
18
18
  @offset = 0
19
- @selected = nil
20
19
  @session = nil
21
20
  end
22
21
 
@@ -30,8 +29,7 @@ module ClaudeInbox
30
29
  def close = @open = false
31
30
 
32
31
  # Called on every selection change; a fold has no session.
33
- def select(selection, session)
34
- @selected = selection
32
+ def select(session)
35
33
  @session = session
36
34
  @offset = 0
37
35
  @logs.want(session.id) if session&.actionable?
@@ -42,17 +40,16 @@ module ClaudeInbox
42
40
  @offset = [@offset + delta, 0].max
43
41
  end
44
42
 
45
- # `row` is the selected row as the frame shows it, or nil when nothing is
46
- # selected. Answers nil when there is no pane to paint.
43
+ # `row` is the selected row as the frame shows it, or nil on a fold or
44
+ # with nothing selected. Answers nil when there is no pane to paint.
47
45
  def view(row, body_h)
48
- return nil unless @open && @selected&.row?
49
- View.new(scrolled(body(row), body_h), row&.label || @selected.key, subtitle(row))
46
+ return nil unless @open && row
47
+ View.new(scrolled(body(row), body_h), row.label, subtitle(row))
50
48
  end
51
49
 
52
50
  private
53
51
 
54
52
  def body(row)
55
- return ["(nothing selected)"] unless row
56
53
  return interactive_note(row.session) if row.session.interactive?
57
54
  @logs.cached(row.session.id) || ["(loading…)"]
58
55
  end
@@ -62,7 +59,6 @@ module ClaudeInbox
62
59
  end
63
60
 
64
61
  def subtitle(row)
65
- return nil unless row
66
62
  s = row.session
67
63
  parts = [s.effective_state, s.status, s.waiting_for, s.id, s.started_at&.strftime("started %b %-d %H:%M")].compact
68
64
  parts += s.prs.map { |pr| "#{pr.short} #{pr.state&.downcase || "?"}" }
@@ -1,13 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "actions"
4
3
  require_relative "sessions"
5
- require_relative "snapshot"
6
4
 
7
5
  module ClaudeInbox
8
6
  # Asks `claude agents` for the list off the main thread, every INTERVAL
9
7
  # seconds and on demand, puts what it finds in the store and the snapshot,
10
- # and tells App over the shared queue: [:sessions, list] for each
8
+ # and tells App over the shared queue: [:sessions] after each
11
9
  # hand-over, [:error, msg] when a poll fails, [:notice, text] when the
12
10
  # reaper took something. Between polls it applies what another front end
13
11
  # asked for. Nothing here touches App's state directly; the queue is the
@@ -17,7 +15,7 @@ module ClaudeInbox
17
15
  INTERVAL = 4
18
16
  ACTIONS_INTERVAL = 0.5
19
17
 
20
- def initialize(client:, store:, pull_requests:, reaper:, queue:, snapshot: Snapshot.disabled, actions: Actions.disabled,
18
+ def initialize(client:, store:, pull_requests:, reaper:, queue:, snapshot:, actions:,
21
19
  interval: INTERVAL, clock: -> { Time.now })
22
20
  @client = client
23
21
  @clock = clock
@@ -72,11 +70,9 @@ module ClaudeInbox
72
70
  @queue << [:error, e.message]
73
71
  end
74
72
 
75
- # Applies the requests another front end left, and says whether any landed.
73
+ # Applies the requests another front end left.
76
74
  def apply
77
- return false unless @actions.drain(@store) > 0
78
- write_snapshot
79
- true
75
+ write_snapshot if @actions.drain(@store) > 0
80
76
  end
81
77
 
82
78
  private
@@ -84,7 +80,7 @@ module ClaudeInbox
84
80
  def publish(sessions)
85
81
  @store.update(sessions)
86
82
  write_snapshot
87
- @queue << [:sessions, sessions]
83
+ @queue << [:sessions]
88
84
  end
89
85
 
90
86
  def write_snapshot
@@ -10,7 +10,7 @@ module ClaudeInbox
10
10
  # DRAFT, the same as Claude Code's own cache: OPEN, DRAFT, MERGED, CLOSED,
11
11
  # or nil when nothing has told us yet. `resolved_at` is when it was merged
12
12
  # or closed, in epoch seconds, once gh has said.
13
- PullRequest = Struct.new(:number, :url, :state, :title, :resolved_at) do
13
+ PullRequest = Struct.new(:number, :url, :state, :resolved_at) do
14
14
  def short = number ? "##{number}" : url.to_s.sub(%r{\Ahttps?://(www\.)?}, "")
15
15
 
16
16
  def merged? = state == "MERGED"
@@ -76,7 +76,7 @@ module ClaudeInbox
76
76
  # one (interactive, or forgotten) has none; Sessions.load sees to the
77
77
  # order. `overrides` maps session key => url for links set by hand; an
78
78
  # override replaces the scanned list.
79
- def enrich(sessions, overrides = {})
79
+ def enrich(sessions, overrides)
80
80
  sessions.map do |s|
81
81
  urls = overrides[s.key] ? [overrides[s.key]] : (s.job_state&.pr_urls || [])
82
82
  s.with(prs: urls.map { |u| known(u) })
@@ -115,7 +115,7 @@ module ClaudeInbox
115
115
  h = JSON.parse(json)
116
116
  state = (h["state"] == "OPEN" && h["isDraft"]) ? "DRAFT" : h["state"]
117
117
  resolved_at = h["closedAt"] && Time.iso8601(h["closedAt"]).to_i
118
- PullRequest.new(number: h["number"], url: h["url"] || url, state: state, title: h["title"], resolved_at: resolved_at)
118
+ PullRequest.new(number: h["number"], url: h["url"] || url, state: state, resolved_at: resolved_at)
119
119
  rescue JSON::ParserError, ArgumentError
120
120
  nil
121
121
  end
@@ -140,7 +140,7 @@ module ClaudeInbox
140
140
  number = url[%r{/pull/(\d+)}, 1]&.to_i
141
141
  cached = resolved[url] || claude_cache[url]
142
142
  PullRequest.new(number: cached&.dig("number") || number, url: url, state: cached&.dig("state"),
143
- title: cached&.dig("title"), resolved_at: cached&.dig("resolved_at"))
143
+ resolved_at: cached&.dig("resolved_at"))
144
144
  end
145
145
 
146
146
  # Same shape as Claude Code's cache, so `seed` reads both alike.
@@ -154,13 +154,13 @@ module ClaudeInbox
154
154
 
155
155
  # Under @mutex.
156
156
  def remember(pr)
157
- resolved[pr.url] = {"number" => pr.number, "state" => pr.state, "title" => pr.title, "resolved_at" => pr.resolved_at}
157
+ resolved[pr.url] = {"number" => pr.number, "state" => pr.state, "resolved_at" => pr.resolved_at}
158
158
  return unless @resolved_path
159
159
  Records.save(@resolved_path, resolved)
160
160
  end
161
161
 
162
162
  def fetch(url)
163
- r = Subprocess.capture(@gh, "pr", "view", url, "--json", "number,state,isDraft,title,url,closedAt")
163
+ r = Subprocess.capture(@gh, "pr", "view", url, "--json", "number,state,isDraft,url,closedAt")
164
164
  r.success? ? self.class.parse(url, r.out) : nil
165
165
  rescue SystemCallError
166
166
  nil
@@ -30,7 +30,7 @@ module ClaudeInbox
30
30
  # time, or nil.
31
31
  # Parses only when the file has changed, since render asks several times
32
32
  # a second.
33
- def windows(now = Time.now)
33
+ def windows(now)
34
34
  mtime = File.mtime(@path)
35
35
  return nil if now - mtime > STALE_AFTER
36
36
  @data = parse if mtime != @mtime
@@ -20,15 +20,13 @@ module ClaudeInbox
20
20
  RETRY_AFTER = 24 * 3600
21
21
  DEFAULT_LOG = File.join(Dir.home, ".config", "claude-inbox", "reaped.log")
22
22
 
23
- def initialize(client, store, log_path: DEFAULT_LOG, enabled: self.class.enabled?)
23
+ def initialize(client, store, log_path: DEFAULT_LOG, enabled: true)
24
24
  @client = client
25
25
  @store = store
26
26
  @log_path = log_path
27
27
  @enabled = enabled
28
28
  end
29
29
 
30
- def self.enabled? = ENV["CLAUDE_INBOX_NO_REAP"].to_s.empty?
31
-
32
30
  # For --fixture runs and tests: selects nothing, deletes nothing, and
33
31
  # needs neither a client nor a store to do it.
34
32
  def self.disabled = new(nil, nil, enabled: false)
@@ -75,7 +73,7 @@ module ClaudeInbox
75
73
  row.key
76
74
  rescue AgentsClient::Error => e
77
75
  reason = e.message.lines.first.to_s.strip
78
- @store.mark_reap_failed(row.key, reason)
76
+ @store.mark_reap_failed(row.key)
79
77
  write(log, row, now_i, "kept — #{reason}")
80
78
  nil
81
79
  end
@@ -12,7 +12,7 @@ module ClaudeInbox
12
12
  TOKEN = /\A[!#$%&'*+\-.^_`|~0-9A-Za-z]+\z/
13
13
 
14
14
  REASONS = {
15
- 100 => "Continue", 200 => "OK", 201 => "Created", 400 => "Bad Request", 401 => "Unauthorized",
15
+ 200 => "OK", 201 => "Created", 400 => "Bad Request", 401 => "Unauthorized",
16
16
  403 => "Forbidden", 404 => "Not Found", 405 => "Method Not Allowed", 409 => "Conflict",
17
17
  411 => "Length Required", 413 => "Content Too Large", 415 => "Unsupported Media Type",
18
18
  421 => "Misdirected Request", 422 => "Unprocessable Content", 500 => "Internal Server Error",
@@ -107,7 +107,7 @@ module ClaudeInbox
107
107
  end
108
108
  end
109
109
 
110
- def self.write(io, status, headers = {}, body = "")
110
+ def self.write(io, status, headers, body)
111
111
  body = body.b
112
112
  head = ["HTTP/1.1 #{status} #{REASONS.fetch(status)}"]
113
113
  HEADERS.merge(headers, "Content-Length" => body.bytesize.to_s).each { |name, value| head << "#{name}: #{value}" }
@@ -17,7 +17,7 @@ module ClaudeInbox
17
17
  # connection limits, the static files and the Pairing token, in front of
18
18
  # Start. Like the Poller it reaches App only through the queue, and it
19
19
  # never writes to the terminal: every failure becomes a response or a
20
- # notice.
20
+ # notice. It is App's only way into Remote.
21
21
  class Listener
22
22
  DEFAULT_PORT = 7433
23
23
  DEFAULT_MODES = %w[default auto plan].freeze
@@ -58,17 +58,13 @@ module ClaudeInbox
58
58
  # lock, as `held_by`) or :failed (for good, as `error` says). `urls` and
59
59
  # `firewall` are nil until `refresh`.
60
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
61
  # Over a VPN only an address reaches the Mac: multicast DNS, which
64
62
  # the .local name needs, stays on the LAN.
65
63
  def pairing_url = (lan && urls&.[](1)) || urls&.first
66
64
  end
67
65
 
68
66
  # `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
67
+ Outcome = Data.define(:at, :via, :result, :count)
72
68
 
73
69
  # App's `listen:` from the flags, else the environment; nil when neither
74
70
  # asks for it. --listen-lan wins over --listen, whichever came last.
@@ -124,6 +124,9 @@ code { font: 15px ui-monospace, SFMono-Regular, Menlo, monospace; }
124
124
  <label for="permission_mode">Permissions</label>
125
125
  <select id="permission_mode"></select>
126
126
  <p class="error" id="error-permission_mode"></p>
127
+ <label for="color">Color</label>
128
+ <select id="color"></select>
129
+ <p class="error" id="error-color"></p>
127
130
 
128
131
  <div class="toggles">
129
132
  <label class="toggle">Worktree <input type="checkbox" id="worktree" switch></label>
@@ -152,7 +155,7 @@ code { font: 15px ui-monospace, SFMono-Regular, Menlo, monospace; }
152
155
  const MAX_IMAGES = 8;
153
156
  const MAX_BODY = 16777216;
154
157
  const LONG_EDGE = 2000;
155
- const FIELDS = ["prompt", "name", "cwd", "model", "effort", "permission_mode", "images"];
158
+ const FIELDS = ["prompt", "name", "cwd", "model", "effort", "permission_mode", "color", "images"];
156
159
  const NO_ANSWER = "no answer from the inbox. Is it open with --listen-lan, and is the Mac awake?";
157
160
  const REJECTED = "token rejected: press N in the inbox and pair again";
158
161
  const TOKEN = /^[A-Za-z0-9_-]{32,}$/;
@@ -223,9 +226,9 @@ function fill() {
223
226
  $("cwd").replaceChildren(...dirs.map((d) => new Option(d.label, d.path)), new Option("another directory…", ""));
224
227
  $("cwd").value = typed || !dirs.length ? "" : last || dirs[0].path;
225
228
  if (typed) $("cwd-other").value = last;
226
- for (const [id, list] of [["model", options.models], ["effort", options.efforts], ["permission_mode", options.permission_modes]]) {
229
+ for (const [id, list] of [["model", options.models], ["effort", options.efforts], ["permission_mode", options.permission_modes], ["color", options.colors]]) {
227
230
  const picked = $(id).value;
228
- $(id).replaceChildren(...(list || []).map((v) => new Option(v, v)));
231
+ $(id).replaceChildren(...(list || []).map((v) => new Option(id === "color" && v === "default" ? "none" : v, v)));
229
232
  if ((list || []).includes(picked)) $(id).value = picked;
230
233
  }
231
234
  relabel();
@@ -380,7 +383,7 @@ async function start() {
380
383
  const fields = {
381
384
  prompt: $("prompt").value, name: $("name").value, cwd: cwd(),
382
385
  model: $("model").value, effort: $("effort").value, permission_mode: $("permission_mode").value,
383
- worktree: $("worktree").checked, remote: remote
386
+ color: $("color").value, worktree: $("worktree").checked, remote: remote
384
387
  };
385
388
  const body = JSON.stringify({...fields, images: images.map((image) => ({data: image.url.slice(image.url.indexOf(",") + 1)}))});
386
389
  const size = new Blob([body]).size;
@@ -28,7 +28,8 @@ module ClaudeInbox
28
28
  # Ethernet and Wi-Fi (en*) first: the first private address is the one
29
29
  # `c` copies, and a VM's bridge is no use to a phone.
30
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)
31
+ en, rest = interfaces.select { |i| i.addr&.ip? }.partition { |i| i.name.start_with?("en") }
32
+ (en + rest).map(&:addr)
32
33
  end
33
34
 
34
35
  # :off, :on or :block_all; nil without macOS's application firewall.
@@ -26,7 +26,7 @@ module ClaudeInbox
26
26
  BODY_TIMEOUT = 120
27
27
  KEYS_KEPT = 16
28
28
 
29
- def initialize(client:, store:, queue:, allowed_modes:, fixture: false, images_dir: Images::DEFAULT_DIR,
29
+ def initialize(client:, store:, queue:, allowed_modes:, fixture:, images_dir: Images::DEFAULT_DIR,
30
30
  trust: Trust.method(:projects), settings: Settings.method(:defaults), bridge_wait: 3)
31
31
  @client = client
32
32
  @store = store
@@ -58,17 +58,12 @@ module ClaudeInbox
58
58
  models: AgentsClient::MODELS,
59
59
  efforts: AgentsClient::EFFORTS,
60
60
  permission_modes: AgentsClient::PERMISSION_MODES & @allowed_modes,
61
+ colors: AgentsClient::COLORS,
61
62
  dirs: paths.map { |path| {path: path, label: SessionRequest.label(path, paths), defaults: @settings.call(path).to_h} }
62
63
  }
63
64
  end
64
65
 
65
- # Where sessions ran lately, newest first, then every directory whose
66
- # trust dialog was accepted: the likely choices first. Only an order;
67
- # the CLI still decides where a session may start.
68
- def dir_paths
69
- recent = @store.sessions.sort_by { |s| -s.started_at.to_i }.filter_map { |s| s.cwd && SessionRequest.strip_worktree(s.cwd) }
70
- (recent + @trust.call).uniq.select { |dir| File.directory?(dir) }
71
- end
66
+ def dir_paths = SessionRequest.directories(@store.sessions, @trust.call)
72
67
 
73
68
  # Checked cheapest first, and nothing is written to disk until every
74
69
  # check has passed. A retry with the same Idempotency-Key gets the