claude-inbox 0.2.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 (60) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +107 -177
  3. data/exe/claude-inbox +67 -4
  4. data/lib/claude_inbox/actions.rb +91 -0
  5. data/lib/claude_inbox/agents_client.rb +68 -62
  6. data/lib/claude_inbox/app.rb +88 -38
  7. data/lib/claude_inbox/config.rb +24 -0
  8. data/lib/claude_inbox/dialog.rb +3 -1
  9. data/lib/claude_inbox/headless.rb +31 -0
  10. data/lib/claude_inbox/images.rb +37 -6
  11. data/lib/claude_inbox/job_state.rb +25 -10
  12. data/lib/claude_inbox/keymap.rb +1 -3
  13. data/lib/claude_inbox/mod.rb +71 -0
  14. data/lib/claude_inbox/new_session_form.rb +43 -52
  15. data/lib/claude_inbox/painter.rb +31 -0
  16. data/lib/claude_inbox/palette.rb +4 -7
  17. data/lib/claude_inbox/paste.rb +2 -0
  18. data/lib/claude_inbox/peek.rb +2 -2
  19. data/lib/claude_inbox/poller.rb +48 -24
  20. data/lib/claude_inbox/pull_requests.rb +36 -22
  21. data/lib/claude_inbox/rate_limits.rb +1 -3
  22. data/lib/claude_inbox/records.rb +7 -2
  23. data/lib/claude_inbox/remote/http.rb +140 -0
  24. data/lib/claude_inbox/remote/icon.png +0 -0
  25. data/lib/claude_inbox/remote/icon.svg +8 -0
  26. data/lib/claude_inbox/remote/listener.rb +435 -0
  27. data/lib/claude_inbox/remote/page.html +477 -0
  28. data/lib/claude_inbox/remote/pairing.rb +111 -0
  29. data/lib/claude_inbox/remote/pairing_dialog.rb +93 -0
  30. data/lib/claude_inbox/remote/start.rb +201 -0
  31. data/lib/claude_inbox/remote.rb +10 -0
  32. data/lib/claude_inbox/renderer.rb +61 -61
  33. data/lib/claude_inbox/session.rb +7 -21
  34. data/lib/claude_inbox/session_request.rb +151 -0
  35. data/lib/claude_inbox/sessions.rb +0 -1
  36. data/lib/claude_inbox/settings.rb +20 -7
  37. data/lib/claude_inbox/slash_commands.rb +1 -0
  38. data/lib/claude_inbox/snapshot.rb +48 -0
  39. data/lib/claude_inbox/store/entry.rb +0 -2
  40. data/lib/claude_inbox/store/row.rb +7 -3
  41. data/lib/claude_inbox/store.rb +24 -11
  42. data/lib/claude_inbox/subprocess.rb +9 -1
  43. data/lib/claude_inbox/switch.rb +40 -0
  44. data/lib/claude_inbox/terminal.rb +9 -3
  45. data/lib/claude_inbox/text.rb +7 -1
  46. data/lib/claude_inbox/text_buffer.rb +84 -33
  47. data/lib/claude_inbox/trust.rb +23 -0
  48. data/lib/claude_inbox/vt_screen.rb +1 -1
  49. data/lib/claude_inbox/writer_lock.rb +65 -0
  50. data/lib/claude_inbox.rb +1 -10
  51. data/mod/.claude-plugin/plugin.json +20 -0
  52. data/mod/hooks/hooks.json +1 -0
  53. data/mod/hooks/register.tsx +470 -0
  54. data/mod/tests/contract.test.tsx +23 -0
  55. data/mod/tests/pane.test.tsx +199 -0
  56. data/mod/tests/snapshot.fixture.ts +47 -0
  57. data/mod/tests/snooze.test.tsx +31 -0
  58. data/mod/tsconfig.json +3 -0
  59. data/mod/types/index.d.ts +30 -0
  60. metadata +29 -1
@@ -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, :tempo, :kinds, :tasks, :pr_urls, :color, :intent, :bridge_id, :flags
53
+ attr_reader :pr_urls, :color, :intent, :bridge_id
54
54
 
55
55
  def initialize(hash)
56
56
  @detail = hash["detail"]
@@ -70,16 +70,22 @@ module ClaudeInbox
70
70
  # a wake, are the only record of Remote Control being on it.
71
71
  def remote_control? = flags.include?("--remote-control")
72
72
 
73
- # The agent itself is not thinking. On its own this means little — a
74
- # session whose process died leaves the same reading behind — so it only
75
- # says something paired with work still in flight.
76
- def agent_idle? = tempo == "idle"
77
-
78
- def in_flight? = tasks.positive?
79
-
80
73
  # True when the agent has stopped and is only waiting on what it started.
81
74
  def waiting_on_work? = agent_idle? && in_flight?
82
75
 
76
+ # What the session needs while blocked, what it produced once done,
77
+ # otherwise its status line; nil before it has said anything.
78
+ def summary(state)
79
+ line =
80
+ case state
81
+ when "blocked" then needs || detail
82
+ when "done" then result || detail
83
+ else detail
84
+ end
85
+ line = line.to_s.gsub(/\s+/, " ").strip
86
+ line.empty? ? nil : line
87
+ end
88
+
83
89
  # "1 shell", "2 agents · 1 shell". Falls back to a bare count for a state
84
90
  # file that counts the open tasks without naming them.
85
91
  def in_flight_label
@@ -90,6 +96,15 @@ module ClaudeInbox
90
96
 
91
97
  private
92
98
 
99
+ attr_reader :detail, :needs, :result, :tempo, :kinds, :tasks, :flags
100
+
101
+ # The agent itself is not thinking. On its own this means little — a
102
+ # session whose process died leaves the same reading behind — so it only
103
+ # says something paired with work still in flight.
104
+ def agent_idle? = tempo == "idle"
105
+
106
+ def in_flight? = tasks.positive?
107
+
93
108
  def count_label = "#{tasks} #{plural("task", tasks)}"
94
109
 
95
110
  def plural(word, n) = (n == 1) ? word : "#{word}s"
@@ -27,7 +27,7 @@ module ClaudeInbox
27
27
  "s" => :snooze, "u" => :wake, "a" => :alias, "x" => :settle, "X" => :stop,
28
28
  :ctrl_x => :delete,
29
29
  "o" => :open_pr, "P" => :link_pr, "t" => :toggle_pin, "w" => :open_remote,
30
- "R" => :refresh, "p" => :toggle_peek, "n" => :new_session,
30
+ "R" => :refresh, "p" => :toggle_peek, "n" => :new_session, "N" => :remote_pairing,
31
31
  :tab => :next_section, :back_tab => :prev_section,
32
32
  "/" => :filter, :escape => :escape,
33
33
  "q" => :quit, :ctrl_c => :quit
@@ -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
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative "agents_client"
4
4
  require_relative "images"
5
+ require_relative "session_request"
5
6
  require_relative "settings"
6
7
  require_relative "slash_commands"
7
8
  require_relative "text"
@@ -17,15 +18,18 @@ module ClaudeInbox
17
18
  # string on :choice ones — `kind` says which.
18
19
  Field = Struct.new(:key, :label, :kind, :value, :choices)
19
20
 
20
- # "default" stays the internal value so spawn_args leaves the flag off;
21
- # the screen shows what that resolves to instead.
21
+ # The screen shows what "default" resolves to; `values` makes it nil.
22
22
  DEFAULT = "default"
23
23
 
24
24
  MENU_ROWS = 6
25
25
 
26
- def initialize(cwd:, pastel:, theme: Theme.new(enabled: pastel.enabled), home: Dir.home, clipboard: Images.method(:from_clipboard))
26
+ # Keys a :choice field answers to, as the step through its choices; an
27
+ # editable field spends these on its own text instead.
28
+ CYCLE = {:left => -1, :right => 1, "h" => -1, "l" => 1, " " => 1}.freeze
29
+
30
+ def initialize(cwd:, pastel:, home: Dir.home, clipboard: Images.method(:from_clipboard))
27
31
  @p = pastel
28
- @theme = theme
32
+ @theme = Theme.new(enabled: pastel.enabled)
29
33
  @home = home
30
34
  @clipboard = clipboard
31
35
  @fields = [
@@ -36,7 +40,7 @@ module ClaudeInbox
36
40
  Field.new(:effort, "Effort", :choice, DEFAULT, AgentsClient::EFFORTS),
37
41
  Field.new(:permission_mode, "Permissions", :choice, DEFAULT, AgentsClient::PERMISSION_MODES),
38
42
  Field.new(:worktree, "Worktree", :choice, "no", %w[no yes]),
39
- Field.new(:remote, "Remote Control", :choice, "no", %w[no yes])
43
+ Field.new(:remote, "Remote Control", :choice, DEFAULT, [DEFAULT, "no", "yes"])
40
44
  ]
41
45
  @focus = 0
42
46
  @error = nil
@@ -99,11 +103,6 @@ module ClaudeInbox
99
103
  :changed
100
104
  end
101
105
 
102
- def command_query
103
- return nil unless focused.key == :prompt
104
- focused.value.head[/(?:\A|\s)\/(\S*)\z/, 1]
105
- end
106
-
107
106
  def menu
108
107
  q = command_query
109
108
  return nil if q.nil? || @dismissed == q
@@ -114,33 +113,24 @@ module ClaudeInbox
114
113
  def picked = menu&.fetch(@pick.clamp(0, menu.size - 1))
115
114
 
116
115
  def values
117
- @fields.to_h { |f| [f.key, f.value.to_s] }.tap do |v|
118
- v[:prompt] = field(:prompt).value.expand { |chip| AgentsClient.mention(chip.path) }.strip
119
- v[:worktree] = v[:worktree] == "yes"
120
- v[:remote] = v[:remote] == "yes"
121
- v[:name] = nil if v[:name].strip.empty?
122
- v[:cwd] = File.expand_path(v[:cwd].strip.empty? ? "." : v[:cwd].strip)
123
- end
116
+ v = @fields.to_h { |f| [f.key, (f.kind == :choice && f.value == DEFAULT) ? nil : f.value.to_s] }
117
+ v[:prompt] = field(:prompt).value.expand { |chip| AgentsClient.mention(chip.path) }.strip
118
+ v[:worktree] = v[:worktree] == "yes"
119
+ v[:name] = nil if v[:name].strip.empty?
120
+ v[:cwd] = directory
121
+ v[:remote] = SessionRequest::FLAGS[v[:remote]]
122
+ SessionRequest.resolve(v, defaults)
124
123
  end
125
124
 
126
125
  # Settings resolve against the directory the session will run in, so
127
126
  # they follow the Directory field.
128
127
  def defaults
129
- cwd = values[:cwd]
128
+ cwd = directory
130
129
  return @defaults if @defaults_for == cwd
131
130
  @defaults_for = cwd
132
131
  @defaults = Settings.defaults(cwd, home: @home)
133
132
  end
134
133
 
135
- # Project commands live under the Directory field's path, so they
136
- # follow it as the defaults do.
137
- def commands
138
- cwd = values[:cwd]
139
- return @commands if @commands_for == cwd
140
- @commands_for = cwd
141
- @commands = SlashCommands.list(cwd: cwd, home: @home)
142
- end
143
-
144
134
  # Full-screen body: a tall prompt editor, then one row per setting with
145
135
  # every choice visible. Exactly `height` lines.
146
136
  def screen(width, height)
@@ -189,6 +179,20 @@ module ClaudeInbox
189
179
 
190
180
  private
191
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
+
192
196
  # Esc with a prompt typed asks first, so a stray keypress can't lose it.
193
197
  def escape_pressed
194
198
  return :cancel if field(:prompt).value.empty?
@@ -216,7 +220,7 @@ module ClaudeInbox
216
220
 
217
221
  def insert(text)
218
222
  return unless editable?
219
- focused.value.insert((focused.kind == :multiline) ? text : text.tr("\n", " "))
223
+ (focused.kind == :multiline) ? focused.value.paste(text) : focused.value.insert(text.tr("\n", " "))
220
224
  end
221
225
 
222
226
  def menu_press(name)
@@ -344,38 +348,25 @@ module ClaudeInbox
344
348
 
345
349
  def field(key) = @fields.find { |f| f.key == key }
346
350
 
351
+ def directory
352
+ path = field(:cwd).value.to_s.strip
353
+ File.expand_path(path.empty? ? "." : path)
354
+ end
355
+
347
356
  def focus_on(key) = @focus = @fields.index { |f| f.key == key }
348
357
 
349
- # Keys a :choice field answers to; an editable field spends these on its
350
- # own text instead.
351
358
  def choose(name, raw)
352
- case name
353
- when :left then cycle(-1)
354
- when :right then cycle(1)
355
- else
356
- case raw
357
- when "h" then cycle(-1)
358
- when "l", " " then cycle(1)
359
- end
360
- end
361
- end
362
-
363
- def cycle(d)
359
+ d = CYCLE[name] || CYCLE[raw]
360
+ return unless d
364
361
  f = focused
365
- return unless f.kind == :choice
366
362
  f.value = f.choices[(f.choices.index(f.value) + d) % f.choices.size]
367
363
  end
368
364
 
369
365
  def submit(attach:)
370
- v = values
371
- if v[:prompt].empty?
372
- @error = "a prompt is required"
373
- focus_on(:prompt)
374
- return :changed
375
- end
376
- unless File.directory?(v[:cwd])
377
- @error = "no such directory: #{v[:cwd]}"
378
- focus_on(:cwd)
366
+ field, message = SessionRequest.problem(values)
367
+ if field
368
+ @error = message
369
+ focus_on(field)
379
370
  return :changed
380
371
  end
381
372
  @busy = true
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tty-cursor"
4
+
5
+ module ClaudeInbox
6
+ # Diffs successive frames and writes only changed rows. No erase-to-end-of-
7
+ # line after a row: in the terminal's last column the cursor stays put
8
+ # (pending wrap), so EL would eat the glyph just drawn.
9
+ class Painter
10
+ def initialize(out)
11
+ @out = out
12
+ @prev = []
13
+ end
14
+
15
+ def paint(lines)
16
+ buf = +""
17
+ lines.each_with_index do |line, i|
18
+ next if @prev[i] == line
19
+ buf << TTY::Cursor.move_to(0, i) << line
20
+ end
21
+ if @prev.size > lines.size
22
+ (lines.size...@prev.size).each { |i| buf << TTY::Cursor.move_to(0, i) << TTY::Cursor.clear_line }
23
+ end
24
+ @out.print buf unless buf.empty?
25
+ @out.flush
26
+ @prev = lines
27
+ end
28
+
29
+ def invalidate = @prev = []
30
+ end
31
+ end
@@ -17,13 +17,10 @@ module ClaudeInbox
17
17
 
18
18
  INDEXED = {"orange" => 208, "pink" => 205}.freeze
19
19
 
20
- RESET = "\e[39m"
20
+ SEQUENCES = ANSI.transform_values { |n| "\e[#{n}m" }
21
+ .merge(INDEXED.transform_values { |n| "\e[38;5;#{n}m" }).freeze
21
22
 
22
- def self.sequence(name)
23
- return "\e[#{ANSI[name]}m" if ANSI.key?(name)
24
- return "\e[38;5;#{INDEXED[name]}m" if INDEXED.key?(name)
25
- nil
26
- end
23
+ RESET = "\e[39m"
27
24
 
28
25
  def initialize(enabled: true)
29
26
  @enabled = enabled
@@ -31,7 +28,7 @@ module ClaudeInbox
31
28
 
32
29
  def paint(text, name)
33
30
  return text unless @enabled
34
- seq = self.class.sequence(name)
31
+ seq = SEQUENCES[name]
35
32
  seq ? seq + text + RESET : text
36
33
  end
37
34
  end
@@ -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
@@ -44,9 +44,9 @@ module ClaudeInbox
44
44
 
45
45
  # `row` is the selected row as the frame shows it, or nil when nothing is
46
46
  # selected. Answers nil when there is no pane to paint.
47
- def view(row, height)
47
+ def view(row, body_h)
48
48
  return nil unless @open && @selected&.row?
49
- View.new(scrolled(body(row), height - 2), row&.label || @selected.key, subtitle(row))
49
+ View.new(scrolled(body(row), body_h), row&.label || @selected.key, subtitle(row))
50
50
  end
51
51
 
52
52
  private
@@ -1,47 +1,46 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "reaper"
3
+ require_relative "actions"
4
4
  require_relative "sessions"
5
+ require_relative "snapshot"
5
6
 
6
7
  module ClaudeInbox
7
8
  # Asks `claude agents` for the list off the main thread, every INTERVAL
8
- # seconds and on demand, and hands what it finds to App over the shared
9
- # queue: [:sessions, list] for each hand-over, [:error, msg] when a poll
10
- # fails, [:notice, text] when the reaper took something. Nothing here
11
- # touches App's state directly; the queue is the whole of the interface.
12
- # One worker runs every poll, so two can never overlap and publish the
13
- # 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.
14
16
  class Poller
15
17
  INTERVAL = 4
18
+ ACTIONS_INTERVAL = 0.5
16
19
 
17
- 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 })
18
22
  @client = client
19
23
  @clock = clock
20
24
  @store = store
21
25
  @pull_requests = pull_requests
22
26
  @reaper = reaper
23
27
  @queue = queue
28
+ @snapshot = snapshot
29
+ @actions = actions
24
30
  @interval = interval
25
31
  @wake = Queue.new
26
- @paused = false
27
- @lock = Mutex.new
28
32
  end
29
33
 
30
34
  def start
31
35
  return if @thread&.alive?
32
36
  soon
33
37
  @thread = Thread.new { worker }
38
+ @applier = Thread.new { applier }
34
39
  end
35
40
 
36
- def stop = @thread&.kill
37
-
38
- # Skips the poll rather than the timer, so nothing forks `claude` while
39
- # another process holds the terminal. A poll already under way finishes.
40
- def pause = @lock.synchronize { @paused = true }
41
-
42
- def resume
43
- @lock.synchronize { @paused = false }
44
- soon
41
+ def stop
42
+ @thread&.kill
43
+ @applier&.kill
45
44
  end
46
45
 
47
46
  def soon = @wake << true
@@ -58,35 +57,60 @@ module ClaudeInbox
58
57
  sessions = Sessions.load(client: @client, pull_requests: @pull_requests, overrides: @store.pr_overrides)
59
58
  doomed = @reaper.due(sessions, now)
60
59
  @store.hide(doomed)
61
- @queue << [:sessions, sessions]
60
+ publish(sessions)
62
61
  reaped = []
63
62
  begin
64
63
  reaped = @reaper.sweep(sessions.select { |s| doomed.include?(s.key) }, now)
65
64
  ensure
66
65
  @store.release(doomed - reaped)
67
66
  end
68
- @queue << [:sessions, sessions] if reaped != doomed
67
+ publish(sessions) if reaped != doomed
69
68
  @queue << [:notice, @reaper.report(reaped)] if reaped.any?
70
69
  fresh, moved = @pull_requests.refresh(sessions)
71
- @queue << [:sessions, fresh] if moved
70
+ publish(fresh) if moved
72
71
  rescue => e
73
72
  @queue << [:error, e.message]
74
73
  end
75
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
+
76
82
  private
77
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
+
78
95
  # Past StandardError `once` does not catch, and a dead worker would end
79
96
  # polling with nothing on screen to say so.
80
97
  def worker
81
98
  loop do
82
99
  @wake.pop(timeout: @interval)
83
100
  @wake.clear
84
- once unless paused?
101
+ once
85
102
  rescue SystemStackError, ScriptError, SecurityError => e
86
103
  @queue << [:error, e.message]
87
104
  end
88
105
  end
89
106
 
90
- 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
91
115
  end
92
116
  end