claude-inbox 0.1.0 → 0.2.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.
@@ -2,7 +2,6 @@
2
2
 
3
3
  require_relative "reaper"
4
4
  require_relative "sessions"
5
- require_relative "store"
6
5
 
7
6
  module ClaudeInbox
8
7
  # Asks `claude agents` for the list off the main thread, every INTERVAL
@@ -15,11 +14,11 @@ module ClaudeInbox
15
14
  class Poller
16
15
  INTERVAL = 4
17
16
 
18
- def initialize(client:, store:, pull_requests:, jobs_dir:, reaper:, queue:, interval: INTERVAL)
17
+ def initialize(client:, store:, pull_requests:, reaper:, queue:, interval: INTERVAL, clock: -> { Time.now })
19
18
  @client = client
19
+ @clock = clock
20
20
  @store = store
21
21
  @pull_requests = pull_requests
22
- @jobs_dir = jobs_dir
23
22
  @reaper = reaper
24
23
  @queue = queue
25
24
  @interval = interval
@@ -55,8 +54,8 @@ module ClaudeInbox
55
54
  # dropped it. The gh refresh comes last and publishes again only if a PR
56
55
  # state moved.
57
56
  def once
58
- now = Time.now
59
- sessions = Sessions.load(client: @client, jobs_dir: @jobs_dir, pull_requests: @pull_requests, overrides: @store.pr_overrides)
57
+ now = @clock.call
58
+ sessions = Sessions.load(client: @client, pull_requests: @pull_requests, overrides: @store.pr_overrides)
60
59
  doomed = @reaper.due(sessions, now)
61
60
  @store.hide(doomed)
62
61
  @queue << [:sessions, sessions]
@@ -67,7 +66,7 @@ module ClaudeInbox
67
66
  @store.release(doomed - reaped)
68
67
  end
69
68
  @queue << [:sessions, sessions] if reaped != doomed
70
- notice_reaped(reaped) if reaped.any?
69
+ @queue << [:notice, @reaper.report(reaped)] if reaped.any?
71
70
  fresh, moved = @pull_requests.refresh(sessions)
72
71
  @queue << [:sessions, fresh] if moved
73
72
  rescue => e
@@ -89,10 +88,5 @@ module ClaudeInbox
89
88
  end
90
89
 
91
90
  def paused? = @lock.synchronize { @paused }
92
-
93
- def notice_reaped(keys)
94
- word = (keys.size == 1) ? "session" : "sessions"
95
- @queue << [:notice, "reaped #{keys.size} #{word} idle over #{Store::REAP_AFTER / 86_400}d — see #{@reaper.log_path}"]
96
- end
97
91
  end
98
92
  end
@@ -5,7 +5,7 @@ require "json"
5
5
  module ClaudeInbox
6
6
  # The subscription's 5-hour and 7-day usage, read from
7
7
  # ~/.claude/rate_limits.json. Nothing writes that file but the user's own
8
- # status line script (README, "Usage"): `claude agents --json` says nothing
8
+ # status line script (README, "Setup"): `claude agents --json` says nothing
9
9
  # about limits and there is no CLI command for them, but every session hands
10
10
  # its status line a `rate_limits` object on each turn, so a one-line tee
11
11
  # there is the only source that costs no API calls. No file, or a file
@@ -13,6 +13,14 @@ module ClaudeInbox
13
13
  class RateLimits
14
14
  DEFAULT_PATH = File.join(Dir.home, ".claude", "rate_limits.json")
15
15
  STALE_AFTER = 15 * 60
16
+ # Named as Claude's own /usage screen names them, not by length, so the
17
+ # label never reads like the countdown beside it.
18
+ LABELS = {"five_hour" => "session", "seven_day" => "week"}.freeze
19
+
20
+ # `resets_at` is when the window rolls over, or nil if the file omits it.
21
+ Window = Data.define(:label, :percent, :resets_at) do
22
+ def initialize(label:, percent:, resets_at: nil) = super
23
+ end
16
24
 
17
25
  def initialize(path: DEFAULT_PATH)
18
26
  @path = path
@@ -20,9 +28,11 @@ module ClaudeInbox
20
28
  @data = nil
21
29
  end
22
30
 
23
- # "usage 5h 23% · 7d 41%", or nil. Parses only when the file has changed,
24
- # since render asks several times a second.
25
- def label(now = Time.now)
31
+ # The windows the file reports, each with a whole percent and its reset
32
+ # time, or nil.
33
+ # Parses only when the file has changed, since render asks several times
34
+ # a second.
35
+ def windows(now = Time.now)
26
36
  mtime = File.mtime(@path)
27
37
  return nil if now - mtime > STALE_AFTER
28
38
  @data = parse if mtime != @mtime
@@ -36,11 +46,13 @@ module ClaudeInbox
36
46
 
37
47
  def parse
38
48
  hash = JSON.parse(File.read(@path))
39
- parts = {"five_hour" => "5h", "seven_day" => "7d"}.filter_map do |key, word|
49
+ windows = LABELS.filter_map do |key, label|
40
50
  pct = hash.dig(key, "used_percentage")
41
- "#{word} #{pct.round}%" if pct.is_a?(Numeric)
51
+ next unless pct.is_a?(Numeric)
52
+ resets_at = hash.dig(key, "resets_at")
53
+ Window.new(label, pct.round.clamp(0, 100), (Time.at(resets_at) if resets_at.is_a?(Numeric)))
42
54
  end
43
- "usage " + parts.join(" · ") unless parts.empty?
55
+ windows unless windows.empty?
44
56
  rescue JSON::ParserError, TypeError
45
57
  nil
46
58
  end
@@ -9,8 +9,8 @@ module ClaudeInbox
9
9
  #
10
10
  # The one thing in the inbox that destroys anything without being asked
11
11
  # first, so the whole of it lives here rather than spread through the poll
12
- # loop: one public method, and one append-only log that is the last record
13
- # a session ever existed once `claude rm` has taken its transcript.
12
+ # loop, along with the append-only log that is the last record a session
13
+ # ever existed once `claude rm` has taken its transcript.
14
14
  #
15
15
  # Unpushed work is safe by construction. `claude rm` refuses a worktree
16
16
  # holding commits that aren't pushed and reports a --discard-unpushed token
@@ -20,8 +20,6 @@ module ClaudeInbox
20
20
  RETRY_AFTER = 24 * 3600
21
21
  DEFAULT_LOG = File.join(Dir.home, ".config", "claude-inbox", "reaped.log")
22
22
 
23
- attr_reader :log_path
24
-
25
23
  def initialize(client, store, log_path: DEFAULT_LOG, enabled: self.class.enabled?)
26
24
  @client = client
27
25
  @store = store
@@ -43,27 +41,26 @@ module ClaudeInbox
43
41
  # Raises if the log cannot be opened, before anything is deleted. No
44
42
  # audit trail, no reaping.
45
43
  def sweep(sessions, now)
46
- return [] unless @enabled
47
44
  now_i = now.to_i
48
- due = sessions.filter_map { |s| row_if_due(s, now_i) }
49
- return [] if due.empty?
50
- with_log { |log| due.filter_map { |row| reap(row, log, now_i) } }
45
+ rows = due_rows(sessions, now_i)
46
+ return [] if rows.empty?
47
+ with_log { |log| rows.filter_map { |row| reap(row, log, now_i) } }
51
48
  end
52
49
 
53
50
  # Keys `sweep` would go after right now, without touching any of them,
54
51
  # so the poller can hand the list over minus these before `claude rm`.
55
- def due(sessions, now)
56
- return [] unless @enabled
57
- now_i = now.to_i
58
- sessions.filter_map { |s| row_if_due(s, now_i)&.key }
52
+ def due(sessions, now) = due_rows(sessions, now.to_i).map(&:key)
53
+
54
+ def report(keys)
55
+ word = (keys.size == 1) ? "session" : "sessions"
56
+ "reaped #{keys.size} #{word} idle over #{Store::REAP_AFTER / 86_400}d — see #{@log_path}"
59
57
  end
60
58
 
61
59
  private
62
60
 
63
- def row_if_due(session, now_i)
64
- row = @store.row(session)
65
- return nil unless row.reapable?(now_i)
66
- backing_off?(row, now_i) ? nil : row
61
+ def due_rows(sessions, now_i)
62
+ return [] unless @enabled
63
+ sessions.map { |s| @store.row(s) }.select { |row| row.reapable?(now_i) && !backing_off?(row, now_i) }
67
64
  end
68
65
 
69
66
  def backing_off?(row, now_i)
@@ -20,6 +20,13 @@ module ClaudeInbox
20
20
 
21
21
  Frame = Struct.new(:lines, :items, :top, :list_width)
22
22
 
23
+ # What App hands Renderer for one frame. `peek` is a Peek::View.
24
+ View = Data.define(:width, :height, :now, :selected, :top, :expanded, :peek, :modal, :screen,
25
+ :status, :usage, :filter, :filter_editing, :tick, :loading) do
26
+ def initialize(width:, height:, now:, selected: nil, top: 0, expanded: {}, peek: nil, modal: nil, screen: nil,
27
+ status: nil, usage: nil, filter: nil, filter_editing: false, tick: 0, loading: nil) = super
28
+ end
29
+
23
30
  SECTION_TITLES = {
24
31
  pinned: "PINNED",
25
32
  needs_you: "NEEDS YOU",
@@ -28,6 +35,8 @@ module ClaudeInbox
28
35
  settled: "SETTLED"
29
36
  }.freeze
30
37
 
38
+ SECTION_HUES = {pinned: :cyan, needs_you: :red, active: :yellow, snoozed: :purple}.freeze
39
+
31
40
  SPINNER = %w[⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏].freeze
32
41
 
33
42
  # The first poll usually lands inside a second. Past LOADING_QUIET the
@@ -62,41 +71,34 @@ module ClaudeInbox
62
71
 
63
72
  def caret = ->(cell) { @p.inverse(cell) }
64
73
 
65
- # opts: selected (id | :snoozed | :settled | nil), expanded ({snoozed:, settled:} => bool), top (scroll),
66
- # peek (Array<String> | nil), peek_title, modal (Array<String> | nil),
67
- # status (String | nil, a notice or error at the header's right end),
68
- # usage (String | nil, the rate-limit label after it), now (Time), filter (TextBuffer | nil), filter_editing,
69
- # tick (Integer, drives the spinner),
70
- # loading (Float seconds waited for the first poll, nil once it has landed),
71
- # screen ({lines:, footer:} takes over everything below the header)
72
- def frame(sections, width:, height:, now:, **opts)
73
- return full_screen(sections, width, height, now, opts) if opts[:screen]
74
- selected = opts[:selected]
75
- list_w = width_for_list(width, opts[:peek])
74
+ def frame(sections, view)
75
+ return full_screen(sections, view) if view.screen
76
+ width, height, now, selected = view.width, view.height, view.now, view.selected
77
+ list_w = width_for_list(width, view.peek)
76
78
  view_h = height - 2 # header + footer
77
79
  body, items =
78
- if opts[:loading]
79
- [loading_state(list_w, view_h, opts[:loading], opts[:tick].to_i), []]
80
+ if view.loading
81
+ [loading_state(list_w, view_h, view.loading, view.tick), []]
80
82
  else
81
- body_lines(sections, list_w, selected, opts, now)
83
+ body_lines(sections, list_w, view, now)
82
84
  end
83
85
 
84
- top = clamp_top(opts[:top] || 0, body.size, view_h, items, selected)
86
+ top = clamp_top(view.top, body.size, view_h, items, selected)
85
87
  visible = body[top, view_h] || []
86
88
  visible_items = items[top, view_h] || []
87
89
  visible += [""] * (view_h - visible.size)
88
90
  visible_items += [nil] * (view_h - visible_items.size)
89
91
 
90
- if opts[:peek]
92
+ if view.peek
91
93
  peek_w = width - list_w - 1
92
- peek_lines = peek_pane(opts[:peek], opts[:peek_title], opts[:peek_subtitle], peek_w, view_h)
94
+ peek_lines = peek_pane(view.peek, peek_w, view_h)
93
95
  visible = visible.each_with_index.map do |l, i|
94
96
  Text.pad(l, list_w) + @p.dim("│") + Text.pad(peek_lines[i] || "", peek_w)
95
97
  end
96
98
  end
97
99
 
98
- lines = [header(sections, width, opts, now)] + visible.map { |l| Text.pad(l, width) } + [footer(width, opts)]
99
- lines = overlay(lines, opts[:modal], width) if opts[:modal]
100
+ lines = [header(sections, width, view)] + visible.map { |l| Text.pad(l, width) } + [footer(width, view)]
101
+ lines = overlay(lines, view.modal, width) if view.modal
100
102
  Frame.new(lines, [nil] + visible_items + [nil], top, list_w)
101
103
  end
102
104
 
@@ -114,53 +116,79 @@ module ClaudeInbox
114
116
  top.clamp(0, [size - view_h, 0].max)
115
117
  end
116
118
 
117
- def full_screen(sections, width, height, now, opts)
118
- view_h = height - 2
119
- body = opts[:screen][:lines].first(view_h)
119
+ def full_screen(sections, view)
120
+ width = view.width
121
+ view_h = view.height - 2
122
+ body = view.screen[:lines].first(view_h)
120
123
  body += [""] * (view_h - body.size)
121
- lines = [header(sections, width, opts, now)] + body.map { |l| Text.pad(l, width) } + [Text.pad(" " + opts[:screen][:footer], width)]
122
- Frame.new(lines, [nil] * (view_h + 2), opts[:top] || 0, width)
124
+ lines = [header(sections, width, view)] + body.map { |l| Text.pad(l, width) } + [Text.pad(" " + view.screen[:footer], width)]
125
+ Frame.new(lines, [nil] * (view_h + 2), view.top, width)
123
126
  end
124
127
 
125
128
  # ----- chrome -------------------------------------------------------------
126
129
 
127
- def header(sections, width, opts, now)
130
+ def header(sections, width, view)
128
131
  brand = " " + @theme.cyan_bold("▌ claude-inbox")
129
- right = [opts[:status], opts[:usage]].compact.map { |s| @p.dim(s) }.join(@p.dim(" · "))
132
+ right = [view.status && @p.dim(view.status), view.usage && usage_meters(view.usage, view.now)].compact.join(@p.dim(" · "))
130
133
  right += " " unless right.empty?
131
134
  room = width - Text.width(brand) - Text.width(right) - 3
132
- chips = opts[:loading] ? "" : header_chips(sections, compact: false)
135
+ chips = view.loading ? "" : header_chips(sections, compact: false)
133
136
  chips = header_chips(sections, compact: true) if Text.width(chips) > room
134
137
  chips = "" if Text.width(chips) > room
135
138
  Text.pad(brand + " " + chips, width - Text.width(right)) + right
136
139
  end
137
140
 
141
+ # "session ██░░░░░░░░ 24% · 3h left" per window, drawn and colored the way
142
+ # the context bar in the user's status line is, so the two read alike.
143
+ def usage_meters(windows, now)
144
+ windows.map do |w|
145
+ filled = w.percent / 10
146
+ bar = "█" * filled + "░" * (10 - filled)
147
+ left = w.resets_at && w.resets_at.to_i - now.to_i
148
+ figure = left&.positive? ? "#{w.percent}% · #{Text.age(left)} left" : "#{w.percent}%"
149
+ "#{@p.dim(w.label)} #{@theme.public_send(usage_hue(w.percent), bar)} #{@p.dim(figure)}"
150
+ end.join(" ")
151
+ end
152
+
153
+ def usage_hue(percent)
154
+ if percent >= 90 then :red
155
+ elsif percent >= 70 then :yellow
156
+ else :green
157
+ end
158
+ end
159
+
138
160
  def header_chips(sections, compact:)
139
- pn = sections.pinned.size
140
- n = sections.needs_you.size
141
- w = sections.active.count { |r| r.session.effective_state == "working" && !r.session.waiting_on_work? }
142
- q = sections.active.count { |r| r.session.waiting_on_work? }
143
- i = sections.all.count { |r| r.session.terminal? }
144
- m = sections.all.count { |r| r.session.remote? }
145
- z = sections.snoozed.size
146
- d = sections.settled.size
147
- chips = []
148
- chips << @theme.cyan_bold(compact ? "★ #{pn}" : "★ #{pn} pinned") if pn > 0
149
- chips << @theme.red_bold(compact ? "● #{n}" : "● #{n} need#{"s" if n == 1} you") if n > 0
150
- chips << @theme.yellow(compact ? "✻ #{w}" : "✻ #{w} working") if w > 0
151
- chips << @theme.yellow(compact ? "◌ #{q}" : "◌ #{q} idle") if q > 0
152
- chips << @p.dim(compact ? "○ #{i}" : "○ #{i} terminal#{"s" if i > 1}") if i > 0
153
- chips << @theme.blue(compact ? "⇅ #{m}" : "⇅ #{m} remote") if m > 0
154
- chips << @theme.purple(compact ? "z #{z}" : "z #{z} snoozed") if z > 0
155
- chips << @p.dim(compact ? "◦ #{d}" : "◦ #{d} settled") if d > 0
161
+ pinned = sections.pinned.size
162
+ needing = sections.needs_you.size
163
+ working = sections.active.count { |r| r.session.effective_state == "working" && !r.session.waiting_on_work? }
164
+ idle = sections.active.count { |r| r.session.waiting_on_work? }
165
+ terminals = sections.all.count { |r| r.session.terminal? }
166
+ remotes = sections.all.count { |r| r.session.remote? }
167
+ snoozed = sections.snoozed.size
168
+ settled = sections.settled.size
169
+ chips = [
170
+ header_chip(pinned, "★", "pinned", compact) { |s| @theme.cyan_bold(s) },
171
+ header_chip(needing, "●", "need#{"s" if needing == 1} you", compact) { |s| @theme.red_bold(s) },
172
+ header_chip(working, "✻", "working", compact) { |s| @theme.yellow(s) },
173
+ header_chip(idle, "◌", "idle", compact) { |s| @theme.yellow(s) },
174
+ header_chip(terminals, "○", "terminal#{"s" if terminals > 1}", compact) { |s| @p.dim(s) },
175
+ header_chip(remotes, "⇅", "remote", compact) { |s| @theme.blue(s) },
176
+ header_chip(snoozed, "z", "snoozed", compact) { |s| @theme.purple(s) },
177
+ header_chip(settled, "◦", "settled", compact) { |s| @p.dim(s) }
178
+ ].compact
156
179
  chips << @p.dim("nothing running") if sections.all.empty?
157
180
  chips.join(compact ? " " : @p.dim(" · "))
158
181
  end
159
182
 
160
- def footer(width, opts)
183
+ def header_chip(count, glyph, words, compact)
184
+ return unless count > 0
185
+ yield(compact ? "#{glyph} #{count}" : "#{glyph} #{count} #{words}")
186
+ end
187
+
188
+ def footer(width, view)
161
189
  text =
162
- if opts[:filter_editing] then " " + @theme.cyan_bold("/") + line(opts[:filter], width - 2)
163
- elsif opts[:filter] then " " + @theme.cyan_bold("/") + opts[:filter].to_s + @p.dim(" esc clears")
190
+ if view.filter_editing then " " + @theme.cyan_bold("/") + line(view.filter, width - 2)
191
+ elsif view.filter then " " + @theme.cyan_bold("/") + view.filter.to_s + @p.dim(" esc clears")
164
192
  else " " + KEYS.map { |k, d| @theme.cyan_bold(k) + " " + @p.dim(d) }.join(" ")
165
193
  end
166
194
  Text.pad(text, width)
@@ -172,40 +200,34 @@ module ClaudeInbox
172
200
  title = " #{SECTION_TITLES[name]} "
173
201
  count_s = " #{count} "
174
202
  fill = [width - 3 - Text.width(title) - Text.width(count_s), 0].max
175
- color = section_color(name)
176
- Text.pad(" " + color.call("▎") + color.call(@p.bold(title)) + @p.dim("─" * fill) + @p.dim(count_s), width)
203
+ Text.pad(" " + section_color(name, "▎") + section_color(name, @p.bold(title)) + @p.dim("─" * fill) + @p.dim(count_s), width)
177
204
  end
178
205
 
179
- def section_color(name)
180
- case name
181
- when :pinned then ->(s) { @theme.cyan(s) }
182
- when :needs_you then ->(s) { @theme.red(s) }
183
- when :active then ->(s) { @theme.yellow(s) }
184
- when :snoozed then ->(s) { @theme.purple(s) }
185
- else ->(s) { @p.dim(s) }
186
- end
206
+ def section_color(name, s)
207
+ hue = SECTION_HUES[name]
208
+ hue ? @theme.public_send(hue, s) : @p.dim(s)
187
209
  end
188
210
 
189
211
  # ----- body ---------------------------------------------------------------
190
212
 
191
- def body_lines(sections, width, selected, opts, now)
213
+ def body_lines(sections, width, view, now)
192
214
  lines = []
193
215
  items = []
194
216
  if sections.all.empty?
195
217
  return [empty_state(width), []]
196
218
  end
197
- expanded = opts[:expanded] || {}
219
+ selected = view.selected
198
220
  sections.each_section do |name, rows|
199
221
  next if rows.empty?
200
222
  lines << "" << section_title(name, rows.size, width)
201
223
  items << nil << nil
202
- if Store.folded?(name, expanded)
224
+ if Store.folded?(name, view.expanded)
203
225
  lines << fold_toggle_line(name, rows.size, selected, width)
204
226
  items << Item.new(:fold_toggle, nil, name)
205
227
  next
206
228
  end
207
229
  rows.each do |row|
208
- row_lines(row, name, selected, width, now, opts[:tick].to_i).each_with_index do |l, i|
230
+ row_lines(row, name, selected, width, now, view.tick).each_with_index do |l, i|
209
231
  lines << l
210
232
  items << ((i.zero? && row.selectable?) ? Item.new(:row, row, name) : nil)
211
233
  end
@@ -336,7 +358,8 @@ module ClaudeInbox
336
358
  state_badge(s) + @p.dim(" · #{where} · #{age}")
337
359
  else
338
360
  age = row.state_since ? @p.dim(" · " + Text.age(now.to_i - row.state_since.to_i)) : ""
339
- state_badge(s) + age
361
+ remote = s.remote_control? ? @theme.blue(" ⇅") : ""
362
+ state_badge(s) + remote + age
340
363
  end
341
364
  end
342
365
  pr = pr_badge(s, section)
@@ -387,12 +410,12 @@ module ClaudeInbox
387
410
 
388
411
  # ----- peek ---------------------------------------------------------------
389
412
 
390
- def peek_pane(lines, title, subtitle, width, height)
391
- bar = Text.pad(" " + (title || ""), width)
413
+ def peek_pane(peek, width, height)
414
+ bar = Text.pad(" " + (peek.title || ""), width)
392
415
  out = [@p.inverse(bar)]
393
- out << Text.pad(" " + @p.dim(subtitle.to_s), width) if subtitle
416
+ out << Text.pad(" " + @p.dim(peek.subtitle.to_s), width) if peek.subtitle
394
417
  body_h = height - out.size
395
- wrapped = lines.flat_map { |l| Text.wrap(l, width - 1) }
418
+ wrapped = peek.lines.flat_map { |l| Text.wrap(l, width - 1) }
396
419
  wrapped.last(body_h).each { |l| out << Text.pad(" " + l, width) }
397
420
  out
398
421
  end
@@ -427,11 +450,10 @@ module ClaudeInbox
427
450
  @prev = []
428
451
  end
429
452
 
430
- def paint(lines, force: false)
453
+ def paint(lines)
431
454
  buf = +""
432
- buf << @cursor.clear_screen if force
433
455
  lines.each_with_index do |line, i|
434
- next if !force && @prev[i] == line
456
+ next if @prev[i] == line
435
457
  buf << @cursor.move_to(0, i) << line
436
458
  end
437
459
  if @prev.size > lines.size
@@ -10,14 +10,14 @@ module ClaudeInbox
10
10
  # move under it.
11
11
  Session = Data.define(
12
12
  :id, :cwd, :kind, :started_at, :session_id, :name,
13
- :state, :pid, :status, :waiting_for, :origin, :prs, :job_state
13
+ :state, :pid, :status, :waiting_for, :origin, :prs, :job_state, :bridge_id
14
14
  ) do
15
15
  # Every member is optional so the parser and the specs name only what they
16
16
  # have; prs is [] rather than nil so nobody asks whether PullRequests has run.
17
17
  def initialize(id: nil, cwd: nil, kind: nil, started_at: nil, session_id: nil, name: nil,
18
- state: nil, pid: nil, status: nil, waiting_for: nil, origin: nil, prs: nil, job_state: nil)
18
+ state: nil, pid: nil, status: nil, waiting_for: nil, origin: nil, prs: nil, job_state: nil, bridge_id: nil)
19
19
  super(id: id, cwd: cwd, kind: kind, started_at: started_at, session_id: session_id, name: name,
20
- state: state, pid: pid, status: status, waiting_for: waiting_for, origin: origin, prs: prs || [], job_state: job_state)
20
+ state: state, pid: pid, status: status, waiting_for: waiting_for, origin: origin, prs: prs || [], job_state: job_state, bridge_id: bridge_id)
21
21
  end
22
22
 
23
23
  def self.from_hash(h)
@@ -59,6 +59,17 @@ module ClaudeInbox
59
59
 
60
60
  def terminal? = interactive? && !remote? && !unattended?
61
61
 
62
+ # Where claude.ai/code shows this session. Every background session
63
+ # registers a bridge and records it in the job file; a worker a `claude
64
+ # remote-control` server spawned carries it on its command line.
65
+ def remote_url
66
+ bridge = bridge_id || job_state&.bridge_id
67
+ bridge && "https://claude.ai/code/session_#{bridge.delete_prefix("cse_")}"
68
+ end
69
+
70
+ # Driven from claude.ai/code as well as from here, not only followed there.
71
+ def remote_control? = remote? || job_state&.remote_control? == true
72
+
62
73
  # Selection handle: short id for background sessions, the UUID otherwise.
63
74
  def key = id || session_id
64
75
 
@@ -69,6 +80,9 @@ module ClaudeInbox
69
80
  # From `/color`; interactive sessions have no job file, so never one.
70
81
  def color = job_state&.color
71
82
 
83
+ # The prompt this session was started with.
84
+ def intent = job_state&.intent
85
+
72
86
  # The session's own one-line account of where it is, the same line
73
87
  # `claude agents` prints under a row: what it needs while blocked, what
74
88
  # it produced once done, otherwise its status line. Nil without a job
@@ -10,8 +10,8 @@ module ClaudeInbox
10
10
  module Sessions
11
11
  module_function
12
12
 
13
- def load(client:, jobs_dir:, pull_requests:, overrides:)
14
- sessions = JobState.enrich(client.list, jobs_dir: jobs_dir)
13
+ def load(client:, pull_requests:, overrides:)
14
+ sessions = JobState.enrich(client.list, jobs_dir: client.jobs_dir)
15
15
  pull_requests.enrich(sessions, overrides)
16
16
  end
17
17
  end
@@ -26,8 +26,8 @@ module ClaudeInbox
26
26
  def list(cwd:, home: Dir.home)
27
27
  found = {}
28
28
  add = ->(cmd) { found[cmd.name] ||= cmd }
29
- local(File.join(cwd, ".claude"), "project").each(&add)
30
- local(File.join(home, ".claude"), "user").each(&add)
29
+ from_dir(File.join(cwd, ".claude"), "project").each(&add)
30
+ from_dir(File.join(home, ".claude"), "user").each(&add)
31
31
  plugins(home).each(&add)
32
32
  synced(home).each(&add)
33
33
  found.values.sort_by(&:name)
@@ -39,8 +39,9 @@ module ClaudeInbox
39
39
  starts + rest.select { |c| c.name.downcase.include?(q) }
40
40
  end
41
41
 
42
- def local(dir, source)
43
- skills(File.join(dir, "skills"), source) + commands(File.join(dir, "commands"), source)
42
+ def from_dir(dir, source, prefix: nil)
43
+ skills(File.join(dir, "skills"), source, prefix:) +
44
+ commands(File.join(dir, "commands"), source, prefix:)
44
45
  end
45
46
 
46
47
  def skills(dir, source, prefix: nil)
@@ -70,8 +71,7 @@ module ClaudeInbox
70
71
  (installed["plugins"] || {}).flat_map do |key, entries|
71
72
  plugin = key.split("@").first
72
73
  Array(entries).filter_map { |e| e["installPath"] if e.is_a?(Hash) }.uniq.flat_map do |root|
73
- skills(File.join(root, "skills"), "plugin", prefix: plugin) +
74
- commands(File.join(root, "commands"), "plugin", prefix: plugin)
74
+ from_dir(root, "plugin", prefix: plugin)
75
75
  end
76
76
  end
77
77
  end
@@ -27,9 +27,11 @@ module ClaudeInbox
27
27
 
28
28
  def reap_failed_at = entry&.reap_failed_at
29
29
 
30
+ # Names get shortened and forgotten, so the filter also reads what the
31
+ # session was asked to do and what it says it is doing now.
30
32
  def matches?(query)
31
33
  q = query.downcase
32
- label.downcase.include?(q) || session.cwd.to_s.downcase.include?(q)
34
+ [label, session.cwd, session.intent, session.summary].any? { |field| field.to_s.downcase.include?(q) }
33
35
  end
34
36
 
35
37
  # A session already blocked when you snoozed it stays snoozed; one that
@@ -89,10 +91,8 @@ module ClaudeInbox
89
91
  if session.terminal? then session.needs_you? ? :needs_you : :active
90
92
  elsif pinned? then :pinned
91
93
  elsif snoozed?(now) then :snoozed
92
- elsif !revived? && hand_settled? then :settled
93
94
  elsif !revived? && settled? then :settled # a resolved PR outranks Needs You
94
95
  elsif session.needs_you? then acknowledged? ? :active : :needs_you
95
- elsif session.finished? then :active
96
96
  else :active
97
97
  end
98
98
  end
@@ -35,10 +35,15 @@ module ClaudeInbox
35
35
  out
36
36
  end
37
37
 
38
+ def self.row_for(session, entries)
39
+ hash = session.key && entries[session.key]
40
+ Row.new(session: session, entry: hash && Entry.new(hash))
41
+ end
42
+
38
43
  def self.sectionize(sessions, entries, now)
39
44
  sec = Sections.new(pinned: [], needs_you: [], active: [], snoozed: [], settled: [])
40
45
  sessions.each do |s|
41
- row = Row.new(session: s, entry: s.key && entries[s.key] && Entry.new(entries[s.key]))
46
+ row = row_for(s, entries)
42
47
  sec[row.section(now)] << row
43
48
  end
44
49
  sec.pinned.sort_by! { |r| -(r.pinned_at || 0) }
@@ -108,7 +113,11 @@ module ClaudeInbox
108
113
  @mutex.synchronize { @entries.transform_values { |e| Entry.new(e).pr }.select { |_, url| url } }
109
114
  end
110
115
 
111
- def row(session) = Row.new(session: session, entry: session.key && entry(session.key)&.then { |e| Entry.new(e) })
116
+ # A copy, not the live entry: the reaper decides a deletion on this row
117
+ # while the UI thread may be editing that entry in place.
118
+ def row(session)
119
+ @mutex.synchronize { self.class.row_for(session, {session.key => @entries[session.key]&.dup}) }
120
+ end
112
121
 
113
122
  # The raw hash, for the specs; nothing in lib/ reads it.
114
123
  def entry(id) = @mutex.synchronize { @entries[id]&.dup }
@@ -37,8 +37,9 @@ module ClaudeInbox
37
37
  ready, = IO.select(readers)
38
38
  ready.each do |io|
39
39
  chunk = io.read_nonblock(65_536, exception: false)
40
- if chunk.nil? || chunk == :wait_readable
41
- next unless chunk.nil?
40
+ case chunk
41
+ when :wait_readable then next
42
+ when nil
42
43
  readers.delete(io)
43
44
  io.close
44
45
  else
@@ -61,13 +61,8 @@ module ClaudeInbox
61
61
  end
62
62
 
63
63
  # Greedy word wrap on display width. Words wider than `w` are split.
64
- def wrap(s, w) = segments(s, w).map(&:rstrip)
65
-
66
- # Word wrap that keeps every character, so joining the segments back
67
- # together returns the original string. An editor needs that: `wrap`
68
- # drops the space you just typed, and with it the cursor's place.
69
- def segments(s, w)
70
- return [s] if w <= 0 || width(s) <= w
64
+ def wrap(s, w)
65
+ return [s.rstrip] if w <= 0 || width(s) <= w
71
66
  lines = []
72
67
  line = +""
73
68
  s.split(/(?<= )/).each do |word|
@@ -76,13 +71,14 @@ module ClaudeInbox
76
71
  line = +""
77
72
  end
78
73
  while width(word) > w
79
- lines << take(word, w)
80
- word = word[take(word, w).size..]
74
+ head = take(word, w)
75
+ lines << head
76
+ word = word[head.size..]
81
77
  end
82
78
  line << word
83
79
  end
84
80
  lines << line unless line.empty?
85
- lines
81
+ lines.map(&:rstrip)
86
82
  end
87
83
 
88
84
  # "45s", "12m", "3h", "2d"
@@ -158,8 +158,9 @@ module ClaudeInbox
158
158
  rows
159
159
  end
160
160
 
161
- # Text.segments on cells: a chip is one cell however wide its label, so
162
- # the wrap has to measure cells rather than a joined string.
161
+ # Text.wrap on cells, keeping the trailing spaces the cursor may sit on:
162
+ # a chip is one cell however wide its label, so the wrap has to measure
163
+ # cells rather than a joined string.
163
164
  def segments(cells, width)
164
165
  return [cells] if width_of(cells) <= width
165
166
  words = cells.slice_when { |c, _| c == " " }.to_a