claude-inbox 0.11.0 → 0.11.2

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7e14abc910988446bead1ad4edc841f64fe256e396765cea5a6b77cd6ae28777
4
- data.tar.gz: 8e705e40ce80454dd1d8265f567128fae801286b7112d84f3e63ef00b0228913
3
+ metadata.gz: 86190c0236a4084ec5b00e5044886f6a6b5ab8b51c8e81ac56d7ee9a78619753
4
+ data.tar.gz: 763db348bacbb927ec5c5fae6344a02d449ddbf8aeb7dc4460594168e523b5dd
5
5
  SHA512:
6
- metadata.gz: 895a773cf9237ad41e23826fb4b29f863b55dbbe81954abb9fe3337530b4f9ef77e19642a2a7bfed1bad1710d59e1ca705deabee42205fe52d9e63c7079af1a5
7
- data.tar.gz: 211a75470bb66188394cfbb7b0809f0e685e37e4d6c59366b32a8dc3e90fc4cb5f78d5b6ff4c1b42c78ef68d720dcfd29327446f283f0f2343233d0710505e3a
6
+ metadata.gz: 532e078a269b17bfafd4eb6bbe83893a8be012740bd7fbac7c4625f074c9ce40df5afb9475e0cfbdd09963200265e35dc4335ecfacadfe4a653a83f78033f369
7
+ data.tar.gz: 51632f06152f27dd1708304ecdc4f9eea0ff9dfba18d8ce302f44a8e7b1d260472d56abf80151d1d48f947faf7432ea2ed47133037c43b570972cc171a4b9e24
@@ -47,6 +47,15 @@ module ClaudeInbox
47
47
  select(stops[(idx + delta).clamp(0, stops.size - 1)])
48
48
  end
49
49
 
50
+ # Before a row leaves the list: the stop below it, or above it at the
51
+ # bottom, so working down a section doesn't throw the cursor to the top.
52
+ def select_neighbour
53
+ stops = filtered.selections(@expanded)
54
+ idx = stops.index(@selected)
55
+ rest = stops - [@selected]
56
+ select(rest[idx.clamp(..rest.size - 1)]) if idx && rest.any?
57
+ end
58
+
50
59
  # Tab / Shift-Tab: first selectable row of the next / previous section.
51
60
  def jump_section(dir)
52
61
  sections = filtered
@@ -28,11 +28,11 @@ module ClaudeInbox
28
28
  # it we stay in the inbox and poll, so the new row shows up right away
29
29
  # rather than at the next tick.
30
30
  def start_session(form, attach:)
31
- v = form.values
31
+ v, note = SessionRequest.without_stray_worktree(form.values)
32
32
  notice("starting session…")
33
33
  in_background do
34
34
  id = @client.spawn(**v)
35
- @queue << [:notice, "started #{id}"]
35
+ @queue << [:notice, ["started #{id}", note].compact.join(" — ")]
36
36
  @queue << [:form_started, form]
37
37
  @queue << [:select, id]
38
38
  attach ? @queue << [:attach, id] : @poller.soon
@@ -53,8 +53,8 @@ module ClaudeInbox
53
53
 
54
54
  # Someone at the desk may be mid-thought, so a start from another
55
55
  # device never moves the cursor, attaches or closes what is open.
56
- def remote_started(id, via)
57
- notice("started #{id} from #{via}")
56
+ def remote_started(id, via, note = nil)
57
+ notice(["started #{id} from #{via}", note].compact.join(" — "))
58
58
  @poller.soon
59
59
  end
60
60
  end
@@ -33,7 +33,9 @@ module ClaudeInbox
33
33
 
34
34
  def settle_selected
35
35
  return unless require_storable
36
- @store.settle(@selected.key)
36
+ key = @selected.key
37
+ select_neighbour
38
+ @store.settle(key)
37
39
  notice("settled — u brings it back")
38
40
  end
39
41
 
@@ -44,10 +46,16 @@ module ClaudeInbox
44
46
 
45
47
  def snooze_chosen
46
48
  return @modal = Dialog::Prompt.new(:snooze, @modal.id, "") if @modal.choice == :custom
47
- @store.snooze(@modal.id, @modal.choice)
49
+ snooze_modal_row(@modal.choice)
48
50
  @modal = nil
49
51
  end
50
52
 
53
+ # A poll can move the cursor while the menu is up, so only step off a row still under it.
54
+ def snooze_modal_row(choice)
55
+ select_neighbour if @selected == Store::Selection.row(@modal.id)
56
+ @store.snooze(@modal.id, choice)
57
+ end
58
+
51
59
  def open_alias_editor
52
60
  return unless require_storable && require_writable("rename")
53
61
  current = @store.alias_for(@selected.key) || ""
@@ -67,7 +75,7 @@ module ClaudeInbox
67
75
  elsif @modal.kind == :snooze
68
76
  wake_at = SnoozeTime.parse(value, Time.now)
69
77
  return notice("try 45m, 2h, 3d, 17:00, 5pm, fri, tomorrow or tonight") unless wake_at
70
- @store.snooze(@modal.id, wake_at)
78
+ snooze_modal_row(wake_at)
71
79
  elsif value.empty? || PullRequests.valid_url?(value)
72
80
  @store.set_pr(@modal.id, value)
73
81
  @poller.soon
@@ -58,7 +58,7 @@ module ClaudeInbox
58
58
 
59
59
  def press(name, key)
60
60
  return :cancel if name == :escape || key == "q"
61
- entry = MENU.find { |k, _, _| k == key }
61
+ entry = MENU.assoc(key)
62
62
  return nil unless entry
63
63
  @choice = entry[2]
64
64
  :snooze
@@ -15,11 +15,15 @@ module ClaudeInbox
15
15
  # reaps, since deleting sessions stays with the inbox you can see, and it
16
16
  # exits at once while an inbox holds the writer lock.
17
17
  class Headless
18
+ RECHECK_EVERY = 60
19
+
18
20
  def initialize(client:, store:, pull_requests: PullRequests.new, actions: Actions.new, snapshot: Snapshot.new,
19
- lock: WriterLock.new, updater: nil, notifier: Notifier.disabled)
21
+ lock: WriterLock.new, updater: nil, notifier: Notifier.disabled, recheck_every: RECHECK_EVERY)
20
22
  @lock = lock
21
23
  @snapshot = snapshot
22
24
  @updater = updater
25
+ @recheck_every = recheck_every
26
+ @notice = nil
23
27
  @queue = Queue.new
24
28
  @poller = Poller.new(client: client, store: store, pull_requests: pull_requests, reaper: Reaper.disabled,
25
29
  queue: @queue, snapshot: snapshot, actions: actions, notifier: notifier)
@@ -29,7 +33,7 @@ module ClaudeInbox
29
33
  def run
30
34
  return false unless @lock.take(role: "headless")
31
35
  @poller.start
32
- check_for_update if @updater
36
+ Thread.new { watch_for_updates } if @updater
33
37
  loop { @queue.pop }
34
38
  end
35
39
 
@@ -38,8 +42,19 @@ module ClaudeInbox
38
42
  # After the poller is off, since the check may wait on the network. The
39
43
  # pane cannot swap the process under Claude Code, so the snapshot only
40
44
  # says a newer one is out, and a check that cannot tell says nothing.
45
+ # Re-checked because this process outlives an update applied from a
46
+ # terminal; the updater's day-long cache keeps that off the network.
47
+ def watch_for_updates
48
+ loop do
49
+ check_for_update
50
+ sleep @recheck_every
51
+ end
52
+ end
53
+
41
54
  def check_for_update
42
- @snapshot.update = @updater.check&.notice
55
+ notice = @updater.check&.notice
56
+ return if notice == @notice
57
+ @notice = @snapshot.update = notice
43
58
  @poller.soon
44
59
  rescue Updater::Error
45
60
  nil
@@ -158,6 +158,8 @@ module ClaudeInbox
158
158
  url.to_s.match?(%r{\Ahttps://github\.com/[^/\s]+/[^/\s]+/pull/\d+/?\z})
159
159
  end
160
160
 
161
+ private_class_method :health
162
+
161
163
  private
162
164
 
163
165
  def scanned(job_state)
@@ -140,6 +140,7 @@ code { font: 15px ui-monospace, SFMono-Regular, Menlo, monospace; }
140
140
  <section id="done" class="card" hidden>
141
141
  <p>Started <code id="done-id"></code></p>
142
142
  <p class="muted" id="done-where"></p>
143
+ <p class="muted" id="done-note" hidden></p>
143
144
  <p class="muted" id="done-later">claude.ai/code lists it once it has registered, in a moment.</p>
144
145
  <div class="actions">
145
146
  <a class="button" id="done-url" target="_blank" rel="noopener">Open in Claude</a>
@@ -421,6 +422,8 @@ function started(session, remote) {
421
422
  $("name").value = "";
422
423
  $("done-id").textContent = session.id;
423
424
  $("done-where").textContent = [session.name, session.cwd].filter(Boolean).join(" · ");
425
+ $("done-note").textContent = session.note || "";
426
+ $("done-note").hidden = !session.note;
424
427
  const link = $("done-url");
425
428
  link.hidden = !/^https:\/\//.test(session.url || "");
426
429
  if (!link.hidden) link.href = session.url;
@@ -156,11 +156,12 @@ module ClaudeInbox
156
156
  values[:permission_mode] = capped_mode(values[:permission_mode] || defaults.permission_mode || builtin_mode)
157
157
  paths = images.each_with_index.map { |bytes, i| Images.save(bytes, dir: @images_dir, index: i + 1) }
158
158
  values[:prompt] = SessionRequest.attach(values[:prompt], paths)
159
+ values, note = SessionRequest.without_stray_worktree(values)
159
160
  @queue << [:notice, "remote: starting session…"]
160
161
  id = Http.utf8(@spawn_lock.synchronize { @client.spawn(**values) })
161
162
  url = remote_url(id, wait: values[:remote] ? @bridge_wait : 0)
162
- @queue << [:remote_started, id, via]
163
- {id: id, name: values[:name], cwd: values[:cwd], url: url}
163
+ @queue << [:remote_started, id, via, note].compact
164
+ {id: id, name: values[:name], cwd: values[:cwd], url: url}.merge(note ? {note: note} : {})
164
165
  rescue SessionRequest::Invalid => e
165
166
  raise Http::Error.new(422, e.message, field: e.field)
166
167
  end
@@ -317,7 +317,7 @@ module ClaudeInbox
317
317
 
318
318
  label_w = [width - chrome - Text.width(project_text), 0].max
319
319
  label = Text.truncate(row.label, label_w)
320
- label = style_label(label, row, quiet, sel || (live && row.unread?))
320
+ label = style_label(label, row, quiet, sel || unread)
321
321
  first = " #{marker} #{glyph} " + Text.pad(label, label_w) + " " + meta + " " + project + " "
322
322
 
323
323
  return [Text.pad(first, width)] unless live
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "pathname"
3
4
  require_relative "agents_client"
4
5
 
5
6
  module ClaudeInbox
@@ -77,6 +78,20 @@ module ClaudeInbox
77
78
  [text, rest.join("\n")].reject(&:empty?).join("\n\n")
78
79
  end
79
80
 
81
+ # `claude --bg --worktree` outside a git repository dies before init
82
+ # (docs/cli-quirks.md). A folder above several repos is a fair place to
83
+ # start from, so there the session starts without a worktree and the
84
+ # note says so.
85
+ def self.without_stray_worktree(values)
86
+ return [values, nil] unless values[:worktree] && !inside_repo?(values[:cwd])
87
+ [values.merge(worktree: false), "not in a git repo, so no worktree"]
88
+ end
89
+
90
+ # A worktree or a submodule has a `.git` file rather than a folder.
91
+ def self.inside_repo?(dir)
92
+ Pathname(File.expand_path(dir)).ascend.any? { |d| (d + ".git").exist? }
93
+ end
94
+
80
95
  # A session's cwd may sit inside a worktree another agent is using;
81
96
  # carrying that into a new prompt would spawn the new agent there too,
82
97
  # writing over the same files. Fall back to the repo it was cut from.
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "fileutils"
4
+ require_relative "subprocess"
4
5
 
5
6
  module ClaudeInbox
6
7
  # One snapshot writer per machine: the inbox you can see, else a headless
@@ -25,8 +26,8 @@ module ClaudeInbox
25
26
  file = File.open(@path, File::RDWR | File::CREAT, 0o600)
26
27
  return hold(file, role) if file.flock(File::LOCK_EX | File::LOCK_NB)
27
28
  holder_role, holder = file.read.split
28
- return refuse(file) unless role == "inbox" && holder_role == "headless"
29
- Process.kill("TERM", holder.to_i) if holder.to_i.positive? && holder.to_i != Process.pid
29
+ return refuse(file) unless role == "inbox" && holder_role == "headless" && headless?(holder.to_i)
30
+ Process.kill("TERM", holder.to_i)
30
31
  EVICT_TRIES.times do
31
32
  return hold(file, role) if file.flock(File::LOCK_EX | File::LOCK_NB)
32
33
  sleep 0.1
@@ -39,6 +40,14 @@ module ClaudeInbox
39
40
 
40
41
  private
41
42
 
43
+ # The file can name a pid that no longer holds the lock (#322), which by
44
+ # now may be some other process; only a headless claude-inbox is ended.
45
+ def headless?(pid)
46
+ return false unless pid.positive? && pid != Process.pid
47
+ command = Subprocess.capture("ps", "-o", "command=", "-p", pid.to_s).out
48
+ command.include?("claude-inbox") && command.split.include?("--headless")
49
+ end
50
+
42
51
  # After reading the holder the position sits at the end, and truncate does
43
52
  # not move it, so the write is rewound first or it lands after NUL padding.
44
53
  def hold(file, role)
data/lib/claude_inbox.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ClaudeInbox
4
- VERSION = "0.11.0"
4
+ VERSION = "0.11.2"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: claude-inbox
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.11.0
4
+ version: 0.11.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jordan Byron