claude-inbox 0.14.1 → 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 +4 -4
- data/lib/claude_inbox/app/attaching.rb +5 -3
- data/lib/claude_inbox/dialog.rb +4 -3
- data/lib/claude_inbox/poller.rb +5 -5
- data/lib/claude_inbox/rate_limits.rb +6 -9
- data/lib/claude_inbox/renderer.rb +3 -4
- data/lib/claude_inbox/session.rb +18 -3
- data/lib/claude_inbox/slash_commands.rb +1 -2
- data/lib/claude_inbox/store/entry.rb +2 -1
- data/lib/claude_inbox/store/row.rb +4 -3
- data/lib/claude_inbox/store.rb +43 -3
- data/lib/claude_inbox.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 20f415de1f5b57baa5330a96c70bb10545c1b1885e419f4174e9a938c0d3c59f
|
|
4
|
+
data.tar.gz: 1a989c77cbb0553c63ace2b341c95a988eb1028e103c09c173bc28d14023cd14
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
@
|
|
36
|
-
@terminal.
|
|
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
|
data/lib/claude_inbox/dialog.rb
CHANGED
|
@@ -74,7 +74,8 @@ module ClaudeInbox
|
|
|
74
74
|
end
|
|
75
75
|
|
|
76
76
|
# `o` open and `y` copy on a session with several PRs: the headline
|
|
77
|
-
# first,
|
|
77
|
+
# first, then the rest in Session#prs_by_status order; the same key
|
|
78
|
+
# again takes the headline. The PR is read from `choice`.
|
|
78
79
|
class PrPicker < Dialog
|
|
79
80
|
MARGIN = 6
|
|
80
81
|
KEYS = {open: "o", copy: "y"}.freeze
|
|
@@ -83,7 +84,7 @@ module ClaudeInbox
|
|
|
83
84
|
|
|
84
85
|
def initialize(kind, session)
|
|
85
86
|
super(kind, session.key)
|
|
86
|
-
@prs = [session.pr, *session.
|
|
87
|
+
@prs = [session.pr, *session.prs_by_status.reject { |pr| pr.equal?(session.pr) }].first(9)
|
|
87
88
|
end
|
|
88
89
|
|
|
89
90
|
def title = " #{kind.to_s.capitalize} pull request "
|
|
@@ -188,7 +189,7 @@ module ClaudeInbox
|
|
|
188
189
|
# Drawn by hand: TTY::Box measures an emoji as one column, which
|
|
189
190
|
# pushes the right border out on every line that has one.
|
|
190
191
|
def frame(width, _caret = nil)
|
|
191
|
-
inner = [width - 4,
|
|
192
|
+
inner = [width - 4, fit].min - 2
|
|
192
193
|
bordered(lines(inner, nil).map { |l| Text.pad(Text.truncate(l, inner), inner) }, inner)
|
|
193
194
|
end
|
|
194
195
|
|
data/lib/claude_inbox/poller.rb
CHANGED
|
@@ -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, "
|
|
9
|
-
# about limits and there is no CLI command for them, but every
|
|
10
|
-
# its status line a `rate_limits` object on each turn, so a
|
|
11
|
-
# there is the only source that costs no API calls.
|
|
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,
|
|
30
|
-
#
|
|
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} · #{
|
|
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)
|
data/lib/claude_inbox/session.rb
CHANGED
|
@@ -99,19 +99,34 @@ module ClaudeInbox
|
|
|
99
99
|
|
|
100
100
|
def project = cwd ? File.basename(cwd) : ""
|
|
101
101
|
|
|
102
|
-
# The PR you would act on. One
|
|
103
|
-
# only mentioned (as Row#settled? judges), so it loses to any live one.
|
|
102
|
+
# The PR you would act on. One only mentioned loses to any live one.
|
|
104
103
|
def pr = prs.each_with_index.min_by { |pr, i| [headline_rank(pr), i] }&.first
|
|
105
104
|
|
|
105
|
+
# Oldest first so a stack reads up from its base; trouble is the
|
|
106
|
+
# headline's job, not this order's.
|
|
107
|
+
def prs_by_status = prs.each_with_index.sort_by { |pr, i| [*status_order(pr), i] }.map(&:first)
|
|
108
|
+
|
|
106
109
|
private
|
|
107
110
|
|
|
108
111
|
def headline_rank(pr)
|
|
109
|
-
if
|
|
112
|
+
if mentioned?(pr) then 4
|
|
110
113
|
elsif pr.needs_you? then 0
|
|
111
114
|
elsif pr.health == "CONFLICTING" then 1
|
|
112
115
|
elsif !pr.resolved? then 2
|
|
113
116
|
else 3
|
|
114
117
|
end
|
|
115
118
|
end
|
|
119
|
+
|
|
120
|
+
def status_order(pr)
|
|
121
|
+
age = pr.number.to_i
|
|
122
|
+
if mentioned?(pr) then [3, age]
|
|
123
|
+
elsif pr.state == "OPEN" then [0, age]
|
|
124
|
+
elsif pr.state == "DRAFT" then [1, age]
|
|
125
|
+
else [2, -age]
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Unknown, or resolved before the session began (as Row#settled? judges).
|
|
130
|
+
def mentioned?(pr) = !pr.known? || pr.resolved_before?(created_at)
|
|
116
131
|
end
|
|
117
132
|
end
|
|
@@ -102,8 +102,7 @@ module ClaudeInbox
|
|
|
102
102
|
|
|
103
103
|
def unquote(s)
|
|
104
104
|
s = s.sub(/\A[>|][-+]?\z/, "")
|
|
105
|
-
|
|
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
|
|
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
|
|
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
|
-
#
|
|
122
|
-
#
|
|
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
|
data/lib/claude_inbox/store.rb
CHANGED
|
@@ -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
|
-
|
|
87
|
-
|
|
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