claude-inbox 0.1.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 (40) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +442 -0
  4. data/exe/claude-inbox +33 -0
  5. data/lib/claude_inbox/agents_client.rb +224 -0
  6. data/lib/claude_inbox/app.rb +533 -0
  7. data/lib/claude_inbox/debug.rb +16 -0
  8. data/lib/claude_inbox/dialog.rb +105 -0
  9. data/lib/claude_inbox/images.rb +58 -0
  10. data/lib/claude_inbox/job_state.rb +89 -0
  11. data/lib/claude_inbox/keymap.rb +71 -0
  12. data/lib/claude_inbox/logs.rb +66 -0
  13. data/lib/claude_inbox/mouse.rb +34 -0
  14. data/lib/claude_inbox/new_session_form.rb +363 -0
  15. data/lib/claude_inbox/palette.rb +40 -0
  16. data/lib/claude_inbox/paste.rb +42 -0
  17. data/lib/claude_inbox/peek.rb +81 -0
  18. data/lib/claude_inbox/poller.rb +98 -0
  19. data/lib/claude_inbox/pull_requests.rb +155 -0
  20. data/lib/claude_inbox/rate_limits.rb +48 -0
  21. data/lib/claude_inbox/reaper.rb +103 -0
  22. data/lib/claude_inbox/records.rb +28 -0
  23. data/lib/claude_inbox/renderer.rb +447 -0
  24. data/lib/claude_inbox/session.rb +100 -0
  25. data/lib/claude_inbox/sessions.rb +18 -0
  26. data/lib/claude_inbox/settings.rb +39 -0
  27. data/lib/claude_inbox/slash_commands.rb +116 -0
  28. data/lib/claude_inbox/store/entry.rb +144 -0
  29. data/lib/claude_inbox/store/row.rb +101 -0
  30. data/lib/claude_inbox/store/sections.rb +47 -0
  31. data/lib/claude_inbox/store/selection.rb +16 -0
  32. data/lib/claude_inbox/store.rb +156 -0
  33. data/lib/claude_inbox/subprocess.rb +53 -0
  34. data/lib/claude_inbox/terminal.rb +101 -0
  35. data/lib/claude_inbox/text.rb +98 -0
  36. data/lib/claude_inbox/text_buffer.rb +216 -0
  37. data/lib/claude_inbox/theme.rb +35 -0
  38. data/lib/claude_inbox/vt_screen.rb +138 -0
  39. data/lib/claude_inbox.rb +14 -0
  40. metadata +163 -0
@@ -0,0 +1,447 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pastel"
4
+ require "tty-cursor"
5
+ require_relative "text"
6
+ require_relative "palette"
7
+ require_relative "theme"
8
+ require_relative "store"
9
+
10
+ module ClaudeInbox
11
+ # sections -> Array<String> (one entry per terminal row, each exactly
12
+ # `width` columns wide) plus a parallel Array of selectable items.
13
+ # Pure: no terminal, no IO, no clock beyond the `now` it is handed.
14
+ class Renderer
15
+ Item = Struct.new(:kind, :row, :section) do
16
+ def selection = (kind == :fold_toggle) ? Store::Selection.fold(section) : Store::Selection.row(row.key)
17
+
18
+ def key = selection.key
19
+ end
20
+
21
+ Frame = Struct.new(:lines, :items, :top, :list_width)
22
+
23
+ SECTION_TITLES = {
24
+ pinned: "PINNED",
25
+ needs_you: "NEEDS YOU",
26
+ active: "ACTIVE",
27
+ snoozed: "SNOOZED",
28
+ settled: "SETTLED"
29
+ }.freeze
30
+
31
+ SPINNER = %w[⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏].freeze
32
+
33
+ # The first poll usually lands inside a second. Past LOADING_QUIET the
34
+ # wait is long enough to feel like a hang, so it gets some company; past
35
+ # LOADING_HINT_AFTER it is long enough to be one.
36
+ LOADING_QUIET = 1.5
37
+ LOADING_HINT_AFTER = 10
38
+ QUIPS = [
39
+ "asking the daemon nicely…",
40
+ "counting agents…",
41
+ "reticulating splines…",
42
+ "herding sessions…",
43
+ "checking behind the couch…",
44
+ "reading the process tree…",
45
+ "polishing the spinner…",
46
+ "still here…"
47
+ ].freeze
48
+
49
+ KEYS = [
50
+ ["j/k", "move"], ["⏎", "attach"], ["n", "new"], ["t", "pin"], ["s", "snooze"], ["u", "wake"],
51
+ ["a", "alias"], ["o", "PR"], ["x", "settle"], ["p", "peek"], ["⇥", "section"],
52
+ ["za", "fold"], ["/", "filter"], ["q", "quit"]
53
+ ].freeze
54
+
55
+ def initialize(color: true, min_left: 44, home: Dir.home)
56
+ @p = Pastel.new(enabled: color)
57
+ @theme = Theme.new(enabled: color)
58
+ @palette = Palette.new(enabled: color)
59
+ @min_left = min_left
60
+ @home = home
61
+ end
62
+
63
+ def caret = ->(cell) { @p.inverse(cell) }
64
+
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])
76
+ view_h = height - 2 # header + footer
77
+ body, items =
78
+ if opts[:loading]
79
+ [loading_state(list_w, view_h, opts[:loading], opts[:tick].to_i), []]
80
+ else
81
+ body_lines(sections, list_w, selected, opts, now)
82
+ end
83
+
84
+ top = clamp_top(opts[:top] || 0, body.size, view_h, items, selected)
85
+ visible = body[top, view_h] || []
86
+ visible_items = items[top, view_h] || []
87
+ visible += [""] * (view_h - visible.size)
88
+ visible_items += [nil] * (view_h - visible_items.size)
89
+
90
+ if opts[:peek]
91
+ peek_w = width - list_w - 1
92
+ peek_lines = peek_pane(opts[:peek], opts[:peek_title], opts[:peek_subtitle], peek_w, view_h)
93
+ visible = visible.each_with_index.map do |l, i|
94
+ Text.pad(l, list_w) + @p.dim("│") + Text.pad(peek_lines[i] || "", peek_w)
95
+ end
96
+ end
97
+
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
+ Frame.new(lines, [nil] + visible_items + [nil], top, list_w)
101
+ end
102
+
103
+ private
104
+
105
+ def width_for_list(width, peek)
106
+ return width unless peek
107
+ [(width * 0.4).floor, @min_left].max.clamp(0, width)
108
+ end
109
+
110
+ def clamp_top(top, size, view_h, items, selected)
111
+ idx = items.index { |item| item && item.key == selected }
112
+ top = idx - 2 if idx && idx - 2 < top # keep the section title in view
113
+ top = idx - view_h + 1 if idx && idx >= top + view_h
114
+ top.clamp(0, [size - view_h, 0].max)
115
+ end
116
+
117
+ def full_screen(sections, width, height, now, opts)
118
+ view_h = height - 2
119
+ body = opts[:screen][:lines].first(view_h)
120
+ 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)
123
+ end
124
+
125
+ # ----- chrome -------------------------------------------------------------
126
+
127
+ def header(sections, width, opts, now)
128
+ brand = " " + @theme.cyan_bold("▌ claude-inbox")
129
+ right = [opts[:status], opts[:usage]].compact.map { |s| @p.dim(s) }.join(@p.dim(" · "))
130
+ right += " " unless right.empty?
131
+ room = width - Text.width(brand) - Text.width(right) - 3
132
+ chips = opts[:loading] ? "" : header_chips(sections, compact: false)
133
+ chips = header_chips(sections, compact: true) if Text.width(chips) > room
134
+ chips = "" if Text.width(chips) > room
135
+ Text.pad(brand + " " + chips, width - Text.width(right)) + right
136
+ end
137
+
138
+ 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
156
+ chips << @p.dim("nothing running") if sections.all.empty?
157
+ chips.join(compact ? " " : @p.dim(" · "))
158
+ end
159
+
160
+ def footer(width, opts)
161
+ 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")
164
+ else " " + KEYS.map { |k, d| @theme.cyan_bold(k) + " " + @p.dim(d) }.join(" ")
165
+ end
166
+ Text.pad(text, width)
167
+ end
168
+
169
+ def line(buffer, width) = buffer.row(width, cursor: caret)
170
+
171
+ def section_title(name, count, width)
172
+ title = " #{SECTION_TITLES[name]} "
173
+ count_s = " #{count} "
174
+ 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)
177
+ end
178
+
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
187
+ end
188
+
189
+ # ----- body ---------------------------------------------------------------
190
+
191
+ def body_lines(sections, width, selected, opts, now)
192
+ lines = []
193
+ items = []
194
+ if sections.all.empty?
195
+ return [empty_state(width), []]
196
+ end
197
+ expanded = opts[:expanded] || {}
198
+ sections.each_section do |name, rows|
199
+ next if rows.empty?
200
+ lines << "" << section_title(name, rows.size, width)
201
+ items << nil << nil
202
+ if Store.folded?(name, expanded)
203
+ lines << fold_toggle_line(name, rows.size, selected, width)
204
+ items << Item.new(:fold_toggle, nil, name)
205
+ next
206
+ end
207
+ rows.each do |row|
208
+ row_lines(row, name, selected, width, now, opts[:tick].to_i).each_with_index do |l, i|
209
+ lines << l
210
+ items << ((i.zero? && row.selectable?) ? Item.new(:row, row, name) : nil)
211
+ end
212
+ end
213
+ end
214
+ [lines, items]
215
+ end
216
+
217
+ # Nothing to list yet and no way to know whether that means nothing is
218
+ # running, so neither the empty state nor the chips. A short wait gets
219
+ # a blank body; a long one gets a spinner, a dot pacing its tray and a
220
+ # rotating excuse, with the elapsed time so a hang looks like one.
221
+ def loading_state(width, height, waited, tick)
222
+ return [] if waited < LOADING_QUIET
223
+ block = [
224
+ centered(@theme.cyan_bold(SPINNER[tick % SPINNER.size]), width),
225
+ "",
226
+ centered(tray(tick), width),
227
+ "",
228
+ centered(QUIPS[(tick / 6) % QUIPS.size], width),
229
+ centered(@p.dim("waiting on claude agents · #{waited.floor}s"), width)
230
+ ]
231
+ if waited >= LOADING_HINT_AFTER
232
+ block << "" << centered(@p.dim("slow? ") + @theme.cyan("claude daemon status") + @p.dim(" says whether the daemon is up"), width)
233
+ end
234
+ [""] * [(height - block.size) / 2, 0].max + block
235
+ end
236
+
237
+ TRAY_SLOTS = 9
238
+
239
+ # A dot bouncing between the ends of a tray, one slot per tick.
240
+ def tray(tick)
241
+ span = TRAY_SLOTS - 1
242
+ i = tick % (span * 2)
243
+ pos = (i <= span) ? i : span * 2 - i
244
+ cells = Array.new(TRAY_SLOTS) { |j| (j == pos) ? @theme.cyan_bold("●") : @p.dim("·") }
245
+ @p.dim("▌") + " " + cells.join(" ") + " " + @p.dim("▐")
246
+ end
247
+
248
+ def centered(s, width)
249
+ left = [(width - Text.width(s)) / 2, 0].max
250
+ Text.pad(" " * left + s, width)
251
+ end
252
+
253
+ def empty_state(width)
254
+ [
255
+ "", "",
256
+ Text.pad(" " + @p.bold("Nothing running."), width),
257
+ Text.pad(" " + @p.dim("Start one from any terminal with ") + @theme.cyan("claude --bg \"task\""), width),
258
+ Text.pad(" " + @p.dim("or press ") + @theme.cyan("R") + @p.dim(" to poll again."), width)
259
+ ]
260
+ end
261
+
262
+ def fold_toggle_line(name, count, selected, width)
263
+ sel = selected == name
264
+ marker = sel ? @theme.cyan_bold("▶") : " "
265
+ text = @p.dim("… #{count} #{SECTION_TITLES[name].downcase}") + (sel ? @p.dim(" ⏎ or zo to expand") : "")
266
+ Text.pad(" #{marker} " + text, width)
267
+ end
268
+
269
+ def row_lines(row, section, selected, width, now, tick)
270
+ s = row.session
271
+ sel = row.selectable? && selected == row.key
272
+ marker = sel ? @theme.cyan_bold("▶") : " "
273
+ glyph = glyph_for(s, section, tick)
274
+ meta = meta_for(row, section, now)
275
+
276
+ # " " marker " " glyph " " label " " meta " " project " "
277
+ chrome = 1 + 1 + 1 + 1 + 1 + 2 + Text.width(meta) + 2 + 1
278
+ # Project is only cut once the label has given up all its space too,
279
+ # so the row can never exceed `width` and fall into Text.pad's blind
280
+ # tail-chop (which used to land mid-project-name with no ellipsis).
281
+ project_text = Text.truncate(s.project, [width - chrome, 0].max)
282
+ project = (section == :settled) ? @p.dim(project_text) : @theme.cyan(project_text)
283
+
284
+ label_w = [width - chrome - Text.width(project_text), 0].max
285
+ label = Text.truncate(row.label, label_w)
286
+ label = style_label(label, row, section, sel)
287
+ first = " #{marker} #{glyph} " + Text.pad(label, label_w) + " " + meta + " " + project + " "
288
+
289
+ return [Text.pad(first, width)] unless %i[pinned needs_you active].include?(section)
290
+
291
+ # The session's own line when it has one; the path is what is left to
292
+ # say about a terminal, which has no job file.
293
+ detail = @p.dim(" ↳ " + Text.truncate(s.summary || short_path(s.cwd), [width - 10, 0].max))
294
+ [Text.pad(first, width), Text.pad(detail, width)]
295
+ end
296
+
297
+ # The label is the one part of a row you own: `/color` tints it, and
298
+ # nothing else on the line. Glyph, badge and PR keep the state's colors,
299
+ # so no color you pick can make a blocked session stop looking blocked.
300
+ # Settled stays dim — the section is meant to be quiet.
301
+ def style_label(label, row, section, sel)
302
+ return @p.dim(label) if section == :settled
303
+ styled =
304
+ if row.alias_name then sel ? @p.bold.italic(label) : @p.italic(label)
305
+ elsif sel then @p.bold(label)
306
+ else label
307
+ end
308
+ @palette.paint(styled, row.session.color)
309
+ end
310
+
311
+ def glyph_for(s, section, tick)
312
+ return @theme.purple("z") if section == :snoozed
313
+ return @p.dim("◦") if section == :settled
314
+ case s.effective_state
315
+ when "blocked" then @theme.red_bold("●")
316
+ when "failed" then @theme.red_bold("✗")
317
+ when "working" then s.waiting_on_work? ? @theme.yellow("◌") : @theme.yellow(SPINNER[tick % SPINNER.size])
318
+ when "done" then @theme.green("✓")
319
+ when "stopped" then @p.dim("■")
320
+ else @p.dim("?")
321
+ end
322
+ end
323
+
324
+ def meta_for(row, section, now)
325
+ s = row.session
326
+ base =
327
+ case section
328
+ when :snoozed
329
+ row.parked? ? @theme.purple("parked") : @theme.purple("wakes in #{Text.age(row.wake_at.to_i - now.to_i)}")
330
+ when :settled
331
+ @p.dim("#{s.state} · #{Text.age(now.to_i - row.state_since.to_i)}")
332
+ else
333
+ if s.interactive?
334
+ where = s.remote? ? "remote" : "your terminal"
335
+ age = row.state_since ? Text.age(now.to_i - row.state_since.to_i) : Text.age(now - s.started_at)
336
+ state_badge(s) + @p.dim(" · #{where} · #{age}")
337
+ else
338
+ age = row.state_since ? @p.dim(" · " + Text.age(now.to_i - row.state_since.to_i)) : ""
339
+ state_badge(s) + age
340
+ end
341
+ end
342
+ pr = pr_badge(s, section)
343
+ pr ? base + @p.dim(" · ") + pr : base
344
+ end
345
+
346
+ # "#885 open" in GitHub's colors: green open, dim draft, purple merged,
347
+ # red closed. Only the first PR is shown; the peek subtitle lists them all.
348
+ def pr_badge(s, section)
349
+ pr = s.pr
350
+ return nil unless pr
351
+ return @p.dim("#{pr.short} #{pr.state&.downcase}".strip) if section == :settled
352
+ case pr.state
353
+ when "OPEN" then @theme.green("#{pr.short} open")
354
+ when "DRAFT" then @p.dim("#{pr.short} draft")
355
+ when "MERGED" then @theme.purple("#{pr.short} merged")
356
+ when "CLOSED" then @theme.red("#{pr.short} closed")
357
+ else @p.dim(pr.short)
358
+ end
359
+ end
360
+
361
+ def state_badge(s)
362
+ case s.effective_state
363
+ when "blocked"
364
+ detail = s.waiting_for ? ": #{s.waiting_for}" : ""
365
+ @theme.red_bold("needs you#{detail}")
366
+ when "failed" then @theme.red_bold("failed")
367
+ when "working" then working_badge(s)
368
+ when "done" then @theme.green("done") + ((s.alive? && !s.interactive?) ? @p.dim(" · #{s.status}") : "")
369
+ when "stopped" then @p.dim("stopped")
370
+ else @p.dim(s.state.to_s)
371
+ end
372
+ end
373
+
374
+ # "working" while the agent is thinking, "idle" once it has stopped and
375
+ # only the work it kicked off is still open — with that work named either
376
+ # way, since "working · 2 agents" is the answer to "working on what?".
377
+ def working_badge(s)
378
+ return @theme.yellow("waiting#{": #{s.waiting_for}" if s.waiting_for}") if s.status == "waiting"
379
+ label = s.job_state&.in_flight_label
380
+ @theme.yellow(s.waiting_on_work? ? "idle" : "working") + (label ? @p.dim(" · #{label}") : "")
381
+ end
382
+
383
+ def short_path(path)
384
+ return "" unless path
385
+ path.start_with?(@home) ? path.sub(@home, "~") : path
386
+ end
387
+
388
+ # ----- peek ---------------------------------------------------------------
389
+
390
+ def peek_pane(lines, title, subtitle, width, height)
391
+ bar = Text.pad(" " + (title || ""), width)
392
+ out = [@p.inverse(bar)]
393
+ out << Text.pad(" " + @p.dim(subtitle.to_s), width) if subtitle
394
+ body_h = height - out.size
395
+ wrapped = lines.flat_map { |l| Text.wrap(l, width - 1) }
396
+ wrapped.last(body_h).each { |l| out << Text.pad(" " + l, width) }
397
+ out
398
+ end
399
+
400
+ # ----- modal --------------------------------------------------------------
401
+
402
+ # Centre a block of lines over the frame.
403
+ def overlay(lines, block, width)
404
+ block_w = block.map { |l| Text.width(l) }.max || 0
405
+ left = [(width - block_w) / 2, 0].max
406
+ top = [(lines.size - block.size) / 2, 0].max
407
+ out = lines.dup
408
+ block.each_with_index do |bl, i|
409
+ y = top + i
410
+ next if y >= out.size
411
+ base = Text.strip_ansi(out[y])
412
+ prefix = Text.pad(Text.take(base, left), left)
413
+ suffix = Text.drop(base, left + block_w)
414
+ out[y] = Text.pad(@p.dim(prefix) + Text.pad(bl, block_w) + @p.dim(suffix), width)
415
+ end
416
+ out
417
+ end
418
+ end
419
+
420
+ # Diffs successive frames and writes only changed rows. No erase-to-end-of-
421
+ # line after a row: in the terminal's last column the cursor stays put
422
+ # (pending wrap), so EL would eat the glyph just drawn.
423
+ class Painter
424
+ def initialize(out, cursor: TTY::Cursor)
425
+ @out = out
426
+ @cursor = cursor
427
+ @prev = []
428
+ end
429
+
430
+ def paint(lines, force: false)
431
+ buf = +""
432
+ buf << @cursor.clear_screen if force
433
+ lines.each_with_index do |line, i|
434
+ next if !force && @prev[i] == line
435
+ buf << @cursor.move_to(0, i) << line
436
+ end
437
+ if @prev.size > lines.size
438
+ (lines.size...@prev.size).each { |i| buf << @cursor.move_to(0, i) << @cursor.clear_line }
439
+ end
440
+ @out.print buf unless buf.empty?
441
+ @out.flush
442
+ @prev = lines
443
+ end
444
+
445
+ def invalidate = @prev = []
446
+ end
447
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeInbox
4
+ # Interactive sessions report only `status`; folded into the background
5
+ # vocabulary, an idle terminal is a finished turn and a waiting one needs you.
6
+ INTERACTIVE_STATE = {"busy" => "working", "waiting" => "blocked", "idle" => "done"}.freeze
7
+
8
+ # One entry from `claude agents --json`. Immutable, and the enrichers hand
9
+ # back copies via `with`, so a list already given to another thread cannot
10
+ # move under it.
11
+ Session = Data.define(
12
+ :id, :cwd, :kind, :started_at, :session_id, :name,
13
+ :state, :pid, :status, :waiting_for, :origin, :prs, :job_state
14
+ ) do
15
+ # Every member is optional so the parser and the specs name only what they
16
+ # have; prs is [] rather than nil so nobody asks whether PullRequests has run.
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)
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)
21
+ end
22
+
23
+ def self.from_hash(h)
24
+ new(
25
+ id: h["id"],
26
+ cwd: h["cwd"],
27
+ kind: h["kind"],
28
+ started_at: h["startedAt"] && Time.at(h["startedAt"] / 1000.0),
29
+ session_id: h["sessionId"],
30
+ name: h["name"],
31
+ state: h["state"],
32
+ pid: h["pid"],
33
+ status: h["status"],
34
+ waiting_for: h["waitingFor"]
35
+ )
36
+ end
37
+
38
+ def background? = kind == "background"
39
+
40
+ def interactive? = kind == "interactive"
41
+
42
+ # Only background sessions carry an id, and every action needs one.
43
+ def actionable? = background? && !id.nil?
44
+
45
+ def effective_state = state || INTERACTIVE_STATE[status] || "done"
46
+
47
+ # The JSON does not say where an interactive session is driven from;
48
+ # AgentsClient reads it off the process tree: :terminal, :remote (a
49
+ # claude.ai/code worker), :subagent or :headless (`claude -p` or an SDK
50
+ # run); the last two are dropped before anyone downstream sees them.
51
+ def remote? = origin == :remote
52
+
53
+ def subagent? = origin == :subagent
54
+
55
+ def headless? = origin == :headless
56
+
57
+ # A remote worker is not unattended: a person drives it, just from claude.ai/code.
58
+ def unattended? = subagent? || headless?
59
+
60
+ def terminal? = interactive? && !remote? && !unattended?
61
+
62
+ # Selection handle: short id for background sessions, the UUID otherwise.
63
+ def key = id || session_id
64
+
65
+ # "working" from the daemon means either the agent is thinking or it has
66
+ # stopped and is waiting on work it started. JobState tells them apart.
67
+ def waiting_on_work? = effective_state == "working" && job_state&.waiting_on_work? == true
68
+
69
+ # From `/color`; interactive sessions have no job file, so never one.
70
+ def color = job_state&.color
71
+
72
+ # The session's own one-line account of where it is, the same line
73
+ # `claude agents` prints under a row: what it needs while blocked, what
74
+ # it produced once done, otherwise its status line. Nil without a job
75
+ # file, or before the session has said anything.
76
+ def summary
77
+ return nil unless job_state
78
+ line =
79
+ case effective_state
80
+ when "blocked" then job_state.needs || job_state.detail
81
+ when "done" then job_state.result || job_state.detail
82
+ else job_state.detail
83
+ end
84
+ line = line.to_s.gsub(/\s+/, " ").strip
85
+ line.empty? ? nil : line
86
+ end
87
+
88
+ def needs_you? = %w[blocked failed].include?(effective_state)
89
+
90
+ def finished? = %w[done stopped].include?(effective_state)
91
+
92
+ def alive? = !pid.nil?
93
+
94
+ def display_name = name || id || session_id || "(unnamed)"
95
+
96
+ def project = cwd ? File.basename(cwd) : ""
97
+
98
+ def pr = prs.first
99
+ end
100
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "job_state"
4
+ require_relative "pull_requests"
5
+
6
+ module ClaudeInbox
7
+ # Puts the list together. JobState goes before PullRequests because the PR
8
+ # links it wants are read off the job file. Nothing here asks gh: that is
9
+ # `PullRequests#refresh`, which the poller runs once this list has gone up.
10
+ module Sessions
11
+ module_function
12
+
13
+ def load(client:, jobs_dir:, pull_requests:, overrides:)
14
+ sessions = JobState.enrich(client.list, jobs_dir: jobs_dir)
15
+ pull_requests.enrich(sessions, overrides)
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module ClaudeInbox
6
+ # What `claude` will use when a flag is left off: read from the same
7
+ # settings files it reads, most specific wins. Values are strings, or
8
+ # nil when nothing sets them — then the CLI decides at launch.
9
+ module Settings
10
+ Defaults = Struct.new(:model, :effort, :permission_mode)
11
+
12
+ def self.defaults(cwd, home: Dir.home)
13
+ layers = [
14
+ File.join(home, ".claude", "settings.json"),
15
+ File.join(cwd, ".claude", "settings.json"),
16
+ File.join(cwd, ".claude", "settings.local.json")
17
+ ].map { |path| read(path) }
18
+ Defaults.new(
19
+ model: pick(layers, "model"),
20
+ effort: pick(layers, "effortLevel"),
21
+ permission_mode: pick(layers, "permissions", "defaultMode")
22
+ )
23
+ end
24
+
25
+ def self.read(path)
26
+ JSON.parse(File.read(path))
27
+ rescue Errno::ENOENT, Errno::EACCES, JSON::ParserError
28
+ {}
29
+ end
30
+
31
+ def self.pick(layers, *keys)
32
+ layers.reverse_each do |h|
33
+ v = h.dig(*keys)
34
+ return v.to_s if v.is_a?(String) || v.is_a?(Numeric)
35
+ end
36
+ nil
37
+ end
38
+ end
39
+ end