claude-inbox 0.3.0 → 0.4.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 +4 -4
  2. data/README.md +86 -164
  3. data/exe/claude-inbox +44 -0
  4. data/lib/claude_inbox/actions.rb +91 -0
  5. data/lib/claude_inbox/agents_client.rb +11 -7
  6. data/lib/claude_inbox/app.rb +48 -14
  7. data/lib/claude_inbox/headless.rb +31 -0
  8. data/lib/claude_inbox/job_state.rb +4 -4
  9. data/lib/claude_inbox/keymap.rb +0 -2
  10. data/lib/claude_inbox/mod.rb +71 -0
  11. data/lib/claude_inbox/new_session_form.rb +18 -17
  12. data/lib/claude_inbox/painter.rb +3 -4
  13. data/lib/claude_inbox/paste.rb +2 -0
  14. data/lib/claude_inbox/poller.rb +48 -25
  15. data/lib/claude_inbox/pull_requests.rb +31 -17
  16. data/lib/claude_inbox/rate_limits.rb +1 -3
  17. data/lib/claude_inbox/remote/http.rb +10 -7
  18. data/lib/claude_inbox/remote/listener.rb +2 -3
  19. data/lib/claude_inbox/remote/start.rb +2 -3
  20. data/lib/claude_inbox/renderer.rb +4 -4
  21. data/lib/claude_inbox/settings.rb +1 -0
  22. data/lib/claude_inbox/slash_commands.rb +1 -0
  23. data/lib/claude_inbox/snapshot.rb +48 -0
  24. data/lib/claude_inbox/store/entry.rb +0 -2
  25. data/lib/claude_inbox/store/row.rb +7 -3
  26. data/lib/claude_inbox/store.rb +24 -9
  27. data/lib/claude_inbox/switch.rb +40 -0
  28. data/lib/claude_inbox/text_buffer.rb +84 -33
  29. data/lib/claude_inbox/writer_lock.rb +65 -0
  30. data/lib/claude_inbox.rb +1 -10
  31. data/mod/.claude-plugin/plugin.json +20 -0
  32. data/mod/hooks/hooks.json +1 -0
  33. data/mod/hooks/register.tsx +470 -0
  34. data/mod/tests/contract.test.tsx +23 -0
  35. data/mod/tests/pane.test.tsx +199 -0
  36. data/mod/tests/snapshot.fixture.ts +47 -0
  37. data/mod/tests/snooze.test.tsx +31 -0
  38. data/mod/tsconfig.json +3 -0
  39. data/mod/types/index.d.ts +30 -0
  40. metadata +16 -1
@@ -20,6 +20,9 @@ require_relative "reaper"
20
20
  require_relative "poller"
21
21
  require_relative "rate_limits"
22
22
  require_relative "session_request"
23
+ require_relative "actions"
24
+ require_relative "snapshot"
25
+ require_relative "switch"
23
26
 
24
27
  module ClaudeInbox
25
28
  # Owns the terminal and the key loop. The only class allowed to spawn a
@@ -28,19 +31,23 @@ module ClaudeInbox
28
31
  # The reaper deletes sessions and the listener lets other machines in,
29
32
  # so both are off unless `bin/claude-inbox` switches them on.
30
33
  def initialize(client: AgentsClient.new, store: Store.new, pull_requests: PullRequests.new,
31
- rate_limits: RateLimits.new, reaper: Reaper.disabled, listen: nil, out: $stdout, input: $stdin, color: true,
34
+ rate_limits: RateLimits.new, reaper: Reaper.disabled, snapshot: Snapshot.disabled, switch: Switch.disabled,
35
+ actions: Actions.disabled, listen: nil, out: $stdout, input: $stdin, color: true,
32
36
  terminal: Terminal.new(out, input), queue: Queue.new)
33
37
  @client = client
34
38
  @store = store
39
+ @snapshot = snapshot
40
+ @switch = switch
35
41
  @rate_limits = rate_limits
36
42
  @terminal = terminal
37
43
  @color = color
38
44
  @renderer = Renderer.new(color: color)
45
+ @input = input
39
46
  @reader = TTY::Reader.new(input: input, output: out, interrupt: :noop)
40
47
  @queue = queue
41
48
  @poller = Poller.new(client: client, store: store, pull_requests: pull_requests,
42
- reaper: reaper, queue: @queue)
43
- @listener = listen ? Remote::Listener.new(client: client, store: store, queue: @queue, jobs_dir: client.jobs_dir, **listen) : Remote::Listener.disabled
49
+ reaper: reaper, queue: @queue, snapshot: snapshot, actions: actions)
50
+ @listener = listen ? Remote::Listener.new(client: client, store: store, queue: @queue, **listen) : Remote::Listener.disabled
44
51
  @logs = Logs.new(client)
45
52
  @peek = Peek.new(@logs)
46
53
  @selected = nil
@@ -80,8 +87,10 @@ module ClaudeInbox
80
87
  @resize = false
81
88
  @terminal.resized
82
89
  end
83
- render
84
90
  handle_input(input) if input
91
+ # A paste arrives a character per read and shows nothing until it
92
+ # closes, so a frame per character would only stall a long one.
93
+ render unless @paste.pasting?
85
94
  end
86
95
 
87
96
  private
@@ -109,7 +118,6 @@ module ClaudeInbox
109
118
  kind, *rest = @queue.pop(true)
110
119
  case kind
111
120
  when :sessions
112
- @store.update(rest[0])
113
121
  @last_poll = Time.now
114
122
  @error = nil
115
123
  when :error then @error = rest[0]
@@ -128,7 +136,19 @@ module ClaudeInbox
128
136
  # ----- main loop --------------------------------------------------------
129
137
 
130
138
  def main_loop
131
- step(@reader.read_keypress(echo: false, raw: false, nonblock: true)) until @quit
139
+ step(@paste.pasting? ? read_pasted : @reader.read_keypress(echo: false, raw: false, nonblock: true)) until @quit
140
+ end
141
+
142
+ # As much of an open paste as has arrived, read straight off the tty:
143
+ # the reader switches echo off and back on around every character, and
144
+ # on a long paste that is most of the wait.
145
+ def read_pasted
146
+ return nil unless @input.wait_readable(0.1)
147
+ out = +""
148
+ while out.size < 65_536 && @input.wait_readable(0) && (c = @input.getc)
149
+ out << c
150
+ end
151
+ out
132
152
  end
133
153
 
134
154
  def handle_input(raw)
@@ -153,7 +173,9 @@ module ClaudeInbox
153
173
  def render
154
174
  t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
155
175
  now = Time.now
156
- sections = filtered(@store.sections(now))
176
+ all = @store.sections(now)
177
+ @snapshot.write(all, now)
178
+ sections = filtered(all)
157
179
  width, height = @terminal.size
158
180
  body_h = Renderer.body_height(height)
159
181
  ensure_selection(sections)
@@ -229,6 +251,13 @@ module ClaudeInbox
229
251
  false
230
252
  end
231
253
 
254
+ # The edits with no request form, which a second inbox cannot keep.
255
+ def require_writable(verb)
256
+ return true if @store.writable?
257
+ notice("another claude-inbox owns state.json — #{verb} there")
258
+ false
259
+ end
260
+
232
261
  def notice(msg)
233
262
  @notice = [msg, Time.now + 4]
234
263
  end
@@ -389,12 +418,17 @@ module ClaudeInbox
389
418
  notice("settled — u brings it back")
390
419
  end
391
420
 
421
+ # The pane in the attached session may ask for a different one: the
422
+ # attach ends and the next starts without passing through the list. The
423
+ # poller keeps running meanwhile, so the pane's snapshot stays fresh.
392
424
  def attach(id)
393
- @store.acknowledge(id)
394
- @poller.pause
395
- @terminal.release { @client.attach(id) }
396
- ensure
397
- @poller.resume
425
+ while id
426
+ @store.acknowledge(id)
427
+ @switch.clear
428
+ from = @store.sessions.find { |s| s.key == id }&.session_id
429
+ @terminal.release { @client.attach(id) { @switch.requested_for?(from) } }
430
+ id = @switch.take(@store.sessions.select(&:actionable?).map(&:key))
431
+ end
398
432
  end
399
433
 
400
434
  # ----- modals -----------------------------------------------------------
@@ -410,13 +444,13 @@ module ClaudeInbox
410
444
  end
411
445
 
412
446
  def open_alias_editor
413
- return unless require_storable
447
+ return unless require_storable && require_writable("rename")
414
448
  current = @store.alias_for(@selected.key) || ""
415
449
  @modal = Dialog::Prompt.new(:alias, @selected.key, current)
416
450
  end
417
451
 
418
452
  def open_pr_editor
419
- return unless require_storable
453
+ return unless require_storable && require_writable("link a pull request")
420
454
  current = @store.pr_for(@selected.key) || selected_session&.pr&.url || ""
421
455
  @modal = Dialog::Prompt.new(:pr, @selected.key, current)
422
456
  end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "actions"
4
+ require_relative "poller"
5
+ require_relative "pull_requests"
6
+ require_relative "reaper"
7
+ require_relative "snapshot"
8
+ require_relative "writer_lock"
9
+
10
+ module ClaudeInbox
11
+ # The poller with no terminal, for a front end that only reads the
12
+ # snapshot: it polls, writes the file and applies the requests. It never
13
+ # reaps, since deleting sessions stays with the inbox you can see, and it
14
+ # exits at once while an inbox holds the writer lock.
15
+ class Headless
16
+ def initialize(client:, store:, pull_requests: PullRequests.new, snapshot: Snapshot.new, actions: Actions.new,
17
+ lock: WriterLock.new, queue: Queue.new)
18
+ @lock = lock
19
+ @queue = queue
20
+ @poller = Poller.new(client: client, store: store, pull_requests: pull_requests, reaper: Reaper.disabled,
21
+ queue: queue, snapshot: snapshot, actions: actions)
22
+ end
23
+
24
+ # False when the lock is held, by an inbox or another headless process.
25
+ def run
26
+ return false unless @lock.take(role: "headless")
27
+ @poller.start
28
+ loop { @queue.pop }
29
+ end
30
+ end
31
+ end
@@ -38,19 +38,19 @@ module ClaudeInbox
38
38
  # Each background session with its job file read onto `job_state`, or nil
39
39
  # when there is none: interactive sessions have no job file, and neither
40
40
  # does one the daemon has already forgotten. Sessions.load calls this.
41
- def self.enrich(sessions, jobs_dir: DEFAULT_DIR)
41
+ def self.enrich(sessions, jobs_dir:)
42
42
  sessions.map { |s| s.background? ? s.with(job_state: read(s.id, jobs_dir: jobs_dir)) : s }
43
43
  end
44
44
 
45
45
  # => JobState, or nil when there is no readable file for this id.
46
- def self.read(id, jobs_dir: DEFAULT_DIR)
46
+ def self.read(id, jobs_dir:)
47
47
  return nil unless id
48
48
  new(JSON.parse(File.read(File.join(jobs_dir, id, "state.json"))))
49
49
  rescue JSON::ParserError, SystemCallError
50
50
  nil
51
51
  end
52
52
 
53
- attr_reader :detail, :needs, :result, :pr_urls, :color, :intent, :bridge_id
53
+ attr_reader :pr_urls, :color, :intent, :bridge_id
54
54
 
55
55
  def initialize(hash)
56
56
  @detail = hash["detail"]
@@ -96,7 +96,7 @@ module ClaudeInbox
96
96
 
97
97
  private
98
98
 
99
- attr_reader :tempo, :kinds, :tasks, :flags
99
+ attr_reader :detail, :needs, :result, :tempo, :kinds, :tasks, :flags
100
100
 
101
101
  # The agent itself is not thinking. On its own this means little — a
102
102
  # session whose process died leaves the same reading behind — so it only
@@ -38,8 +38,6 @@ module ClaudeInbox
38
38
  "z" => {"o" => :fold_open, "c" => :fold_close, "a" => :fold_toggle}
39
39
  }.freeze
40
40
 
41
- attr_reader :pending
42
-
43
41
  def initialize(clock: -> { Time.now })
44
42
  @clock = clock
45
43
  @pending = nil
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "json"
5
+
6
+ module ClaudeInbox
7
+ # Where the Claude Code pane ships, and a path to it that holds still: the
8
+ # gem's own folder moves with every version, so a link under ~/.config is
9
+ # what settings.json names, refreshed by the installed gem on every launch.
10
+ module Mod
11
+ DIR = File.expand_path("../../mod", __dir__)
12
+ LINK = File.join(Dir.home, ".config", "claude-inbox", "mod")
13
+ NAME = "inbox-pane"
14
+ CHECKOUT = File.exist?(File.expand_path("../../.git", __dir__))
15
+ # What the pane runs headless: a checkout's own, or the gem's from PATH.
16
+ COMMAND = CHECKOUT ? File.expand_path("../../bin/claude-inbox", __dir__) : nil
17
+
18
+ # Points the link at this gem's mod folder and returns the link. A folder
19
+ # of that name that is not a link is left alone. From a checkout only a
20
+ # missing or dangling link is made, unless forced (`--mod-dir` asks for
21
+ # it), so a worktree's launch never takes the path from the installed gem.
22
+ def self.link(link: LINK, dir: DIR, force: false, checkout: CHECKOUT)
23
+ FileUtils.mkdir_p(File.dirname(link))
24
+ return link if File.exist?(link) && !File.symlink?(link)
25
+ if File.symlink?(link)
26
+ return link if File.readlink(link) == dir
27
+ return link if checkout && !force && File.directory?(File.readlink(link))
28
+ File.delete(link)
29
+ end
30
+ File.symlink(dir, link)
31
+ link
32
+ end
33
+
34
+ Installed = Data.define(:added, :removed, :command_set)
35
+
36
+ # Links the mod and adds the link to CLAUDE_CODE_PLUGIN_DIRS in Claude
37
+ # Code's user settings, keeping any folders already there; forced, it
38
+ # drops the other folders holding this plugin, such as a worktree's, and
39
+ # sets the command the pane runs to this copy's. Raises JSON::ParserError
40
+ # rather than rewrite a settings file it cannot read.
41
+ def self.install(settings: File.join(Dir.home, ".claude", "settings.json"), link: LINK, dir: DIR, force: false, command: COMMAND)
42
+ self.link(link: link, dir: dir, force: true)
43
+ json = File.exist?(settings) ? JSON.parse(File.read(settings)) : {}
44
+ env = json["env"] ||= {}
45
+ dirs = env["CLAUDE_CODE_PLUGIN_DIRS"].to_s.split(File::PATH_SEPARATOR).map(&:strip).reject(&:empty?)
46
+ kept = dirs.reject { |d| force && d != link && plugin?(d) }
47
+ wanted = kept.include?(link) ? kept : [*kept, link]
48
+ options = json.dig("pluginConfigs", NAME, "options") || {}
49
+ replace = force && options["command"] != command
50
+ installed = Installed.new(added: !kept.include?(link), removed: dirs - kept, command_set: replace)
51
+ return installed if wanted == dirs && !replace
52
+
53
+ if replace
54
+ command ? options["command"] = command : options.delete("command")
55
+ ((json["pluginConfigs"] ||= {})[NAME] ||= {})["options"] = options
56
+ end
57
+ env["CLAUDE_CODE_PLUGIN_DIRS"] = wanted.join(File::PATH_SEPARATOR)
58
+ FileUtils.mkdir_p(File.dirname(settings))
59
+ File.write("#{settings}.tmp", JSON.pretty_generate(json) + "\n")
60
+ File.rename("#{settings}.tmp", settings)
61
+ installed
62
+ end
63
+
64
+ def self.plugin?(folder)
65
+ JSON.parse(File.read(File.join(folder, ".claude-plugin", "plugin.json")))["name"] == NAME
66
+ rescue Errno::ENOENT, Errno::ENOTDIR, Errno::EACCES, JSON::ParserError
67
+ false
68
+ end
69
+ private_class_method :plugin?
70
+ end
71
+ end
@@ -103,11 +103,6 @@ module ClaudeInbox
103
103
  :changed
104
104
  end
105
105
 
106
- def command_query
107
- return nil unless focused.key == :prompt
108
- focused.value.head[/(?:\A|\s)\/(\S*)\z/, 1]
109
- end
110
-
111
106
  def menu
112
107
  q = command_query
113
108
  return nil if q.nil? || @dismissed == q
@@ -124,26 +119,18 @@ module ClaudeInbox
124
119
  v[:name] = nil if v[:name].strip.empty?
125
120
  v[:cwd] = directory
126
121
  v[:remote] = SessionRequest::FLAGS[v[:remote]]
127
- SessionRequest.resolve(v, defaults(v[:cwd]))
122
+ SessionRequest.resolve(v, defaults)
128
123
  end
129
124
 
130
125
  # Settings resolve against the directory the session will run in, so
131
126
  # they follow the Directory field.
132
- def defaults(cwd = directory)
127
+ def defaults
128
+ cwd = directory
133
129
  return @defaults if @defaults_for == cwd
134
130
  @defaults_for = cwd
135
131
  @defaults = Settings.defaults(cwd, home: @home)
136
132
  end
137
133
 
138
- # Project commands live under the Directory field's path, so they
139
- # follow it as the defaults do.
140
- def commands
141
- cwd = directory
142
- return @commands if @commands_for == cwd
143
- @commands_for = cwd
144
- @commands = SlashCommands.list(cwd: cwd, home: @home)
145
- end
146
-
147
134
  # Full-screen body: a tall prompt editor, then one row per setting with
148
135
  # every choice visible. Exactly `height` lines.
149
136
  def screen(width, height)
@@ -192,6 +179,20 @@ module ClaudeInbox
192
179
 
193
180
  private
194
181
 
182
+ def command_query
183
+ return nil unless focused.key == :prompt
184
+ focused.value.head[/(?:\A|\s)\/(\S*)\z/, 1]
185
+ end
186
+
187
+ # Project commands live under the Directory field's path, so they
188
+ # follow it as the defaults do.
189
+ def commands
190
+ cwd = directory
191
+ return @commands if @commands_for == cwd
192
+ @commands_for = cwd
193
+ @commands = SlashCommands.list(cwd: cwd, home: @home)
194
+ end
195
+
195
196
  # Esc with a prompt typed asks first, so a stray keypress can't lose it.
196
197
  def escape_pressed
197
198
  return :cancel if field(:prompt).value.empty?
@@ -219,7 +220,7 @@ module ClaudeInbox
219
220
 
220
221
  def insert(text)
221
222
  return unless editable?
222
- focused.value.insert((focused.kind == :multiline) ? text : text.tr("\n", " "))
223
+ (focused.kind == :multiline) ? focused.value.paste(text) : focused.value.insert(text.tr("\n", " "))
223
224
  end
224
225
 
225
226
  def menu_press(name)
@@ -7,9 +7,8 @@ module ClaudeInbox
7
7
  # line after a row: in the terminal's last column the cursor stays put
8
8
  # (pending wrap), so EL would eat the glyph just drawn.
9
9
  class Painter
10
- def initialize(out, cursor: TTY::Cursor)
10
+ def initialize(out)
11
11
  @out = out
12
- @cursor = cursor
13
12
  @prev = []
14
13
  end
15
14
 
@@ -17,10 +16,10 @@ module ClaudeInbox
17
16
  buf = +""
18
17
  lines.each_with_index do |line, i|
19
18
  next if @prev[i] == line
20
- buf << @cursor.move_to(0, i) << line
19
+ buf << TTY::Cursor.move_to(0, i) << line
21
20
  end
22
21
  if @prev.size > lines.size
23
- (lines.size...@prev.size).each { |i| buf << @cursor.move_to(0, i) << @cursor.clear_line }
22
+ (lines.size...@prev.size).each { |i| buf << TTY::Cursor.move_to(0, i) << TTY::Cursor.clear_line }
24
23
  end
25
24
  @out.print buf unless buf.empty?
26
25
  @out.flush
@@ -20,6 +20,8 @@ module ClaudeInbox
20
20
  @open = nil
21
21
  end
22
22
 
23
+ def pasting? = !@open.nil?
24
+
23
25
  def feed(raw)
24
26
  out = []
25
27
  rest = raw.to_s
@@ -1,48 +1,46 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "actions"
3
4
  require_relative "sessions"
5
+ require_relative "snapshot"
4
6
 
5
7
  module ClaudeInbox
6
8
  # Asks `claude agents` for the list off the main thread, every INTERVAL
7
- # seconds and on demand, and hands what it finds to App over the shared
8
- # queue: [:sessions, list] for each hand-over, [:error, msg] when a poll
9
- # fails, [:notice, text] when the reaper took something. Nothing here
10
- # touches App's state directly; the queue is the whole of the interface.
11
- # One worker runs every poll, so two can never overlap and publish the
12
- # list out of order.
9
+ # seconds and on demand, puts what it finds in the store and the snapshot,
10
+ # and tells App over the shared queue: [:sessions, list] for each
11
+ # hand-over, [:error, msg] when a poll fails, [:notice, text] when the
12
+ # reaper took something. Between polls it applies what another front end
13
+ # asked for. Nothing here touches App's state directly; the queue is the
14
+ # whole of the interface. One worker runs every poll, so two can never
15
+ # overlap and publish the list out of order.
13
16
  class Poller
14
17
  INTERVAL = 4
18
+ ACTIONS_INTERVAL = 0.5
15
19
 
16
- def initialize(client:, store:, pull_requests:, reaper:, queue:, interval: INTERVAL, clock: -> { Time.now })
20
+ def initialize(client:, store:, pull_requests:, reaper:, queue:, snapshot: Snapshot.disabled, actions: Actions.disabled,
21
+ interval: INTERVAL, clock: -> { Time.now })
17
22
  @client = client
18
23
  @clock = clock
19
24
  @store = store
20
25
  @pull_requests = pull_requests
21
26
  @reaper = reaper
22
27
  @queue = queue
28
+ @snapshot = snapshot
29
+ @actions = actions
23
30
  @interval = interval
24
31
  @wake = Queue.new
25
- @paused = false
26
- @lock = Mutex.new
27
32
  end
28
33
 
29
34
  def start
30
35
  return if @thread&.alive?
31
36
  soon
32
37
  @thread = Thread.new { worker }
38
+ @applier = Thread.new { applier }
33
39
  end
34
40
 
35
- def stop = @thread&.kill
36
-
37
- # Only skips polls while another process holds the terminal; a poll
38
- # already under way finishes. Forking from another thread meanwhile, as
39
- # a remote start does, is fine: Subprocess puts every child in its own
40
- # session, away from the tty.
41
- def pause = @lock.synchronize { @paused = true }
42
-
43
- def resume
44
- @lock.synchronize { @paused = false }
45
- soon
41
+ def stop
42
+ @thread&.kill
43
+ @applier&.kill
46
44
  end
47
45
 
48
46
  def soon = @wake << true
@@ -59,35 +57,60 @@ module ClaudeInbox
59
57
  sessions = Sessions.load(client: @client, pull_requests: @pull_requests, overrides: @store.pr_overrides)
60
58
  doomed = @reaper.due(sessions, now)
61
59
  @store.hide(doomed)
62
- @queue << [:sessions, sessions]
60
+ publish(sessions)
63
61
  reaped = []
64
62
  begin
65
63
  reaped = @reaper.sweep(sessions.select { |s| doomed.include?(s.key) }, now)
66
64
  ensure
67
65
  @store.release(doomed - reaped)
68
66
  end
69
- @queue << [:sessions, sessions] if reaped != doomed
67
+ publish(sessions) if reaped != doomed
70
68
  @queue << [:notice, @reaper.report(reaped)] if reaped.any?
71
69
  fresh, moved = @pull_requests.refresh(sessions)
72
- @queue << [:sessions, fresh] if moved
70
+ publish(fresh) if moved
73
71
  rescue => e
74
72
  @queue << [:error, e.message]
75
73
  end
76
74
 
75
+ # Applies the requests another front end left, and says whether any landed.
76
+ def apply
77
+ return false unless @actions.drain(@store) > 0
78
+ write_snapshot
79
+ true
80
+ end
81
+
77
82
  private
78
83
 
84
+ def publish(sessions)
85
+ @store.update(sessions)
86
+ write_snapshot
87
+ @queue << [:sessions, sessions]
88
+ end
89
+
90
+ def write_snapshot
91
+ now = @clock.call
92
+ @snapshot.write(@store.sections(now), now)
93
+ end
94
+
79
95
  # Past StandardError `once` does not catch, and a dead worker would end
80
96
  # polling with nothing on screen to say so.
81
97
  def worker
82
98
  loop do
83
99
  @wake.pop(timeout: @interval)
84
100
  @wake.clear
85
- once unless paused?
101
+ once
86
102
  rescue SystemStackError, ScriptError, SecurityError => e
87
103
  @queue << [:error, e.message]
88
104
  end
89
105
  end
90
106
 
91
- def paused? = @lock.synchronize { @paused }
107
+ def applier
108
+ loop do
109
+ sleep ACTIONS_INTERVAL
110
+ apply
111
+ rescue => e
112
+ @queue << [:error, e.message]
113
+ end
114
+ end
92
115
  end
93
116
  end
@@ -1,14 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "json"
4
+ require "time"
4
5
  require_relative "records"
5
6
  require_relative "subprocess"
6
7
 
7
8
  module ClaudeInbox
8
9
  # A pull request tied to a session. `state` uses GitHub's vocabulary plus
9
10
  # DRAFT, the same as Claude Code's own cache: OPEN, DRAFT, MERGED, CLOSED,
10
- # or nil when nothing has told us yet.
11
- PullRequest = Struct.new(:number, :url, :state, :title) do
11
+ # or nil when nothing has told us yet. `resolved_at` is when it was merged
12
+ # or closed, in epoch seconds, once gh has said.
13
+ PullRequest = Struct.new(:number, :url, :state, :title, :resolved_at) do
12
14
  def short = number ? "##{number}" : url.to_s.sub(%r{\Ahttps?://(www\.)?}, "")
13
15
 
14
16
  def merged? = state == "MERGED"
@@ -19,6 +21,16 @@ module ClaudeInbox
19
21
  def resolved? = merged? || closed?
20
22
 
21
23
  def known? = !state.nil?
24
+
25
+ # Nothing more to learn: resolved, and gh has said when.
26
+ def final? = resolved? && !resolved_at.nil?
27
+
28
+ # Resolved before `time`, so a session started then can only have mentioned it.
29
+ def resolved_before?(time) = final? && resolved_at < time.to_i
30
+
31
+ # Resolved at or after `time`, which is the only kind of resolution that
32
+ # can answer a session started then.
33
+ def resolved_since?(time) = final? && resolved_at >= time.to_i
22
34
  end
23
35
 
24
36
  # Finds the PRs a session is tied to and keeps their state fresh.
@@ -34,11 +46,10 @@ module ClaudeInbox
34
46
  # State comes first from our own record of resolved PRs, then from
35
47
  # ~/.claude/gh-pr-status-cache.json (whatever Claude Code last saw), then
36
48
  # from `gh pr view` for PRs that are still open, at most once per
37
- # REFRESH_AFTER. A merged or closed PR never changes again, so it is never
38
- # asked about twice — and once gh has said so, it is written to
39
- # RESOLVED_PATH so the next launch does not ask either. Claude Code's cache
40
- # only covers PRs its own sessions opened, and a link scan picks up plenty
41
- # of others.
49
+ # REFRESH_AFTER. A merged or closed PR never changes again, so once gh has
50
+ # said so, and when, it is written to RESOLVED_PATH and never asked about
51
+ # again. Claude Code's cache never says when, and says nothing at all about
52
+ # most of a link scan's PRs: it only covers PRs its own sessions opened.
42
53
  #
43
54
  # `enrich` never touches gh; it is what stands between `claude agents` and
44
55
  # the first frame. `refresh` is the slow half, one network round trip per
@@ -73,13 +84,14 @@ module ClaudeInbox
73
84
  end
74
85
 
75
86
  # Asks gh about every PR on these sessions that is due. Returns the
76
- # sessions with the answers on their `prs` and whether any state changed,
77
- # so the caller knows whether the list is worth publishing again.
87
+ # sessions with the answers on their `prs` and whether any PR changed, so
88
+ # the caller knows whether the list is worth publishing again. A merge
89
+ # date alone counts: it is what lets a PR the cache called merged settle.
78
90
  def refresh(sessions)
79
91
  changed = false
80
92
  fresh = sessions.map do |s|
81
93
  s.with(prs: s.prs.map { |pr|
82
- status(pr.url).tap { |now| changed = true if now.state != pr.state }
94
+ status(pr.url).tap { |now| changed = true if now != pr }
83
95
  })
84
96
  end
85
97
  [fresh, changed]
@@ -89,11 +101,11 @@ module ClaudeInbox
89
101
  def status(url)
90
102
  @mutex.synchronize do
91
103
  pr = @known[url] ||= seed(url)
92
- return pr if pr.resolved? || !due?(url)
104
+ return pr if pr.final? || !due?(url)
93
105
  @checked_at[url] = @clock.call.to_i
94
106
  fresh = fetch(url)
95
107
  return pr unless fresh
96
- remember(fresh) if fresh.resolved?
108
+ remember(fresh) if fresh.final?
97
109
  @known[url] = fresh
98
110
  end
99
111
  end
@@ -102,8 +114,9 @@ module ClaudeInbox
102
114
  def self.parse(url, json)
103
115
  h = JSON.parse(json)
104
116
  state = (h["state"] == "OPEN" && h["isDraft"]) ? "DRAFT" : h["state"]
105
- PullRequest.new(number: h["number"], url: h["url"] || url, state: state, title: h["title"])
106
- rescue JSON::ParserError
117
+ resolved_at = h["closedAt"] && Time.iso8601(h["closedAt"]).to_i
118
+ PullRequest.new(number: h["number"], url: h["url"] || url, state: state, title: h["title"], resolved_at: resolved_at)
119
+ rescue JSON::ParserError, ArgumentError
107
120
  nil
108
121
  end
109
122
 
@@ -126,7 +139,8 @@ module ClaudeInbox
126
139
  def seed(url)
127
140
  number = url[%r{/pull/(\d+)}, 1]&.to_i
128
141
  cached = resolved[url] || claude_cache[url]
129
- PullRequest.new(number: cached&.dig("number") || number, url: url, state: cached&.dig("state"), title: cached&.dig("title"))
142
+ PullRequest.new(number: cached&.dig("number") || number, url: url, state: cached&.dig("state"),
143
+ title: cached&.dig("title"), resolved_at: cached&.dig("resolved_at"))
130
144
  end
131
145
 
132
146
  # Same shape as Claude Code's cache, so `seed` reads both alike.
@@ -140,13 +154,13 @@ module ClaudeInbox
140
154
 
141
155
  # Under @mutex.
142
156
  def remember(pr)
143
- resolved[pr.url] = {"number" => pr.number, "state" => pr.state, "title" => pr.title}
157
+ resolved[pr.url] = {"number" => pr.number, "state" => pr.state, "title" => pr.title, "resolved_at" => pr.resolved_at}
144
158
  return unless @resolved_path
145
159
  Records.save(@resolved_path, resolved)
146
160
  end
147
161
 
148
162
  def fetch(url)
149
- r = Subprocess.capture(@gh, "pr", "view", url, "--json", "number,state,isDraft,title,url")
163
+ r = Subprocess.capture(@gh, "pr", "view", url, "--json", "number,state,isDraft,title,url,closedAt")
150
164
  r.success? ? self.class.parse(url, r.out) : nil
151
165
  rescue SystemCallError
152
166
  nil
@@ -18,9 +18,7 @@ module ClaudeInbox
18
18
  LABELS = {"five_hour" => "session", "seven_day" => "week"}.freeze
19
19
 
20
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
21
+ Window = Data.define(:label, :percent, :resets_at)
24
22
 
25
23
  def initialize(path: DEFAULT_PATH)
26
24
  @path = path