claude-inbox 0.14.2 → 0.14.3

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: d987ed72f80dde3f91bd8ddeb3486aa867fb25e8b4ac5be91305654be021c9b0
4
- data.tar.gz: 5a4874f4eee5a49c7bf56921db71f966f935397001a20a58c53a71bec69b013b
3
+ metadata.gz: 20f415de1f5b57baa5330a96c70bb10545c1b1885e419f4174e9a938c0d3c59f
4
+ data.tar.gz: 1a989c77cbb0553c63ace2b341c95a988eb1028e103c09c173bc28d14023cd14
5
5
  SHA512:
6
- metadata.gz: c75cc50ea18dd72da00508eb50066dc6f0a7b9b20a1e8bb6ed042272bac0c45cd963ec31efe570b080dfaf9b4ec191c56143de05a229c2f83212b7baad551728
7
- data.tar.gz: 5ed8c579ff1405d26327bc6c39acc95cd79a5c0eb3706314aeab91f006bdc940d699e6ba41f35396e3af9b372900329ec8978d122e6026a8f766554a7501c806
6
+ metadata.gz: 251df40e737ea85313ceefc9d8dc401f61fed6af73f9d26b0b2767737c66dec57e1fd02fbc46bd057384b17475b3e76dd2de669d77c4ab534df3a2bfe1e6b67c
7
+ data.tar.gz: c3368aa2027a9947e09e2d29a642d6ad4884a1c77392298db820b73b69a06c5970f9a30a916224f8786664d5588b307189edcbe960d9634ea1d93d4cff06ac6d
@@ -29,12 +29,14 @@ module ClaudeInbox
29
29
  # failed attach leaves its reason on screen until a key is pressed.
30
30
  def attach(id)
31
31
  while id
32
- @store.acknowledge(id)
33
32
  @switch.clear
34
33
  from = session_for(id)&.session_id
35
- @terminal.release do
36
- @terminal.pause unless @client.attach(id) { @switch.requested_for?(from) }
34
+ @store.visit(id) do
35
+ @terminal.release do
36
+ @terminal.pause unless @client.attach(id) { @switch.requested_for?(from) }
37
+ end
37
38
  end
39
+ @poller.soon
38
40
  id = @switch.take(@store.actionable_keys)
39
41
  end
40
42
  end
@@ -189,7 +189,7 @@ module ClaudeInbox
189
189
  # Drawn by hand: TTY::Box measures an emoji as one column, which
190
190
  # pushes the right border out on every line that has one.
191
191
  def frame(width, _caret = nil)
192
- inner = [width - 4, WIDTH].min - 2
192
+ inner = [width - 4, fit].min - 2
193
193
  bordered(lines(inner, nil).map { |l| Text.pad(Text.truncate(l, inner), inner) }, inner)
194
194
  end
195
195
 
@@ -58,17 +58,17 @@ module ClaudeInbox
58
58
  sessions = Sessions.load(client: @client, pull_requests: @pull_requests, transcripts: @transcripts, overrides: @store.pr_overrides)
59
59
  doomed = @reaper.due(sessions, now)
60
60
  @store.hide(doomed)
61
- publish(sessions)
61
+ publish(sessions, now)
62
62
  reaped = []
63
63
  begin
64
64
  reaped = @reaper.sweep(sessions.select { |s| doomed.include?(s.key) }, now)
65
65
  ensure
66
66
  @store.release(doomed - reaped)
67
67
  end
68
- publish(sessions) if reaped != doomed
68
+ publish(sessions, now) if reaped != doomed
69
69
  @queue << [:notice, @reaper.report(reaped)] if reaped.any?
70
70
  fresh, moved = @pull_requests.refresh(sessions)
71
- publish(fresh) if moved
71
+ publish(fresh, now) if moved
72
72
  rescue => e
73
73
  @queue << [:error, e.message]
74
74
  end
@@ -82,10 +82,10 @@ module ClaudeInbox
82
82
 
83
83
  # The notifier compares the sections from just before the hand-over, so
84
84
  # a row you woke or unpinned yourself since the last poll says nothing.
85
- def publish(sessions)
85
+ def publish(sessions, fetched_at)
86
86
  now = @clock.call
87
87
  before = @store.sections(now)
88
- @store.update(sessions)
88
+ @store.update(sessions, fetched_at:)
89
89
  after = @store.sections(now)
90
90
  @snapshot.write(after, now)
91
91
  @notifier.deliver(before, after) { |msg| @queue << [:notice, msg] }
@@ -5,11 +5,10 @@ require "json"
5
5
  module ClaudeInbox
6
6
  # The subscription's 5-hour and 7-day usage, read from
7
7
  # ~/.claude/rate_limits.json. Nothing writes that file but the user's own
8
- # status line script (README, "Setup"): `claude agents --json` says nothing
9
- # about limits and there is no CLI command for them, but every session hands
10
- # its status line a `rate_limits` object on each turn, so a one-line tee
11
- # there is the only source that costs no API calls. No file, or a file
12
- # nobody has touched in STALE_AFTER, reads as nothing to show.
8
+ # status line script (README, "Usage bars"): `claude agents --json` says
9
+ # nothing about limits and there is no CLI command for them, but every
10
+ # session hands its status line a `rate_limits` object on each turn, so a
11
+ # one-line tee there is the only source that costs no API calls.
13
12
  class RateLimits
14
13
  DEFAULT_PATH = File.join(Dir.home, ".claude", "rate_limits.json")
15
14
  STALE_AFTER = 15 * 60
@@ -26,10 +25,8 @@ module ClaudeInbox
26
25
  @data = nil
27
26
  end
28
27
 
29
- # The windows the file reports, each with a whole percent and its reset
30
- # time, or nil.
31
- # Parses only when the file has changed, since render asks several times
32
- # a second.
28
+ # The windows the file reports, or nil. Parses only when the file has
29
+ # changed, since render asks several times a second.
33
30
  def windows(now)
34
31
  mtime = File.mtime(@path)
35
32
  return nil if now - mtime > STALE_AFTER
@@ -375,22 +375,21 @@ module ClaudeInbox
375
375
 
376
376
  def meta_for(row, section, now)
377
377
  s = row.session
378
+ age = Text.age(now.to_i - row.spoke_at)
378
379
  base =
379
380
  case section
380
381
  when :snoozed
381
382
  wake = row.parked? ? @theme.purple("parked") : @theme.purple("wakes in #{Text.age(row.wake_at.to_i - now.to_i)}")
382
383
  row.pinned? ? wake + @theme.cyan(" ★") : wake
383
384
  when :settled
384
- @p.dim("#{s.state} · #{Text.age(now.to_i - row.spoke_at)}")
385
+ @p.dim("#{s.state} · #{age}")
385
386
  else
386
387
  if s.interactive?
387
388
  where = s.remote? ? "remote" : "your terminal"
388
- age = Text.age(now.to_i - row.spoke_at)
389
389
  state_badge(s) + @p.dim(" · #{where} · #{age}")
390
390
  else
391
- age = @p.dim(" · " + Text.age(now.to_i - row.spoke_at))
392
391
  remote = s.remote_control? ? @theme.blue(" ⇅") : ""
393
- state_badge(s) + remote + age
392
+ state_badge(s) + remote + @p.dim(" · #{age}")
394
393
  end
395
394
  end
396
395
  pr = pr_badge(s, section)
@@ -102,8 +102,7 @@ module ClaudeInbox
102
102
 
103
103
  def unquote(s)
104
104
  s = s.sub(/\A[>|][-+]?\z/, "")
105
- quoted = s.size >= 2 && ((s.start_with?('"') && s.end_with?('"')) || (s.start_with?("'") && s.end_with?("'")))
106
- s = s[1..-2] if quoted
105
+ s = s[1..-2] if s.match?(/\A(["']).*\1\z/)
107
106
  s.gsub("''", "'")
108
107
  end
109
108
  private_class_method :from_dir, :skills, :commands, :qualify, :plugins, :synced, :frontmatter, :unquote
@@ -60,7 +60,8 @@ module ClaudeInbox
60
60
 
61
61
  def acknowledged_at = @h["acknowledged_at"]
62
62
 
63
- # When you last attached. Never cleared, unlike `acknowledged_at`.
63
+ # When you last saw it: an attach, or a change it made while you were
64
+ # in it. Never cleared, unlike `acknowledged_at`.
64
65
  def last_visited_at = @h["last_visited_at"]
65
66
 
66
67
  def revived_at = @h["revived_at"]
@@ -3,7 +3,8 @@
3
3
  module ClaudeInbox
4
4
  class Store
5
5
  # A session paired with its Entry (nil until a poll has recorded one), with
6
- # the triage rules as its methods; each takes `now`, so a Row is pure.
6
+ # the triage rules as its methods. A rule that depends on the time takes
7
+ # `now`, so a Row is pure.
7
8
  Row = Struct.new(:session, :entry) do
8
9
  def key = session.key
9
10
 
@@ -118,8 +119,8 @@ module ClaudeInbox
118
119
 
119
120
  private
120
121
 
121
- # Finished or needing you, so it carries a read or unread mark. Only a
122
- # session you can attach to counts, since nothing else ever clears it.
122
+ # Only a session you can attach to carries a read or unread mark, since
123
+ # nothing else ever clears it.
123
124
  def markable? = session.actionable? && (session.finished? || session.needs_you?)
124
125
 
125
126
  def unchanged_since?(at) = at && entry.state_since.to_i <= at.to_i
@@ -78,19 +78,46 @@ module ClaudeInbox
78
78
  @sessions = []
79
79
  @hidden = Set.new
80
80
  @forgotten = Set.new
81
+ @visiting = Set.new
82
+ @leaving = {}
81
83
  @entries = load
82
84
  end
83
85
 
84
86
  # A hidden or forgotten key the daemon has stopped listing is released;
85
- # one it still lists stays out of sight.
86
- def update(sessions)
87
- @mutex.synchronize do
87
+ # one it still lists stays out of sight. `fetched_at` is when the list
88
+ # was asked for, which can be well before it is handed over.
89
+ def update(sessions, fetched_at: @clock.call)
90
+ seen = @mutex.synchronize do
88
91
  keys = sessions.map(&:key)
89
92
  @hidden &= keys
90
93
  @forgotten &= keys
91
94
  @sessions = sessions.reject { |s| @hidden.include?(s.key) || @forgotten.include?(s.key) }
92
95
  @entries = self.class.merge_entries(@writable ? @entries : load, @sessions, @clock.call)
96
+ # A list asked for before you left shows only what happened while you
97
+ # were there; the first one asked for after closes the visit.
98
+ left = @leaving.select { |_, left_at| fetched_at >= left_at }.keys
99
+ seen = (@visiting.to_a | @leaving.keys).select { |id| see(id) }
100
+ # A reader reloads state.json each poll, so it holds the visit open
101
+ # until the writer has taken its acknowledge.
102
+ (@writable ? left : left - seen).each { |id| @leaving.delete(id) }
93
103
  save
104
+ seen
105
+ end
106
+ seen.each { |id| relay("acknowledge", id) }
107
+ end
108
+
109
+ # You are in the session for the length of the block, so whatever it
110
+ # does meanwhile is seen, not unread. The first list asked for after you
111
+ # leave still counts: a state that changed in your last seconds there is
112
+ # only recorded then.
113
+ def visit(id)
114
+ acknowledge(id)
115
+ @mutex.synchronize { @visiting << id }
116
+ yield
117
+ ensure
118
+ @mutex.synchronize do
119
+ @visiting.delete(id)
120
+ @leaving[id] = @clock.call
94
121
  end
95
122
  end
96
123
 
@@ -173,9 +200,22 @@ module ClaudeInbox
173
200
  yield Entry.new(@entries[id] ||= Entry.blank(@clock.call).to_h)
174
201
  save
175
202
  end
203
+ relay(action, id, **detail)
204
+ end
205
+
206
+ def relay(action, id, **detail)
176
207
  @relay&.request(action, id, **detail) unless @writable
177
208
  end
178
209
 
210
+ # Acknowledges the state the last poll found, and says whether it had to:
211
+ # `Entry#observe` drops an acknowledgement once the state outruns it.
212
+ def see(id)
213
+ entry = @entries[id] && Entry.new(@entries[id])
214
+ return false if entry.nil? || entry.acknowledged_at
215
+ entry.acknowledge(@clock.call)
216
+ true
217
+ end
218
+
179
219
  def load
180
220
  Records.read(@path)["sessions"] || {}
181
221
  end
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.14.2"
4
+ VERSION = "0.14.3"
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.14.2
4
+ version: 0.14.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jordan Byron