claude-inbox 0.14.3 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +17 -10
  3. data/lib/claude_inbox/actions.rb +1 -1
  4. data/lib/claude_inbox/app/attaching.rb +25 -17
  5. data/lib/claude_inbox/app/navigating.rb +22 -9
  6. data/lib/claude_inbox/app/spawning.rb +3 -3
  7. data/lib/claude_inbox/app/triaging.rb +15 -0
  8. data/lib/claude_inbox/app.rb +11 -6
  9. data/lib/claude_inbox/claude/agents_client.rb +353 -0
  10. data/lib/claude_inbox/claude/job_state.rb +126 -0
  11. data/lib/claude_inbox/claude/roster.rb +34 -0
  12. data/lib/claude_inbox/claude/transcript.rb +113 -0
  13. data/lib/claude_inbox/cli.rb +17 -11
  14. data/lib/claude_inbox/codex/daemon.rb +102 -0
  15. data/lib/claude_inbox/codex/relay.rb +148 -0
  16. data/lib/claude_inbox/codex/threads_client.rb +181 -0
  17. data/lib/claude_inbox/codex/web_socket.rb +123 -0
  18. data/lib/claude_inbox/dialog.rb +12 -10
  19. data/lib/claude_inbox/harnesses.rb +40 -0
  20. data/lib/claude_inbox/headless.rb +3 -2
  21. data/lib/claude_inbox/images.rb +9 -7
  22. data/lib/claude_inbox/input.rb +12 -4
  23. data/lib/claude_inbox/keymap.rb +3 -10
  24. data/lib/claude_inbox/mod.rb +1 -1
  25. data/lib/claude_inbox/mouse.rb +13 -6
  26. data/lib/claude_inbox/new_session_form.rb +50 -17
  27. data/lib/claude_inbox/notifier.rb +1 -1
  28. data/lib/claude_inbox/palette.rb +4 -8
  29. data/lib/claude_inbox/paste.rb +2 -6
  30. data/lib/claude_inbox/poller.rb +33 -18
  31. data/lib/claude_inbox/pull_requests.rb +63 -15
  32. data/lib/claude_inbox/reaper.rb +3 -3
  33. data/lib/claude_inbox/records.rb +2 -5
  34. data/lib/claude_inbox/remote/listener.rb +3 -3
  35. data/lib/claude_inbox/remote/pairing_dialog.rb +6 -6
  36. data/lib/claude_inbox/remote/start.rb +8 -9
  37. data/lib/claude_inbox/renderer.rb +53 -35
  38. data/lib/claude_inbox/session.rb +37 -67
  39. data/lib/claude_inbox/session_request.rb +7 -7
  40. data/lib/claude_inbox/snapshot.rb +2 -2
  41. data/lib/claude_inbox/store/entry.rb +6 -1
  42. data/lib/claude_inbox/store/row.rb +13 -8
  43. data/lib/claude_inbox/store/sections.rb +1 -1
  44. data/lib/claude_inbox/store.rb +19 -18
  45. data/lib/claude_inbox/subprocess.rb +3 -2
  46. data/lib/claude_inbox/text.rb +1 -3
  47. data/lib/claude_inbox/theme.rb +1 -3
  48. data/lib/claude_inbox/titler.rb +62 -0
  49. data/lib/claude_inbox/updater.rb +1 -2
  50. data/lib/claude_inbox/vt_screen.rb +1 -2
  51. data/lib/claude_inbox.rb +2 -1
  52. data/mod/hooks/register.tsx +8 -7
  53. data/mod/tests/pane.test.tsx +25 -0
  54. data/mod/tests/snapshot.fixture.ts +4 -0
  55. data/mod/types/index.d.ts +2 -2
  56. metadata +11 -5
  57. data/lib/claude_inbox/agents_client.rb +0 -268
  58. data/lib/claude_inbox/job_state.rb +0 -130
  59. data/lib/claude_inbox/sessions.rb +0 -19
  60. data/lib/claude_inbox/transcript.rb +0 -76
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 20f415de1f5b57baa5330a96c70bb10545c1b1885e419f4174e9a938c0d3c59f
4
- data.tar.gz: 1a989c77cbb0553c63ace2b341c95a988eb1028e103c09c173bc28d14023cd14
3
+ metadata.gz: ab2c702ba985e433dfdef1a421fc112a430338f30e82bc0ccd60c7f3a44bc810
4
+ data.tar.gz: 4d89ccc91011ca3e6230ab936bd6e0e799566384942734dc47f940b9db615c4d
5
5
  SHA512:
6
- metadata.gz: 251df40e737ea85313ceefc9d8dc401f61fed6af73f9d26b0b2767737c66dec57e1fd02fbc46bd057384b17475b3e76dd2de669d77c4ab534df3a2bfe1e6b67c
7
- data.tar.gz: c3368aa2027a9947e09e2d29a642d6ad4884a1c77392298db820b73b69a06c5970f9a30a916224f8786664d5588b307189edcbe960d9634ea1d93d4cff06ac6d
6
+ metadata.gz: b551a056e1a92981e3dcd6d29a1268ea741e0156eae308006d1869f2430e14418a36b42a679fda4d497fc839a5c718a5a7ccb15a8b3ee5cdc74170182233143d
7
+ data.tar.gz: 8237ef30f1eaab21723588d0c3e4203471de666afb3b4163f69a9e6d4ba7201af37db1796001566c885c7fef266fafd40c095065a5e4ce5b269affdfdd7af04d
data/README.md CHANGED
@@ -1,7 +1,8 @@
1
1
  # claude-inbox
2
2
 
3
- An inbox for Claude Code's background sessions. It lists what `claude agents`
4
- lists and adds snooze, settle, pinning and each session's pull request.
3
+ An inbox for Claude Code's background sessions and Codex threads. It lists
4
+ what `claude agents` lists and adds snooze, settle, pinning and each session's
5
+ pull request.
5
6
 
6
7
  Inspired by [T3 Code](https://github.com/pingdotgg/t3code)'s functionality and
7
8
  inbox-style UX, I wanted a better way to manage my Claude agents, so I built
@@ -40,7 +41,7 @@ a fullscreen terminal.
40
41
 
41
42
  `/inbox` opens it, `ctrl+x tab` focuses it and `esc` leaves. The keys are the
42
43
  ones below, with `ge` for `G`, `n` `p` for `Tab` `Shift+Tab`, and no `a`,
43
- `P`, `X`, `Ctrl-x`, `/`, `R` or `U`, and `s` has no `5`. `Enter`, or a click on a row, switches to the
44
+ `r`, `P`, `X`, `Ctrl-x`, `/`, `R` or `U`, and `s` has no `5`. `Enter`, or a click on a row, switches to the
44
45
  session when you attached through `claude-inbox`.
45
46
 
46
47
  It opens when a session starts. To stop that, set
@@ -55,8 +56,12 @@ It opens when a session starts. To stop that, set
55
56
  failing checks or changes requested, that you haven't attached to yet.
56
57
  3. **Active.** Everything else that's running or not yet settled, including
57
58
  interactive terminals.
58
- 4. **Snoozed.** Sorted by wake time. Collapsed.
59
- 5. **Settled.** Settled with `x`, or every pull request is merged or closed.
59
+ 4. **Working.** With `--fold-working`, sessions still at work, until they
60
+ stop or you pin them. A sub-agent still running keeps a session at work;
61
+ one waiting only on a shell stays in Active. Collapsed; one you start
62
+ with `n` lights the fold line with its name instead of opening it.
63
+ 5. **Snoozed.** Sorted by wake time. Collapsed.
64
+ 6. **Settled.** Settled with `x`, or every pull request is merged or closed.
60
65
  Collapsed.
61
66
 
62
67
  `--sort=activity` puts the session that spoke most recently first in every
@@ -68,7 +73,7 @@ status line. A row with a pull request shows it as `#885 open`, `#885 merged`
68
73
  and so on, with `✗ checks`, `changes requested` or `conflicts` after it when
69
74
  something is wrong, and `+2` after it when the session has two more.
70
75
  A steady `◌` with `idle · 1 shell` means the agent stopped
71
- and is waiting on a shell or sub-agent it started. A blue `●` marks a session you
76
+ and is waiting on a shell it started. A blue `●` marks a session you
72
77
  haven't attached to since it got blocked or finished; one you have attached
73
78
  to since is dimmed, unless `/color` tints it. The age after the badge is how
74
79
  long since the session last said anything, read from its transcript: a
@@ -83,7 +88,7 @@ Vim bindings. Arrows and the mouse work too.
83
88
  | `j` `k` | move down / up |
84
89
  | `gg` `G` | first / last row |
85
90
  | `Ctrl-d` `Ctrl-u` | half page down / up (`Ctrl-f` `Ctrl-b` full page) |
86
- | `Enter` `l` | attach (`←` or `Ctrl+Z` returns here), adopt a remote session, or expand Snoozed / Settled |
91
+ | `Enter` `l` | attach (`←` or `Ctrl+Z` returns here; in Codex, `←` at the start of the prompt), adopt a remote session, or expand Snoozed / Settled |
87
92
  | `Tab` `Shift+Tab` | next / previous section |
88
93
  | `h` | collapse the current fold |
89
94
  | `za` `zo` `zc` | toggle / open / close the fold under the cursor |
@@ -94,6 +99,7 @@ Vim bindings. Arrows and the mouse work too.
94
99
  | `u` | wake a snoozed session, or bring back a settled one |
95
100
  | `x` | settle by hand |
96
101
  | `a` | set a local alias |
102
+ | `r` | ask Claude for a new title, kept as the alias |
97
103
  | `o` | open the pull request in the browser; with several, `o` again opens the one that matters, `1`… another |
98
104
  | `w` | open the session at claude.ai/code |
99
105
  | `y` | copy the pull request url, or the session's address; with several, `y` again copies the one that matters, `1`… another |
@@ -101,7 +107,7 @@ Vim bindings. Arrows and the mouse work too.
101
107
  | `X` | stop the session; `Enter` resumes it later |
102
108
  | `Ctrl-x` | delete the session, transcript and worktree for good |
103
109
  | `/` | filter by name, directory, prompt, status or pull request (`#885` or its title); `Enter` keeps it, `Esc` clears |
104
- | `R` | poll now |
110
+ | `R` | poll now (the list also refreshes on its own when a session's status changes) |
105
111
  | `U` | update claude-inbox when the header says a newer one is out |
106
112
  | `q` | quit |
107
113
 
@@ -109,8 +115,9 @@ Vim bindings. Arrows and the mouse work too.
109
115
 
110
116
  ### New session
111
117
 
112
- `n` opens a form for the prompt, name, directory, model, effort, permissions,
113
- worktree, Remote Control and color, which tints the row as `/color` would.
118
+ `n` opens a form for the prompt, Claude or Codex, name, directory, model,
119
+ effort, permissions, worktree, Remote Control and color, which tints the row
120
+ as `/color` would.
114
121
  `Tab` moves between fields, `Ctrl-S` starts the session and `Ctrl-O` starts
115
122
  it and attaches. `Option-S` or `Option-O` by mistake only says which key you
116
123
  meant; it no longer reads as `Esc` and asks to discard the prompt.
@@ -39,7 +39,7 @@ module ClaudeInbox
39
39
  # Applies every request on disk to `store` and returns how many it took.
40
40
  def drain(store)
41
41
  return 0 unless @dir && File.directory?(@dir)
42
- keys = store.actionable_keys
42
+ keys = store.attachable_keys
43
43
  Dir.glob(File.join(@dir, "*.json")).sort.count { |path| take(path, store, keys) }
44
44
  end
45
45
 
@@ -9,14 +9,15 @@ module ClaudeInbox
9
9
 
10
10
  def activate
11
11
  return set_expanded(true) if @selected&.fold?
12
- return @modal = Dialog::Confirm.new(:adopt, @selected.key) if selected_session&.remote?
13
- attach(@selected.key) if require_actionable
12
+ return @modal = Dialog::Confirm.new(:adopt, @selected.key) if selected_session&.can?(:adopt)
13
+ attach(selected_session) if allowed?(:attach)
14
14
  end
15
15
 
16
- # Guard for attach/stop: refuse politely on a terminal or remote row.
17
- def require_actionable
16
+ # Guard for attach/stop/delete: refuse politely on a terminal or remote
17
+ # row, and say nothing for an action the row's harness has not got.
18
+ def allowed?(action)
18
19
  s = selected_session
19
- return true if s&.actionable?
20
+ return true if s&.can?(action)
20
21
  if s&.interactive?
21
22
  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")
22
23
  end
@@ -27,49 +28,56 @@ module ClaudeInbox
27
28
  # attach ends and the next starts without passing through the list. The
28
29
  # poller keeps running meanwhile, so the pane's snapshot stays fresh. A
29
30
  # failed attach leaves its reason on screen until a key is pressed.
30
- def attach(id)
31
- while id
31
+ def attach(session)
32
+ while session
32
33
  @switch.clear
33
- from = session_for(id)&.session_id
34
- @store.visit(id) do
34
+ from = session.session_id
35
+ @store.visit(session.key) do
35
36
  @terminal.release do
36
- @terminal.pause unless @client.attach(id) { @switch.requested_for?(from) }
37
+ @terminal.pause unless @client.attach(session) { @switch.requested_for?(from) }
37
38
  end
38
39
  end
39
40
  @poller.soon
40
- id = @switch.take(@store.actionable_keys)
41
+ session = session_for(@switch.take(@store.attachable_keys))
41
42
  end
42
43
  end
43
44
 
45
+ # A session just started or adopted, which no poll has listed yet.
46
+ def started(id, harness: :claude, cwd: nil) = Session.new(harness: harness, id: id, cwd: cwd)
47
+
44
48
  def adopt_session(key)
45
49
  s = session_for(key)
46
- return unless s&.remote?
50
+ return unless s&.can?(:adopt)
47
51
  notice("adopting #{s.display_name}…")
48
52
  in_background do
49
53
  id = @client.adopt(session_id: s.session_id, cwd: s.cwd, pid: s.pid)
50
54
  @queue << [:notice, "adopted as #{id}"]
51
55
  @queue << [:select, id]
52
- @queue << [:attach, id]
56
+ @queue << [:attach, started(id)]
53
57
  end
54
58
  end
55
59
 
56
60
  def open_confirm(kind)
57
- return unless require_actionable
58
- @modal = Dialog::Confirm.new(kind, @selected.key, label_for(@selected.key))
61
+ return unless allowed?(kind)
62
+ @modal = Dialog::Confirm.new(kind, @selected.key, label_for(@selected.key), worktree: !selected_session.worktree_path.nil?)
59
63
  end
60
64
 
65
+ # A Codex thread between turns has nothing to stop, which is no failure.
61
66
  def stop_session(id)
67
+ return unless (s = session_for(id))
68
+ label = label_for(id)
62
69
  in_background do
63
- @client.stop(id)
70
+ @queue << [:notice, "#{label} has no turn running"] unless @client.stop(s)
64
71
  @poller.soon
65
72
  end
66
73
  end
67
74
 
68
75
  def delete_session(id)
76
+ return unless (s = session_for(id))
69
77
  label = label_for(id)
70
78
  notice("deleting #{label}…")
71
79
  in_background do
72
- @client.rm(id)
80
+ @client.rm(s)
73
81
  @store.forget(id)
74
82
  @queue << [:notice, "deleted #{label}"]
75
83
  @poller.soon
@@ -5,21 +5,36 @@ module ClaudeInbox
5
5
  # Where the cursor is in the sectioned, foldable list and how it moves.
6
6
  # The only writer of the selection; the rest of App reads it.
7
7
  module Navigating
8
+ ARRIVAL_SECONDS = 4
9
+
8
10
  private
9
11
 
12
+ def arrival(now) = @arrival&.then { |a, until_at| a if now < until_at }
13
+
10
14
  # The row to land on once a poll lists it, as after a spawn.
11
15
  def select_later(id)
12
16
  @pending_select = id
13
17
  end
14
18
 
15
- def ensure_selection(sections)
16
- stops = sections.selections(@expanded)
19
+ # A requested row is settled the poll it first appears in, filtered
20
+ # out or not: left pending, it would fire whenever the row next
21
+ # surfaced, taking the cursor from wherever you had moved it since.
22
+ # Behind a fold it lights the fold line for a few seconds and leaves
23
+ # both the fold and the cursor alone, since a folded session is one
24
+ # you chose not to watch.
25
+ def ensure_selection(all)
26
+ sections = filtered(all)
17
27
  pending = @pending_select && Store::Selection.row(@pending_select)
18
- if pending && stops.include?(pending)
19
- select(pending)
28
+ if pending && all.section_of(pending)
20
29
  @pending_select = nil
21
- return
30
+ name = sections.section_of(pending)
31
+ if name && Store.folded?(name, @expanded)
32
+ @arrival = [Renderer::Arrival.new(section: name, label: label_for(pending.key)), Time.now + ARRIVAL_SECONDS]
33
+ elsif name
34
+ return select(pending)
35
+ end
22
36
  end
37
+ stops = sections.selections(@expanded)
23
38
  return if stops.include?(@selected)
24
39
  select(stops.first)
25
40
  end
@@ -63,7 +78,6 @@ module ClaudeInbox
63
78
  select(heads[(idx + dir) % heads.size].last)
64
79
  end
65
80
 
66
- # The foldable section the cursor is currently on or inside, if any.
67
81
  def current_fold_section
68
82
  name = filtered.section_of(@selected)
69
83
  name if Store::FOLDABLE_SECTIONS.include?(name)
@@ -80,9 +94,8 @@ module ClaudeInbox
80
94
 
81
95
  def toggle_expanded = set_expanded(!@expanded[current_fold_section])
82
96
 
83
- # Clicking a row selects it and attaches, same as landing on it with
84
- # j/k and pressing Enter — activate already knows how to expand a fold
85
- # or refuse a terminal/remote row, so this doesn't repeat that.
97
+ # Left to activate, as Enter is, to expand a fold or refuse a
98
+ # terminal or remote row.
86
99
  def click_row(row)
87
100
  item = row_item_at(row)
88
101
  return unless item
@@ -9,7 +9,7 @@ module ClaudeInbox
9
9
  def open_new_session
10
10
  cwd = selected_session&.cwd || Dir.pwd
11
11
  dirs = SessionRequest.directories(@store.sessions, @trust.call)
12
- @modal = NewSessionForm.new(cwd: SessionRequest.strip_worktree(cwd), dirs: dirs)
12
+ @modal = NewSessionForm.new(cwd: SessionRequest.strip_worktree(cwd), dirs: dirs, choices: @client.choices)
13
13
  end
14
14
 
15
15
  # `:start`/`:start_and_attach` leave the form as `@modal`, now busy: it
@@ -35,8 +35,8 @@ module ClaudeInbox
35
35
  @queue << [:notice, ["started #{id}", note].compact.join(" — ")]
36
36
  @queue << [:form_started, form]
37
37
  @queue << [:select, id]
38
- attach ? @queue << [:attach, id] : @poller.soon
39
- rescue AgentsClient::Error => e
38
+ attach ? @queue << [:attach, started(id, harness: v[:harness], cwd: v[:cwd])] : @poller.soon
39
+ rescue Harnesses::Error => e
40
40
  @queue << [:form_failed, form, e.message]
41
41
  end
42
42
  end
@@ -62,6 +62,21 @@ module ClaudeInbox
62
62
  @modal = Dialog::Prompt.new(:alias, @selected.key, current)
63
63
  end
64
64
 
65
+ def retitle_selected
66
+ return unless require_storable && require_writable("retitle")
67
+ key, session = @selected.key, selected_session
68
+ current = @store.alias_for(key) || session.display_name
69
+ notice("asking claude for a title…")
70
+ in_background do
71
+ title = @titler.title(session, current: current)
72
+ next @queue << [:notice, "“#{current}” still fits"] if title == current
73
+ @store.set_alias(key, title)
74
+ @queue << [:notice, "titled “#{title}” — a edits it"]
75
+ rescue Titler::Error => e
76
+ @queue << [:notice, e.message]
77
+ end
78
+ end
79
+
65
80
  def open_pr_editor
66
81
  return unless require_storable && require_writable("link a pull request")
67
82
  current = @store.pr_for(@selected.key) || selected_session&.pr&.url || ""
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "agents_client"
3
+ require_relative "claude/agents_client"
4
+ require_relative "harnesses"
4
5
  require_relative "debug"
5
6
  require_relative "dialog"
6
7
  require_relative "input"
@@ -12,6 +13,7 @@ require_relative "keymap"
12
13
  require_relative "remote/listener"
13
14
  require_relative "new_session_form"
14
15
  require_relative "pull_requests"
16
+
15
17
  require_relative "reaper"
16
18
  require_relative "notifier"
17
19
  require_relative "poller"
@@ -25,6 +27,7 @@ require_relative "switch"
25
27
  require_relative "trust"
26
28
  require_relative "mod"
27
29
  require_relative "updater"
30
+ require_relative "titler"
28
31
  require_relative "app/updating"
29
32
  require_relative "app/filtering"
30
33
  require_relative "app/triaging"
@@ -47,13 +50,14 @@ module ClaudeInbox
47
50
 
48
51
  # The reaper deletes sessions and the listener lets other machines in,
49
52
  # so both are off unless `bin/claude-inbox` switches them on.
50
- def initialize(client: AgentsClient.new, store: Store.new, pull_requests: PullRequests.new, transcripts: Transcript.new,
53
+ def initialize(client: Harnesses.new(claude: Claude::AgentsClient.new), store: Store.new, pull_requests: PullRequests.new,
51
54
  rate_limits: RateLimits.new, reaper: Reaper.disabled, snapshot: Snapshot.disabled, switch: Switch.disabled,
52
55
  actions: Actions.disabled, notifier: Notifier.disabled, listen: nil, updater: nil, argv: [], out: $stdout, input: $stdin, color: true,
53
56
  trust: Trust.method(:projects), terminal: Terminal.new(out, input), queue: Queue.new, pane_offer: Mod::Offer.disabled,
54
- clipboard: Clipboard.method(:copy))
57
+ clipboard: Clipboard.method(:copy), titler: Titler.new)
55
58
  @client = client
56
59
  @clipboard = clipboard
60
+ @titler = titler
57
61
  @store = store
58
62
  @snapshot = snapshot
59
63
  @switch = switch
@@ -67,7 +71,7 @@ module ClaudeInbox
67
71
  @renderer = Renderer.new
68
72
  @input = Input.new(input, out)
69
73
  @queue = queue
70
- @poller = Poller.new(client: client, store: store, pull_requests: pull_requests, transcripts: transcripts,
74
+ @poller = Poller.new(client: client, store: store, pull_requests: pull_requests,
71
75
  reaper: reaper, queue: @queue, snapshot: snapshot, actions: actions, notifier: notifier)
72
76
  @listener = listen ? Remote::Listener.new(client: client, store: store, queue: @queue, **listen) : Remote::Listener.disabled
73
77
  @selected = nil
@@ -253,6 +257,7 @@ module ClaudeInbox
253
257
  when :toggle_pin then toggle_pin_selected
254
258
  when :settle then settle_selected
255
259
  when :alias then open_alias_editor
260
+ when :retitle then retitle_selected
256
261
  when :link_pr then open_pr_editor
257
262
  when :open_pr then open_pr
258
263
  when :open_remote then open_remote
@@ -283,13 +288,13 @@ module ClaudeInbox
283
288
  sections = filtered(all)
284
289
  width, height = @terminal.size
285
290
  body_h = Renderer.body_height(height)
286
- ensure_selection(sections)
291
+ ensure_selection(all)
287
292
  @tick += 1
288
293
  view = Renderer::View.new(
289
294
  width: width, height: height, now: now, selected: @selected&.key, top: @frame.top, expanded: @expanded,
290
295
  modal: modal_lines(width), screen: screen_lines(width, body_h),
291
296
  status: status_text(now), usage: @rate_limits.windows(now), filter: filter, filter_editing: filter_editing?,
292
- tick: @tick / 2, loading: loading_for, listening: @listener.snapshot
297
+ tick: @tick / 2, loading: loading_for, listening: @listener.snapshot, arrival: arrival(now)
293
298
  )
294
299
  @frame = @renderer.frame(sections, view)
295
300
  @frame = @frame.plain unless @color # --no-color: decided here and nowhere else