claude-inbox 0.12.3 → 0.13.1

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 (41) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +12 -4
  3. data/exe/claude-inbox +4 -0
  4. data/lib/claude_inbox/actions.rb +1 -1
  5. data/lib/claude_inbox/agents_client.rb +11 -27
  6. data/lib/claude_inbox/app/attaching.rb +1 -1
  7. data/lib/claude_inbox/app.rb +8 -6
  8. data/lib/claude_inbox/cli.rb +6 -5
  9. data/lib/claude_inbox/dialog.rb +8 -7
  10. data/lib/claude_inbox/headless.rb +6 -3
  11. data/lib/claude_inbox/images.rb +2 -3
  12. data/lib/claude_inbox/job_state.rb +4 -13
  13. data/lib/claude_inbox/new_session_form.rb +10 -18
  14. data/lib/claude_inbox/palette.rb +2 -2
  15. data/lib/claude_inbox/poller.rb +3 -2
  16. data/lib/claude_inbox/pull_requests.rb +15 -23
  17. data/lib/claude_inbox/reaper.rb +11 -13
  18. data/lib/claude_inbox/remote/http.rb +6 -6
  19. data/lib/claude_inbox/remote/start.rb +1 -1
  20. data/lib/claude_inbox/renderer.rb +12 -14
  21. data/lib/claude_inbox/session.rb +1 -1
  22. data/lib/claude_inbox/sessions.rb +3 -1
  23. data/lib/claude_inbox/slash_commands.rb +3 -10
  24. data/lib/claude_inbox/snapshot.rb +1 -0
  25. data/lib/claude_inbox/store/entry.rb +1 -5
  26. data/lib/claude_inbox/store/row.rb +14 -7
  27. data/lib/claude_inbox/store.rb +14 -3
  28. data/lib/claude_inbox/subprocess.rb +2 -4
  29. data/lib/claude_inbox/terminal.rb +5 -10
  30. data/lib/claude_inbox/text.rb +5 -4
  31. data/lib/claude_inbox/text_buffer.rb +13 -15
  32. data/lib/claude_inbox/transcript.rb +76 -0
  33. data/lib/claude_inbox/updater.rb +5 -3
  34. data/lib/claude_inbox/vt_screen.rb +2 -1
  35. data/lib/claude_inbox.rb +1 -1
  36. data/mod/hooks/register.tsx +13 -2
  37. data/mod/tests/contract.test.tsx +1 -0
  38. data/mod/tests/pane.test.tsx +12 -0
  39. data/mod/tests/snapshot.fixture.ts +2 -1
  40. data/mod/types/index.d.ts +1 -0
  41. metadata +16 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 58507c555e4ab15701ccf14104a7d365abed00c4f3d4386afba2667b12a6afe1
4
- data.tar.gz: 21cb5cc8697d843dae5c557b97872ccca09c0148c1c4655bcb8772b15e156d00
3
+ metadata.gz: 36fa381f8d03173896c6b44dff47534ed54119c9ff529833a9783c7f5c524506
4
+ data.tar.gz: '0386da94a6ffa5259bbad165857821f44b082dc5a64d957beae352c46dc61100'
5
5
  SHA512:
6
- metadata.gz: 24352714cad8d1974cfe056ed606027aaeac4003e621abb6fc72c76fc9c18dfad052f86ba1bd157dec43cad09415f12db64374aae836d948687eabac645145ea
7
- data.tar.gz: 7e426498a2aca8f7961c9a8e8e61b68a138b8c6f2d414ac0bb5b8334f61b923966bf1894c994a76bd8880e442c43d32b8f456116ab670a4a70fbfa347bc81262
6
+ metadata.gz: bb933d5723ee131285824d2faea2570762854a4695da2ecd98716d5b9d981b2b36aece7abbda8acf92fa063b47f52f40ac2931136ef5fecee194f0fda7cdc72c
7
+ data.tar.gz: 3593591f4192056035fdc13e3480a26824794be46df0d8aa5b72fdb5c84a4b6b039d658b4279ec64cdf35ec94c2d65533117321c01bdd9b3e06a4267571a83b6
data/README.md CHANGED
@@ -49,7 +49,8 @@ It opens when a session starts. To stop that, set
49
49
 
50
50
  ## Sections
51
51
 
52
- 1. **Pinned.** Rows you pinned with `t`.
52
+ 1. **Pinned.** Rows you pinned with `t`. Snoozing a pinned row moves it to
53
+ Snoozed, marked `★`, until it wakes back into Pinned.
53
54
  2. **Needs you.** Blocked or failed sessions, or ones whose pull request has
54
55
  failing checks or changes requested, that you haven't attached to yet.
55
56
  3. **Active.** Everything else that's running or not yet settled, including
@@ -58,6 +59,10 @@ It opens when a session starts. To stop that, set
58
59
  5. **Settled.** Settled with `x`, or every pull request is merged or closed.
59
60
  Collapsed.
60
61
 
62
+ `--sort=activity` puts the session that spoke most recently first in every
63
+ section but Snoozed, read from its transcript. Put it in the config file to
64
+ keep it.
65
+
61
66
  Under each row is what the session is waiting on, what it produced, or its
62
67
  status line. A row with a pull request shows it as `#885 open`, `#885 merged`
63
68
  and so on, with `✗ checks`, `changes requested` or `conflicts` after it when
@@ -65,7 +70,9 @@ something is wrong, and `+2` after it when the session has two more.
65
70
  A steady `◌` with `idle · 1 shell` means the agent stopped
66
71
  and is waiting on a shell or sub-agent it started. A blue `●` marks a session you
67
72
  haven't attached to since it got blocked or finished; one you have attached
68
- to since is dimmed, unless `/color` tints it.
73
+ to since is dimmed, unless `/color` tints it. The age after the badge is how
74
+ long since the session last said anything, read from its transcript: a
75
+ `working` row that spoke 30s ago is still talking, one silent for 2h is not.
69
76
 
70
77
  ## Keys
71
78
 
@@ -93,7 +100,7 @@ Vim bindings. Arrows and the mouse work too.
93
100
  | `P` | link a pull request by hand (empty clears) |
94
101
  | `X` | stop the session; `Enter` resumes it later |
95
102
  | `Ctrl-x` | delete the session, transcript and worktree for good |
96
- | `/` | filter; `Enter` keeps it, `Esc` clears |
103
+ | `/` | filter by name, directory, prompt, status or pull request (`#885` or its title); `Enter` keeps it, `Esc` clears |
97
104
  | `R` | poll now |
98
105
  | `U` | update claude-inbox when the header says a newer one is out |
99
106
  | `q` | quit |
@@ -154,7 +161,8 @@ covers ssh, the JSON API and an iOS Shortcut.
154
161
  ## Pull requests
155
162
 
156
163
  Pull requests show up when a session's transcript links to them. Install
157
- `gh` to keep their state current. Use `P` to link one by hand.
164
+ `gh` to keep their state current. Use `P` to link one by hand, and `/#885`
165
+ to find the session behind a pull request.
158
166
 
159
167
  ## Notifications
160
168
 
data/exe/claude-inbox CHANGED
@@ -1,6 +1,10 @@
1
1
  #!/usr/bin/env ruby
2
2
  # frozen_string_literal: true
3
3
 
4
+ # Claude's files are UTF-8 whatever the locale says; under LANG=C a read of
5
+ # ~/.claude.json would otherwise come back US-ASCII and fail to parse.
6
+ Encoding.default_external = Encoding::UTF_8
7
+
4
8
  require "claude_inbox"
5
9
  require "claude_inbox/cli"
6
10
 
@@ -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.sessions.select(&:actionable?).map(&:key)
42
+ keys = store.actionable_keys
43
43
  Dir.glob(File.join(@dir, "*.json")).sort.count { |path| take(path, store, keys) }
44
44
  end
45
45
 
@@ -27,7 +27,6 @@ module ClaudeInbox
27
27
  classify_origins(parse(r.out))
28
28
  end
29
29
 
30
- # Poll interval for the agents-view watchdog below.
31
30
  WATCH_INTERVAL = 0.05
32
31
 
33
32
  # Hands the terminal to the child; caller must have restored cooked mode.
@@ -35,14 +34,11 @@ module ClaudeInbox
35
34
  # False when the attach failed on its own, as a session that cannot be
36
35
  # woken does: the CLI prints why and exits 1.
37
36
  #
38
- # Pressing ← inside an attached session detaches it and then `claude
39
- # attach` execs itself in place as `claude agents` (same pid). There is
40
- # no flag or env var that suppresses only that relaunch: the one switch
41
- # that exists disables attach too. So we watch the child's command line
42
- # and, the moment it becomes the agents view, terminate it. The user then
43
- # lands back in the inbox instead of native agent view.
44
- # The block, polled with the command line, ends the attach early when it
45
- # answers true: how a switch request from another front end is honoured.
37
+ # ← turns `claude attach` into `claude agents` in place, and nothing
38
+ # switches off only that (docs/cli-quirks.md), so the watchdog kills the
39
+ # child once it does. The block, polled alongside, ends the attach early
40
+ # when it answers true. That is how a switch request from another front
41
+ # end is honoured.
46
42
  def attach(id, &stop)
47
43
  pid = Process.spawn(*Subprocess.command(@bin, "attach", id))
48
44
  watchdog = Thread.new { kill_when_agents_view(pid, &stop) }
@@ -76,8 +72,7 @@ module ClaudeInbox
76
72
  # its path completion escapes them; a quoted path is not recognized.
77
73
  def self.mention(path) = "@" + path.gsub(" ", "\\ ")
78
74
 
79
- # Pure so it can be tested: a setting left nil leaves its flag off. The
80
- # prompt goes after "--", where a leading dash is text rather than an
75
+ # The prompt goes after "--", where a leading dash is text rather than an
81
76
  # unknown option, and --remote-control sits just before it: its optional
82
77
  # name would otherwise eat the prompt (both checked on 2.1.282).
83
78
  def self.spawn_args(bin, prompt:, model: nil, effort: nil, permission_mode: nil, worktree: false, name: nil, remote: false)
@@ -115,14 +110,9 @@ module ClaudeInbox
115
110
  # you type in: the headless print mode and the SDK's stream protocol.
116
111
  HEADLESS_FLAGS = %w[-p --print --input-format --output-format].freeze
117
112
 
118
- # pid => origin, given `ps` for the sessions and for their parents. Pure.
119
- #
120
- # :remote a Remote Control worker: --sdk-url, or a `claude rc` parent
121
- # :headless `claude -p "..."` or an SDK stream run. Its parent is
122
- # whatever shell spawned it, not the claude that asked for
123
- # it, so only its own command line gives it away
124
- # :subagent parented by another claude process
125
- # :terminal everything else, which is a claude you are sitting in
113
+ # pid => origin, given `ps` for the sessions and for their parents.
114
+ # A headless run's parent is whatever shell spawned it, not the claude
115
+ # that asked for it, so only its own command line gives it away.
126
116
  def self.origins(rows, parent_cmd)
127
117
  rows.to_h do |pid, ppid, cmd|
128
118
  parent = parent_cmd[ppid].to_s
@@ -143,7 +133,7 @@ module ClaudeInbox
143
133
 
144
134
  # pid => bridge session id, for the workers a `claude remote-control`
145
135
  # server spawned: each is started with `--session-id cse_…`, the same id
146
- # a background session records as bridgeSessionId. Pure.
136
+ # a background session records as bridgeSessionId.
147
137
  def self.bridge_ids(rows)
148
138
  rows.filter_map { |pid, _, cmd| (m = cmd.match(/--session-id[= ](cse_\w+)/)) && [pid, m[1]] }.to_h
149
139
  end
@@ -185,10 +175,6 @@ module ClaudeInbox
185
175
  assign_origins(sessions, origins_from(rows), self.class.bridge_ids(rows))
186
176
  end
187
177
 
188
- # Tags each interactive session with where it is driven from (pid =>
189
- # origin, terminal when unlisted) and the bridge a remote one is served
190
- # over, and drops the unattended ones, whose parent is the row worth
191
- # showing.
192
178
  def assign_origins(sessions, origins, bridges)
193
179
  sessions
194
180
  .map { |s| s.interactive? ? s.with(origin: origins.fetch(s.pid, :terminal), bridge_id: bridges[s.pid]) : s }
@@ -259,9 +245,7 @@ module ClaudeInbox
259
245
  @bridges = bridges
260
246
  end
261
247
 
262
- # Fixture rows carry no process tree; tag each interactive row from the
263
- # pid => origin map the test hands in, otherwise as a terminal, then drop
264
- # the unattended ones same as the real client does.
248
+ # Fixture rows carry no process tree.
265
249
  def list
266
250
  assign_origins(parse(File.read(@path)), @origins, @bridges)
267
251
  end
@@ -35,7 +35,7 @@ module ClaudeInbox
35
35
  @terminal.release do
36
36
  @terminal.pause unless @client.attach(id) { @switch.requested_for?(from) }
37
37
  end
38
- id = @switch.take(@store.sessions.select(&:actionable?).map(&:key))
38
+ id = @switch.take(@store.actionable_keys)
39
39
  end
40
40
  end
41
41
 
@@ -47,7 +47,7 @@ module ClaudeInbox
47
47
 
48
48
  # The reaper deletes sessions and the listener lets other machines in,
49
49
  # so both are off unless `bin/claude-inbox` switches them on.
50
- def initialize(client: AgentsClient.new, store: Store.new, pull_requests: PullRequests.new,
50
+ def initialize(client: AgentsClient.new, store: Store.new, pull_requests: PullRequests.new, transcripts: Transcript.new,
51
51
  rate_limits: RateLimits.new, reaper: Reaper.disabled, snapshot: Snapshot.disabled, switch: Switch.disabled,
52
52
  actions: Actions.disabled, notifier: Notifier.disabled, listen: nil, updater: nil, argv: [], out: $stdout, input: $stdin, color: true,
53
53
  trust: Trust.method(:projects), terminal: Terminal.new(out, input), queue: Queue.new, pane_offer: Mod::Offer.disabled,
@@ -67,7 +67,7 @@ module ClaudeInbox
67
67
  @renderer = Renderer.new
68
68
  @input = Input.new(input, out)
69
69
  @queue = queue
70
- @poller = Poller.new(client: client, store: store, pull_requests: pull_requests,
70
+ @poller = Poller.new(client: client, store: store, pull_requests: pull_requests, transcripts: transcripts,
71
71
  reaper: reaper, queue: @queue, snapshot: snapshot, actions: actions, notifier: notifier)
72
72
  @listener = listen ? Remote::Listener.new(client: client, store: store, queue: @queue, **listen) : Remote::Listener.disabled
73
73
  @selected = nil
@@ -87,7 +87,7 @@ module ClaudeInbox
87
87
  end
88
88
 
89
89
  def run
90
- @booted_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
90
+ @booted_at = monotonic
91
91
  install_traps
92
92
  @terminal.enter
93
93
  @poller.start
@@ -264,7 +264,7 @@ module ClaudeInbox
264
264
  end
265
265
 
266
266
  def render
267
- t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
267
+ t0 = monotonic
268
268
  now = Time.now
269
269
  all = @store.sections(now)
270
270
  @snapshot.write(all, now)
@@ -283,7 +283,7 @@ module ClaudeInbox
283
283
  frame = frame.plain unless @color # --no-color: decided here and nowhere else
284
284
  placed(frame)
285
285
  @terminal.paint(frame.lines)
286
- dt = Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0
286
+ dt = monotonic - t0
287
287
  Debug.log("render #{(dt * 1000).round}ms") if dt > 0.05
288
288
  end
289
289
 
@@ -309,11 +309,13 @@ module ClaudeInbox
309
309
  # already says how to retry.
310
310
  def loading_for
311
311
  return nil if @last_poll || @error || !@booted_at
312
- Process.clock_gettime(Process::CLOCK_MONOTONIC) - @booted_at
312
+ monotonic - @booted_at
313
313
  end
314
314
 
315
315
  def notice(msg)
316
316
  @notice = [msg, Time.now + 4]
317
317
  end
318
+
319
+ def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
318
320
  end
319
321
  end
@@ -59,7 +59,7 @@ module ClaudeInbox
59
59
  opts[:color] = options[:color]
60
60
  opts[:notifier] = notifier
61
61
  return opts if options[:fixture] || writer_lock.take(role: "inbox")
62
- opts.merge(store: Store.new(writable: false, relay: Actions.new), reaper: Reaper.disabled,
62
+ opts.merge(store: Store.new(writable: false, relay: Actions.new, sort: options[:sort]), reaper: Reaper.disabled,
63
63
  snapshot: Snapshot.disabled, actions: Actions.disabled, pane_offer: Mod::Offer.disabled, notifier: Notifier.disabled)
64
64
  end
65
65
 
@@ -113,7 +113,7 @@ module ClaudeInbox
113
113
 
114
114
  def headless
115
115
  abort "claude-inbox: --headless takes no --fixture" if options[:fixture]
116
- Headless.new(client: AgentsClient.new, store: Store.new, updater: updater, notifier: notifier).run
116
+ Headless.new(client: AgentsClient.new, store: Store.new(sort: options[:sort]), updater: updater, notifier: notifier).run
117
117
  end
118
118
 
119
119
  # Only what a check within the day already found: a cold launch never waits
@@ -132,7 +132,7 @@ module ClaudeInbox
132
132
  # token phones are paired with nor hold the real inbox's lock.
133
133
  dir = File.join(Dir.tmpdir, "claude-inbox-fixture")
134
134
  {client: FixtureClient.new(options[:fixture]),
135
- store: Store.new(path: nil),
135
+ store: Store.new(path: nil, sort: options[:sort]),
136
136
  pull_requests: PullRequests.new(cache_path: File.join(fixtures, "gh-pr-status-cache.json"), resolved_path: nil, gh: nil),
137
137
  listen: options[:listen]&.merge(fixture: true, pairing: Remote::Pairing.new(path: File.join(dir, "listen.json")),
138
138
  lock_path: File.join(dir, "listen.lock"), images_dir: File.join(dir, "images"))}
@@ -144,7 +144,7 @@ module ClaudeInbox
144
144
  # comes only from here, and only when asked for.
145
145
  def live_options
146
146
  client = AgentsClient.new
147
- store = Store.new
147
+ store = Store.new(sort: options[:sort])
148
148
  {client: client, store: store, reaper: options[:reap] ? Reaper.new(client, store) : Reaper.disabled,
149
149
  snapshot: Snapshot.new, switch: Switch.new, actions: Actions.new, pane_offer: Mod::Offer.new,
150
150
  listen: options[:listen], updater: updater,
@@ -162,7 +162,7 @@ module ClaudeInbox
162
162
  def writer_lock = (@lock ||= WriterLock.new)
163
163
 
164
164
  def parse(argv)
165
- opts = {color: true, update_check: true}
165
+ opts = {color: true, update_check: true, sort: "default"}
166
166
  parser = OptionParser.new(USAGE) do |p|
167
167
  p.on("--version", "Print the version") {
168
168
  @out.puts VERSION
@@ -176,6 +176,7 @@ module ClaudeInbox
176
176
  p.on("--headless", "Keep the pane's snapshot fresh without a screen") { opts[:headless] = true }
177
177
  p.on("--reap", "Delete background sessions idle 14 days") { opts[:reap] = true }
178
178
  p.on("--no-color", "Draw without color") { opts[:color] = false }
179
+ p.on("--sort=ORDER", Store::SORTS, "Order every section by activity, newest message first") { |order| opts[:sort] = order }
179
180
  p.on("--notify", "Send a desktop notification when a session needs you or finishes") { opts[:notify] = true }
180
181
  p.on("--auto-update", "Install a release found today before launching") { opts[:auto_update] = true }
181
182
  p.on("--no-update-check", "Never look for a newer release") { opts[:update_check] = false }
@@ -28,11 +28,11 @@ module ClaudeInbox
28
28
  .split("\n")
29
29
  end
30
30
 
31
+ private
32
+
31
33
  # How wide the box wants to be; the frame caps it at the terminal.
32
34
  def fit = self.class::WIDTH
33
35
 
34
- private
35
-
36
36
  def key(name) = @theme.cyan_bold(name)
37
37
 
38
38
  # A key and what it does.
@@ -83,10 +83,6 @@ module ClaudeInbox
83
83
 
84
84
  def title = " #{kind.to_s.capitalize} pull request "
85
85
 
86
- # Titles vary, so the box grows to the longest line plus some air,
87
- # and never shrinks below a plain dialog.
88
- def fit = [WIDTH, lines(Float::INFINITY, nil).map { |l| Text.width(l) }.max + MARGIN].max
89
-
90
86
  def lines(width, _caret)
91
87
  (@prs.each_with_index.map { |pr, i| " #{key((i + 1).to_s)} #{label(pr)}" } +
92
88
  ["", " #{hint(KEYS.fetch(kind), "#{kind} #{@prs.first.short}")} · #{key("esc")} #{@theme.dim("cancel")}"])
@@ -102,11 +98,16 @@ module ClaudeInbox
102
98
 
103
99
  private
104
100
 
101
+ # Titles vary, so the box grows to the longest line plus some air,
102
+ # and never shrinks below a plain dialog.
103
+ def fit = [WIDTH, lines(Float::INFINITY, nil).map { |l| Text.width(l) }.max + MARGIN].max
104
+
105
105
  # The list's badge, then the title. A bare #190 is ambiguous only
106
106
  # once the list spans repos.
107
107
  def label(pr)
108
108
  name = (@prs.map(&:repo).uniq.size > 1) ? pr.qualified : pr.short
109
- pr.title ? @theme.pr_badge(pr, name) + @theme.dim(" · ") + pr.title : @theme.pr_badge(pr, name)
109
+ badge = @theme.pr_badge(pr, name)
110
+ pr.title ? badge + @theme.dim(" · ") + pr.title : badge
110
111
  end
111
112
  end
112
113
 
@@ -17,7 +17,7 @@ module ClaudeInbox
17
17
  class Headless
18
18
  RECHECK_EVERY = 60
19
19
 
20
- def initialize(client:, store:, pull_requests: PullRequests.new, actions: Actions.new, snapshot: Snapshot.new,
20
+ def initialize(client:, store:, pull_requests: PullRequests.new, transcripts: Transcript.new, actions: Actions.new, snapshot: Snapshot.new,
21
21
  lock: WriterLock.new, updater: nil, notifier: Notifier.disabled, recheck_every: RECHECK_EVERY)
22
22
  @lock = lock
23
23
  @snapshot = snapshot
@@ -25,7 +25,7 @@ module ClaudeInbox
25
25
  @recheck_every = recheck_every
26
26
  @notice = nil
27
27
  @queue = Queue.new
28
- @poller = Poller.new(client: client, store: store, pull_requests: pull_requests, reaper: Reaper.disabled,
28
+ @poller = Poller.new(client: client, store: store, pull_requests: pull_requests, transcripts: transcripts, reaper: Reaper.disabled,
29
29
  queue: @queue, snapshot: snapshot, actions: actions, notifier: notifier)
30
30
  end
31
31
 
@@ -33,8 +33,11 @@ module ClaudeInbox
33
33
  def run
34
34
  return false unless @lock.take(role: "headless")
35
35
  @poller.start
36
- Thread.new { watch_for_updates } if @updater
36
+ updates = Thread.new { watch_for_updates } if @updater
37
37
  loop { @queue.pop }
38
+ ensure
39
+ @poller.stop
40
+ updates&.kill
38
41
  end
39
42
 
40
43
  private
@@ -42,13 +42,12 @@ module ClaudeInbox
42
42
  # two requests saving in the same millisecond apart. Readable by the
43
43
  # owner only: a photo can be anything.
44
44
  def self.save(bytes, dir:, index:, now: Time.now)
45
- data = bytes.b
46
- ext = extension(data)
45
+ ext = extension(bytes)
47
46
  raise Unsupported, "not a PNG, JPEG, GIF or WebP image" unless ext
48
47
  FileUtils.mkdir_p(dir)
49
48
  prune(dir, now)
50
49
  path = File.join(dir, "#{now.strftime("%Y%m%d-%H%M%S-%L")}-#{index}-#{SecureRandom.hex(3)}#{ext}")
51
- File.open(path, File::WRONLY | File::CREAT | File::EXCL | File::BINARY, 0o600) { |f| f.write(data) }
50
+ File.open(path, File::WRONLY | File::CREAT | File::EXCL | File::BINARY, 0o600) { |f| f.write(bytes) }
52
51
  path
53
52
  end
54
53
 
@@ -8,17 +8,9 @@ module ClaudeInbox
8
8
  # ~/.claude/jobs/<id>/state.json. The only reader of that file, and the
9
9
  # only writer: a new session's color.
10
10
  #
11
- # `claude agents --json` reports one word, `state`, and "working" covers two
12
- # different situations: the agent is thinking, or the agent is idle and
13
- # something it started earlier — a watch shell, a sub-agent — is still open.
14
- # The file tells them apart: `tempo` is the agent's own pulse, `fan` names
15
- # each outstanding piece of work, and `detail` is the session's own status
16
- # line, with `needs` naming what it is waiting on while blocked and
17
- # `output.result` what it produced once done. It also carries the PR links
18
- # the daemon scanned out of the transcript and the transcript's path, which
19
- # PullRequests reads through here, the color `/color` set on the session,
20
- # which `claude agents --json` drops, and `intent`, the prompt the session
21
- # was started from, and `createdAt`, when it was.
11
+ # `claude agents --json` says "working" both while the agent thinks and
12
+ # while it sits idle on a shell or sub-agent it started. This file tells
13
+ # the two apart, and holds what the JSON drops (docs/cli-quirks.md).
22
14
  #
23
15
  # Never cached. A color changes the moment you type `/color`, so every poll
24
16
  # asks the file again.
@@ -40,7 +32,7 @@ module ClaudeInbox
40
32
 
41
33
  # Each background session with its job file read onto `job_state`, or nil
42
34
  # when there is none: interactive sessions have no job file, and neither
43
- # does one the daemon has already forgotten. Sessions.load calls this.
35
+ # does one the daemon has already forgotten.
44
36
  def self.enrich(sessions, jobs_dir:)
45
37
  sessions.map { |s| s.background? ? s.with(job_state: read(s.id, jobs_dir: jobs_dir)) : s }
46
38
  end
@@ -91,7 +83,6 @@ module ClaudeInbox
91
83
  # a wake, are the only record of Remote Control being on it.
92
84
  def remote_control? = flags.include?("--remote-control")
93
85
 
94
- # True when the agent has stopped and is only waiting on what it started.
95
86
  def waiting_on_work? = agent_idle? && in_flight?
96
87
 
97
88
  # What the session needs while blocked, what it produced once done,
@@ -12,12 +12,11 @@ require_relative "text_buffer"
12
12
  require_relative "theme"
13
13
 
14
14
  module ClaudeInbox
15
- # State and key handling for the "new session" modal. Pure: returns what
16
- # happened so the App can act on it. Rendering returns plain lines; the
17
- # App wraps them in a box.
15
+ # The `n` form a new session is composed in. Pure: it says what happened
16
+ # and the App acts on it.
18
17
  class NewSessionForm
19
18
  # `value` is a TextBuffer on :text and :multiline fields and the picked
20
- # string on :choice ones — `kind` says which.
19
+ # string on :choice ones.
21
20
  Field = Struct.new(:key, :label, :kind, :value, :choices)
22
21
 
23
22
  # A directory the Directory field offers, shown by its shortest
@@ -42,7 +41,6 @@ module ClaudeInbox
42
41
  def initialize(cwd:, dirs: [], home: Dir.home, clipboard: Images.method(:from_clipboard))
43
42
  @p = Pastel.new(enabled: true)
44
43
  @theme = Theme.new
45
- @palette = Palette.new
46
44
  @home = home
47
45
  @clipboard = clipboard
48
46
  @places = dirs.map { |path| Place.new(path, SessionRequest.label(path, dirs), path.sub(/\A#{Regexp.escape(home)}(?=\/|\z)/, "~")) }
@@ -72,11 +70,9 @@ module ClaudeInbox
72
70
 
73
71
  # => :cancel | :start | :start_and_attach | :changed
74
72
  def press(name, raw)
75
- # `:start`/`:start_and_attach` leave the form up until the spawn is
76
- # known to have worked, so a failure (untrusted directory, `claude`
77
- # missing, ...) can hand the composed prompt back instead of losing
78
- # it; there is no way to cancel a spawn already under way, so every
79
- # key is swallowed until the App reports back.
73
+ # A start leaves the form up until the spawn has worked, so a failure
74
+ # can hand the prompt back. A spawn under way can't be cancelled, so
75
+ # every key is swallowed until the App reports back.
80
76
  return :changed if @busy
81
77
  return confirm_discard_press(name, raw) if @confirm_discard
82
78
  @error = nil
@@ -150,8 +146,7 @@ module ClaudeInbox
150
146
  @defaults = Settings.defaults(cwd, home: @home)
151
147
  end
152
148
 
153
- # Full-screen body: a tall prompt editor, then one row per setting with
154
- # every choice visible. Exactly `height` lines.
149
+ # The whole body, exactly `height` lines.
155
150
  def screen(width, height)
156
151
  inner_w = width - 4
157
152
  fixed = 3 + 2 + 1 + (@fields.size - 1) + 1
@@ -190,11 +185,8 @@ module ClaudeInbox
190
185
  @theme.hints(keys)
191
186
  end
192
187
 
193
- # The App's `claude --bg` call failed after `:start`/`:start_and_attach`
194
- # already handed the form off — an untrusted directory, a stale binary,
195
- # a permissions error. The composed prompt is still here, so the form
196
- # reopens on it with the reason shown instead of the App just logging
197
- # the failure and losing the draft.
188
+ # The App's `claude --bg` call failed after a start. The form shows why,
189
+ # over the prompt it kept.
198
190
  def submission_failed(message)
199
191
  @busy = false
200
192
  @error = message
@@ -326,7 +318,7 @@ module ClaudeInbox
326
318
  f.choices.map { |c|
327
319
  text = (c == DEFAULT) ? default_text(f) : c
328
320
  if c == f.value then (on ? @theme.pill(" #{text} ", :cyan) : @theme.cyan_bold(" #{text} "))
329
- elsif f.key == :color then @palette.paint(" #{text} ", c)
321
+ elsif f.key == :color then Palette.paint(" #{text} ", c)
330
322
  else @p.dim(" #{text} ")
331
323
  end
332
324
  }.join(" ")
@@ -9,7 +9,7 @@ module ClaudeInbox
9
9
  # Closing with 39 (default foreground) rather than 0 (reset everything)
10
10
  # matters: the label is often already bold or italic and a full reset
11
11
  # would strip that back off.
12
- class Palette
12
+ module Palette
13
13
  ANSI = {
14
14
  "red" => 31, "green" => 32, "yellow" => 33,
15
15
  "blue" => 34, "purple" => 35, "cyan" => 36
@@ -22,7 +22,7 @@ module ClaudeInbox
22
22
 
23
23
  RESET = "\e[39m"
24
24
 
25
- def paint(text, name)
25
+ def self.paint(text, name)
26
26
  seq = SEQUENCES[name]
27
27
  seq ? seq + text + RESET : text
28
28
  end
@@ -16,12 +16,13 @@ module ClaudeInbox
16
16
  INTERVAL = 4
17
17
  ACTIONS_INTERVAL = 0.5
18
18
 
19
- def initialize(client:, store:, pull_requests:, reaper:, queue:, snapshot:, actions:,
19
+ def initialize(client:, store:, pull_requests:, transcripts:, reaper:, queue:, snapshot:, actions:,
20
20
  notifier: Notifier.disabled, interval: INTERVAL, clock: -> { Time.now })
21
21
  @client = client
22
22
  @clock = clock
23
23
  @store = store
24
24
  @pull_requests = pull_requests
25
+ @transcripts = transcripts
25
26
  @reaper = reaper
26
27
  @queue = queue
27
28
  @snapshot = snapshot
@@ -54,7 +55,7 @@ module ClaudeInbox
54
55
  # state moved.
55
56
  def once
56
57
  now = @clock.call
57
- sessions = Sessions.load(client: @client, pull_requests: @pull_requests, overrides: @store.pr_overrides)
58
+ sessions = Sessions.load(client: @client, pull_requests: @pull_requests, transcripts: @transcripts, overrides: @store.pr_overrides)
58
59
  doomed = @reaper.due(sessions, now)
59
60
  @store.hide(doomed)
60
61
  publish(sessions)
@@ -54,25 +54,18 @@ module ClaudeInbox
54
54
 
55
55
  # Finds the PRs a session is tied to and keeps their state fresh.
56
56
  #
57
- # Claude Code already does the hard part: the daemon scans each background
58
- # session's transcript for links and writes them to the session's job state
59
- # file, which JobState reads onto `job_state`; `claude agents --json` does
60
- # not expose them.
61
- # It is a link scan, so a session that merely mentions a PR gets it too.
62
- # The daemon keeps one PR per session, so StackLinks adds the rest of a
63
- # stack the session opened.
64
- # Interactive sessions have no job file; for those the store's `pr` override
65
- # is the only source.
57
+ # The links come from the daemon's scan of each background session's
58
+ # transcript, so a session that merely mentions a PR gets it too. The
59
+ # daemon keeps one PR per session, so StackLinks adds the rest of a stack
60
+ # the session opened. Interactive sessions have no job file; for those the
61
+ # store's `pr` override is the only source.
66
62
  #
67
- # State comes first from our own record of resolved PRs, then from
68
- # ~/.claude/gh-pr-status-cache.json (whatever Claude Code last saw), then
69
- # from `gh pr view` for PRs that are still open, at most once per
70
- # REFRESH_AFTER. A merged or closed PR never changes again, so once gh has
71
- # said so, and when, it is written to RESOLVED_PATH and never asked about
72
- # again. Claude Code's cache never says when, and says nothing at all about
73
- # most of a link scan's PRs: it only covers PRs its own sessions opened.
63
+ # A merged or closed PR never changes again, so once gh has said so, and
64
+ # when, it goes in RESOLVED_PATH and gh is never asked about it again.
65
+ # Claude Code's cache never says when, and only covers PRs its own
66
+ # sessions opened.
74
67
  #
75
- # `enrich` never touches gh; it is what stands between `claude agents` and
68
+ # `enrich` never touches gh, since it stands between `claude agents` and
76
69
  # the first frame. `refresh` is the slow half, one network round trip per
77
70
  # open PR, and the poller calls it after the list has already gone up.
78
71
  class PullRequests
@@ -93,11 +86,10 @@ module ClaudeInbox
93
86
  @stacks = StackLinks.new
94
87
  end
95
88
 
96
- # Each session with its `prs` set from what is already known, asking
97
- # nobody. The scanned links come off `job_state`, so a session without
98
- # one (interactive, or forgotten) has none; Sessions.load sees to the
99
- # order. `overrides` maps session key => url for links set by hand; an
100
- # override replaces the scanned list.
89
+ # Each session with its `prs` set from what is already known. The scanned
90
+ # links come off `job_state`, so Sessions.load reads that first.
91
+ # `overrides` maps session key => url for links set by hand; an override
92
+ # replaces the scanned list.
101
93
  def enrich(sessions, overrides)
102
94
  sessions.map do |s|
103
95
  urls = overrides[s.key] ? [overrides[s.key]] : scanned(s.job_state)
@@ -127,7 +119,7 @@ module ClaudeInbox
127
119
  end
128
120
  end
129
121
 
130
- # Parse `gh pr view --json` output. Pure so it can be tested.
122
+ # Parse `gh pr view --json` output.
131
123
  def self.parse(url, json)
132
124
  h = JSON.parse(json)
133
125
  state = (h["state"] == "OPEN" && h["isDraft"]) ? "DRAFT" : h["state"]
@@ -8,14 +8,13 @@ module ClaudeInbox
8
8
  # Reaps sessions that have sat idle past Store::REAP_AFTER.
9
9
  #
10
10
  # The one thing in the inbox that destroys anything without being asked
11
- # first, so the whole of it lives here rather than spread through the poll
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.
11
+ # first, so all of it lives here, along with the append-only log that is
12
+ # the last record a session ever existed once `claude rm` has taken its
13
+ # transcript.
14
14
  #
15
- # Unpushed work is safe by construction. `claude rm` refuses a worktree
16
- # holding commits that aren't pushed and reports a --discard-unpushed token
17
- # to override it; nothing here ever passes that token, so a refusal is the
18
- # end of it. Refusals are logged and retried at most daily.
15
+ # Unpushed work is safe: `claude rm` refuses it, and nothing here
16
+ # overrides that (docs/cli-quirks.md). Refusals are logged and retried at
17
+ # most daily.
19
18
  class Reaper
20
19
  RETRY_AFTER = 24 * 3600
21
20
  DEFAULT_LOG = File.join(Dir.home, ".config", "claude-inbox", "reaped.log")
@@ -27,14 +26,13 @@ module ClaudeInbox
27
26
  @enabled = enabled
28
27
  end
29
28
 
30
- # For --fixture runs and tests: selects nothing, deletes nothing, and
31
- # needs neither a client nor a store to do it.
29
+ # For --fixture runs and tests: selects nothing, deletes nothing.
32
30
  def self.disabled = new(nil, nil, enabled: false)
33
31
 
34
- # Reaps everything due, returning the keys it actually deleted so the
35
- # caller can drop those rows before they reach the store. Refusals come
36
- # back as survivors rather than exceptions: one worktree with unpushed
37
- # commits must not stop the rest of the sweep.
32
+ # Returns the keys it deleted, so the caller can drop those rows before
33
+ # they reach the store. Refusals come back as survivors rather than
34
+ # exceptions: one worktree with unpushed commits must not stop the rest
35
+ # of the sweep.
38
36
  #
39
37
  # Raises if the log cannot be opened, before anything is deleted. No
40
38
  # audit trail, no reaping.