claude-inbox 0.1.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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +442 -0
- data/exe/claude-inbox +33 -0
- data/lib/claude_inbox/agents_client.rb +224 -0
- data/lib/claude_inbox/app.rb +533 -0
- data/lib/claude_inbox/debug.rb +16 -0
- data/lib/claude_inbox/dialog.rb +105 -0
- data/lib/claude_inbox/images.rb +58 -0
- data/lib/claude_inbox/job_state.rb +89 -0
- data/lib/claude_inbox/keymap.rb +71 -0
- data/lib/claude_inbox/logs.rb +66 -0
- data/lib/claude_inbox/mouse.rb +34 -0
- data/lib/claude_inbox/new_session_form.rb +363 -0
- data/lib/claude_inbox/palette.rb +40 -0
- data/lib/claude_inbox/paste.rb +42 -0
- data/lib/claude_inbox/peek.rb +81 -0
- data/lib/claude_inbox/poller.rb +98 -0
- data/lib/claude_inbox/pull_requests.rb +155 -0
- data/lib/claude_inbox/rate_limits.rb +48 -0
- data/lib/claude_inbox/reaper.rb +103 -0
- data/lib/claude_inbox/records.rb +28 -0
- data/lib/claude_inbox/renderer.rb +447 -0
- data/lib/claude_inbox/session.rb +100 -0
- data/lib/claude_inbox/sessions.rb +18 -0
- data/lib/claude_inbox/settings.rb +39 -0
- data/lib/claude_inbox/slash_commands.rb +116 -0
- data/lib/claude_inbox/store/entry.rb +144 -0
- data/lib/claude_inbox/store/row.rb +101 -0
- data/lib/claude_inbox/store/sections.rb +47 -0
- data/lib/claude_inbox/store/selection.rb +16 -0
- data/lib/claude_inbox/store.rb +156 -0
- data/lib/claude_inbox/subprocess.rb +53 -0
- data/lib/claude_inbox/terminal.rb +101 -0
- data/lib/claude_inbox/text.rb +98 -0
- data/lib/claude_inbox/text_buffer.rb +216 -0
- data/lib/claude_inbox/theme.rb +35 -0
- data/lib/claude_inbox/vt_screen.rb +138 -0
- data/lib/claude_inbox.rb +14 -0
- metadata +163 -0
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ClaudeInbox
|
|
4
|
+
# Splits bracketed pastes (what the terminal sends once App turns on
|
|
5
|
+
# \e[?2004h) out of the raw keypress stream. Pure: no terminal, no IO.
|
|
6
|
+
#
|
|
7
|
+
# A paste can straddle several non-blocking reads, so this keeps the open
|
|
8
|
+
# one between calls and only hands it over once its closing bracket has
|
|
9
|
+
# arrived. Everything outside the brackets comes back as keys to handle
|
|
10
|
+
# as before.
|
|
11
|
+
#
|
|
12
|
+
# p = Paste.new
|
|
13
|
+
# p.feed("j\e[200~hel") # => [[:key, "j"]]
|
|
14
|
+
# p.feed("lo\e[201~k") # => [[:paste, "hello"], [:key, "k"]]
|
|
15
|
+
class Paste
|
|
16
|
+
OPEN = "\e[200~"
|
|
17
|
+
CLOSE = "\e[201~"
|
|
18
|
+
|
|
19
|
+
def initialize
|
|
20
|
+
@open = nil
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def feed(raw)
|
|
24
|
+
out = []
|
|
25
|
+
rest = raw.to_s
|
|
26
|
+
until rest.empty?
|
|
27
|
+
if @open
|
|
28
|
+
body, close, rest = rest.partition(CLOSE)
|
|
29
|
+
@open << body
|
|
30
|
+
break if close.empty?
|
|
31
|
+
out << [:paste, @open]
|
|
32
|
+
@open = nil
|
|
33
|
+
else
|
|
34
|
+
keys, open, rest = rest.partition(OPEN)
|
|
35
|
+
out << [:key, keys] unless keys.empty?
|
|
36
|
+
@open = +"" unless open.empty?
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
out
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ClaudeInbox
|
|
4
|
+
# The peek pane: whether it is open, which row it is on, how far back it
|
|
5
|
+
# is scrolled, and what to paint for that. The lines themselves come from
|
|
6
|
+
# Logs, which it asks on every selection change so that opening the pane
|
|
7
|
+
# lands on a warm cache. Main-thread state only.
|
|
8
|
+
class Peek
|
|
9
|
+
TERMINAL_NOTE = "This is a claude you opened in a terminal yourself. The daemon can't attach to it, read its output, or stop it from outside. Switch to that window."
|
|
10
|
+
REMOTE_NOTE = "This is a Remote Control session driven from claude.ai/code. The daemon can't attach to it or read its output from here. Open it in the web or mobile app instead."
|
|
11
|
+
|
|
12
|
+
# What Renderer paints: the body lines, the title bar, the dim line under it.
|
|
13
|
+
View = Struct.new(:lines, :title, :subtitle)
|
|
14
|
+
|
|
15
|
+
def initialize(logs)
|
|
16
|
+
@logs = logs
|
|
17
|
+
@open = false
|
|
18
|
+
@offset = 0
|
|
19
|
+
@selected = nil
|
|
20
|
+
@session = nil
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def open? = @open
|
|
24
|
+
|
|
25
|
+
def toggle
|
|
26
|
+
@open = !@open
|
|
27
|
+
@logs.want(@session.id) if @open && @session&.actionable?
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def close = @open = false
|
|
31
|
+
|
|
32
|
+
# Called on every selection change; a fold has no session.
|
|
33
|
+
def select(selection, session)
|
|
34
|
+
@selected = selection
|
|
35
|
+
@session = session
|
|
36
|
+
@offset = 0
|
|
37
|
+
@logs.want(session.id) if session&.actionable?
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Positive scrolls back into history, negative towards the tail.
|
|
41
|
+
def scroll(delta)
|
|
42
|
+
@offset = [@offset + delta, 0].max
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# `row` is the selected row as the frame shows it, or nil when nothing is
|
|
46
|
+
# selected. Answers nil when there is no pane to paint.
|
|
47
|
+
def view(row, height)
|
|
48
|
+
return nil unless @open && @selected&.row?
|
|
49
|
+
View.new(scrolled(body(row), height - 2), row&.label || @selected.key, subtitle(row))
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def body(row)
|
|
55
|
+
return ["(nothing selected)"] unless row
|
|
56
|
+
return interactive_note(row.session) if row.session.interactive?
|
|
57
|
+
@logs.cached(row.session.id) || ["(loading…)"]
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def interactive_note(s)
|
|
61
|
+
[s.remote? ? REMOTE_NOTE : TERMINAL_NOTE, "", "pid #{s.pid} · #{s.cwd}", "session #{s.session_id}"]
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def subtitle(row)
|
|
65
|
+
return nil unless row
|
|
66
|
+
s = row.session
|
|
67
|
+
parts = [s.effective_state, s.status, s.waiting_for, s.id, s.started_at&.strftime("started %b %-d %H:%M")].compact
|
|
68
|
+
parts += s.prs.map { |pr| "#{pr.short} #{pr.state&.downcase || "?"}" }
|
|
69
|
+
parts.join(" · ")
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Lines are shown tail-first; the offset scrolls back into history and is
|
|
73
|
+
# clamped to however much history there is.
|
|
74
|
+
def scrolled(lines, view_h)
|
|
75
|
+
return lines if @offset.zero?
|
|
76
|
+
max_off = [lines.size - view_h, 0].max
|
|
77
|
+
@offset = [@offset, max_off].min
|
|
78
|
+
lines[0, lines.size - @offset]
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "reaper"
|
|
4
|
+
require_relative "sessions"
|
|
5
|
+
require_relative "store"
|
|
6
|
+
|
|
7
|
+
module ClaudeInbox
|
|
8
|
+
# Asks `claude agents` for the list off the main thread, every INTERVAL
|
|
9
|
+
# seconds and on demand, and hands what it finds to App over the shared
|
|
10
|
+
# queue: [:sessions, list] for each hand-over, [:error, msg] when a poll
|
|
11
|
+
# fails, [:notice, text] when the reaper took something. Nothing here
|
|
12
|
+
# touches App's state directly; the queue is the whole of the interface.
|
|
13
|
+
# One worker runs every poll, so two can never overlap and publish the
|
|
14
|
+
# list out of order.
|
|
15
|
+
class Poller
|
|
16
|
+
INTERVAL = 4
|
|
17
|
+
|
|
18
|
+
def initialize(client:, store:, pull_requests:, jobs_dir:, reaper:, queue:, interval: INTERVAL)
|
|
19
|
+
@client = client
|
|
20
|
+
@store = store
|
|
21
|
+
@pull_requests = pull_requests
|
|
22
|
+
@jobs_dir = jobs_dir
|
|
23
|
+
@reaper = reaper
|
|
24
|
+
@queue = queue
|
|
25
|
+
@interval = interval
|
|
26
|
+
@wake = Queue.new
|
|
27
|
+
@paused = false
|
|
28
|
+
@lock = Mutex.new
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def start
|
|
32
|
+
return if @thread&.alive?
|
|
33
|
+
soon
|
|
34
|
+
@thread = Thread.new { worker }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def stop = @thread&.kill
|
|
38
|
+
|
|
39
|
+
# Skips the poll rather than the timer, so nothing forks `claude` while
|
|
40
|
+
# another process holds the terminal. A poll already under way finishes.
|
|
41
|
+
def pause = @lock.synchronize { @paused = true }
|
|
42
|
+
|
|
43
|
+
def resume
|
|
44
|
+
@lock.synchronize { @paused = false }
|
|
45
|
+
soon
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def soon = @wake << true
|
|
49
|
+
|
|
50
|
+
# The rows go up as soon as `claude agents` answers, and the slow calls
|
|
51
|
+
# (`claude rm`, a dozen serial `gh pr view`s) follow: that is the
|
|
52
|
+
# difference between the inbox appearing at once and five seconds later.
|
|
53
|
+
# Every hand-over is the whole list; the store hides the rows the reaper
|
|
54
|
+
# is about to take, so a list missing a key can only mean the daemon
|
|
55
|
+
# dropped it. The gh refresh comes last and publishes again only if a PR
|
|
56
|
+
# state moved.
|
|
57
|
+
def once
|
|
58
|
+
now = Time.now
|
|
59
|
+
sessions = Sessions.load(client: @client, jobs_dir: @jobs_dir, pull_requests: @pull_requests, overrides: @store.pr_overrides)
|
|
60
|
+
doomed = @reaper.due(sessions, now)
|
|
61
|
+
@store.hide(doomed)
|
|
62
|
+
@queue << [:sessions, sessions]
|
|
63
|
+
reaped = []
|
|
64
|
+
begin
|
|
65
|
+
reaped = @reaper.sweep(sessions.select { |s| doomed.include?(s.key) }, now)
|
|
66
|
+
ensure
|
|
67
|
+
@store.release(doomed - reaped)
|
|
68
|
+
end
|
|
69
|
+
@queue << [:sessions, sessions] if reaped != doomed
|
|
70
|
+
notice_reaped(reaped) if reaped.any?
|
|
71
|
+
fresh, moved = @pull_requests.refresh(sessions)
|
|
72
|
+
@queue << [:sessions, fresh] if moved
|
|
73
|
+
rescue => e
|
|
74
|
+
@queue << [:error, e.message]
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
private
|
|
78
|
+
|
|
79
|
+
# Past StandardError `once` does not catch, and a dead worker would end
|
|
80
|
+
# polling with nothing on screen to say so.
|
|
81
|
+
def worker
|
|
82
|
+
loop do
|
|
83
|
+
@wake.pop(timeout: @interval)
|
|
84
|
+
@wake.clear
|
|
85
|
+
once unless paused?
|
|
86
|
+
rescue SystemStackError, ScriptError, SecurityError => e
|
|
87
|
+
@queue << [:error, e.message]
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def paused? = @lock.synchronize { @paused }
|
|
92
|
+
|
|
93
|
+
def notice_reaped(keys)
|
|
94
|
+
word = (keys.size == 1) ? "session" : "sessions"
|
|
95
|
+
@queue << [:notice, "reaped #{keys.size} #{word} idle over #{Store::REAP_AFTER / 86_400}d — see #{@reaper.log_path}"]
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require_relative "records"
|
|
5
|
+
require_relative "subprocess"
|
|
6
|
+
|
|
7
|
+
module ClaudeInbox
|
|
8
|
+
# A pull request tied to a session. `state` uses GitHub's vocabulary plus
|
|
9
|
+
# 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
|
|
12
|
+
def short = number ? "##{number}" : url.to_s.sub(%r{\Ahttps?://(www\.)?}, "")
|
|
13
|
+
|
|
14
|
+
def merged? = state == "MERGED"
|
|
15
|
+
|
|
16
|
+
def closed? = state == "CLOSED"
|
|
17
|
+
|
|
18
|
+
# Merged or closed: nothing more will happen to it.
|
|
19
|
+
def resolved? = merged? || closed?
|
|
20
|
+
|
|
21
|
+
def known? = !state.nil?
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Finds the PRs a session is tied to and keeps their state fresh.
|
|
25
|
+
#
|
|
26
|
+
# Claude Code already does the hard part: the daemon scans each background
|
|
27
|
+
# session's transcript for links and writes them to the session's job state
|
|
28
|
+
# file, which JobState reads onto `job_state`; `claude agents --json` does
|
|
29
|
+
# not expose them.
|
|
30
|
+
# It is a link scan, so a session that merely mentions a PR gets it too.
|
|
31
|
+
# Interactive sessions have no job file; for those the store's `pr` override
|
|
32
|
+
# is the only source.
|
|
33
|
+
#
|
|
34
|
+
# State comes first from our own record of resolved PRs, then from
|
|
35
|
+
# ~/.claude/gh-pr-status-cache.json (whatever Claude Code last saw), then
|
|
36
|
+
# 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.
|
|
42
|
+
#
|
|
43
|
+
# `enrich` never touches gh; it is what stands between `claude agents` and
|
|
44
|
+
# the first frame. `refresh` is the slow half, one network round trip per
|
|
45
|
+
# open PR, and the poller calls it after the list has already gone up.
|
|
46
|
+
class PullRequests
|
|
47
|
+
REFRESH_AFTER = 60
|
|
48
|
+
|
|
49
|
+
CLAUDE_DIR = File.join(Dir.home, ".claude")
|
|
50
|
+
RESOLVED_PATH = File.join(Dir.home, ".config", "claude-inbox", "prs.json")
|
|
51
|
+
|
|
52
|
+
def initialize(cache_path: File.join(CLAUDE_DIR, "gh-pr-status-cache.json"), resolved_path: RESOLVED_PATH,
|
|
53
|
+
gh: "gh", clock: -> { Time.now })
|
|
54
|
+
@cache_path = cache_path
|
|
55
|
+
@resolved_path = resolved_path
|
|
56
|
+
@gh = gh
|
|
57
|
+
@clock = clock
|
|
58
|
+
@known = {} # url => PullRequest
|
|
59
|
+
@checked_at = {} # url => epoch seconds of the last gh call
|
|
60
|
+
@mutex = Mutex.new
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Each session with its `prs` set from what is already known, asking
|
|
64
|
+
# nobody. The scanned links come off `job_state`, so a session without
|
|
65
|
+
# one (interactive, or forgotten) has none; Sessions.load sees to the
|
|
66
|
+
# order. `overrides` maps session key => url for links set by hand; an
|
|
67
|
+
# override replaces the scanned list.
|
|
68
|
+
def enrich(sessions, overrides = {})
|
|
69
|
+
sessions.map do |s|
|
|
70
|
+
urls = overrides[s.key] ? [overrides[s.key]] : (s.job_state&.pr_urls || [])
|
|
71
|
+
s.with(prs: urls.map { |u| known(u) })
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# 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.
|
|
78
|
+
def refresh(sessions)
|
|
79
|
+
changed = false
|
|
80
|
+
fresh = sessions.map do |s|
|
|
81
|
+
s.with(prs: s.prs.map { |pr|
|
|
82
|
+
status(pr.url).tap { |now| changed = true if now.state != pr.state }
|
|
83
|
+
})
|
|
84
|
+
end
|
|
85
|
+
[fresh, changed]
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Best known state for a url without asking gh.
|
|
89
|
+
def known(url)
|
|
90
|
+
@mutex.synchronize { @known[url] ||= seed(url) }
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Best known state for a url, refreshed through gh when due.
|
|
94
|
+
def status(url)
|
|
95
|
+
@mutex.synchronize do
|
|
96
|
+
pr = @known[url] ||= seed(url)
|
|
97
|
+
return pr if pr.resolved? || !due?(url)
|
|
98
|
+
@checked_at[url] = @clock.call.to_i
|
|
99
|
+
fresh = fetch(url)
|
|
100
|
+
return pr unless fresh
|
|
101
|
+
remember(fresh) if fresh.resolved?
|
|
102
|
+
@known[url] = fresh
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Parse `gh pr view --json` output. Pure so it can be tested.
|
|
107
|
+
def self.parse(url, json)
|
|
108
|
+
h = JSON.parse(json)
|
|
109
|
+
state = (h["state"] == "OPEN" && h["isDraft"]) ? "DRAFT" : h["state"]
|
|
110
|
+
PullRequest.new(number: h["number"], url: h["url"] || url, state: state, title: h["title"])
|
|
111
|
+
rescue JSON::ParserError
|
|
112
|
+
nil
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Only an https://github.com/<owner>/<repo>/pull/<n> link makes sense here.
|
|
116
|
+
def self.valid_url?(url)
|
|
117
|
+
url.to_s.match?(%r{\Ahttps://github\.com/[^/\s]+/[^/\s]+/pull/\d+/?\z})
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
private
|
|
121
|
+
|
|
122
|
+
def due?(url)
|
|
123
|
+
@gh && @clock.call.to_i - @checked_at.fetch(url, 0) >= REFRESH_AFTER
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def seed(url)
|
|
127
|
+
number = url[%r{/pull/(\d+)}, 1]&.to_i
|
|
128
|
+
cached = resolved[url] || claude_cache[url]
|
|
129
|
+
PullRequest.new(number: cached&.dig("number") || number, url: url, state: cached&.dig("state"), title: cached&.dig("title"))
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Same shape as Claude Code's cache, so `seed` reads both alike.
|
|
133
|
+
def resolved
|
|
134
|
+
@resolved ||= Records.read(@resolved_path)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def claude_cache
|
|
138
|
+
@claude_cache ||= Records.read(@cache_path)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Under @mutex.
|
|
142
|
+
def remember(pr)
|
|
143
|
+
resolved[pr.url] = {"number" => pr.number, "state" => pr.state, "title" => pr.title}
|
|
144
|
+
return unless @resolved_path
|
|
145
|
+
Records.save(@resolved_path, resolved)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def fetch(url)
|
|
149
|
+
r = Subprocess.capture(@gh, "pr", "view", url, "--json", "number,state,isDraft,title,url")
|
|
150
|
+
r.success? ? self.class.parse(url, r.out) : nil
|
|
151
|
+
rescue SystemCallError
|
|
152
|
+
nil
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module ClaudeInbox
|
|
6
|
+
# The subscription's 5-hour and 7-day usage, read from
|
|
7
|
+
# ~/.claude/rate_limits.json. Nothing writes that file but the user's own
|
|
8
|
+
# status line script (README, "Usage"): `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.
|
|
13
|
+
class RateLimits
|
|
14
|
+
DEFAULT_PATH = File.join(Dir.home, ".claude", "rate_limits.json")
|
|
15
|
+
STALE_AFTER = 15 * 60
|
|
16
|
+
|
|
17
|
+
def initialize(path: DEFAULT_PATH)
|
|
18
|
+
@path = path
|
|
19
|
+
@mtime = nil
|
|
20
|
+
@data = nil
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# "usage 5h 23% · 7d 41%", or nil. Parses only when the file has changed,
|
|
24
|
+
# since render asks several times a second.
|
|
25
|
+
def label(now = Time.now)
|
|
26
|
+
mtime = File.mtime(@path)
|
|
27
|
+
return nil if now - mtime > STALE_AFTER
|
|
28
|
+
@data = parse if mtime != @mtime
|
|
29
|
+
@mtime = mtime
|
|
30
|
+
@data
|
|
31
|
+
rescue SystemCallError
|
|
32
|
+
@mtime = @data = nil
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def parse
|
|
38
|
+
hash = JSON.parse(File.read(@path))
|
|
39
|
+
parts = {"five_hour" => "5h", "seven_day" => "7d"}.filter_map do |key, word|
|
|
40
|
+
pct = hash.dig(key, "used_percentage")
|
|
41
|
+
"#{word} #{pct.round}%" if pct.is_a?(Numeric)
|
|
42
|
+
end
|
|
43
|
+
"usage " + parts.join(" · ") unless parts.empty?
|
|
44
|
+
rescue JSON::ParserError, TypeError
|
|
45
|
+
nil
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require_relative "store"
|
|
5
|
+
require_relative "agents_client"
|
|
6
|
+
|
|
7
|
+
module ClaudeInbox
|
|
8
|
+
# Reaps sessions that have sat idle past Store::REAP_AFTER.
|
|
9
|
+
#
|
|
10
|
+
# The one thing in the inbox that destroys anything without being asked
|
|
11
|
+
# first, so the whole of it lives here rather than spread through the poll
|
|
12
|
+
# loop: one public method, and one append-only log that is the last record
|
|
13
|
+
# a session ever existed once `claude rm` has taken its transcript.
|
|
14
|
+
#
|
|
15
|
+
# Unpushed work is safe by construction. `claude rm` refuses a worktree
|
|
16
|
+
# holding commits that aren't pushed and reports a --discard-unpushed token
|
|
17
|
+
# to override it; nothing here ever passes that token, so a refusal is the
|
|
18
|
+
# end of it. Refusals are logged and retried at most daily.
|
|
19
|
+
class Reaper
|
|
20
|
+
RETRY_AFTER = 24 * 3600
|
|
21
|
+
DEFAULT_LOG = File.join(Dir.home, ".config", "claude-inbox", "reaped.log")
|
|
22
|
+
|
|
23
|
+
attr_reader :log_path
|
|
24
|
+
|
|
25
|
+
def initialize(client, store, log_path: DEFAULT_LOG, enabled: self.class.enabled?)
|
|
26
|
+
@client = client
|
|
27
|
+
@store = store
|
|
28
|
+
@log_path = log_path
|
|
29
|
+
@enabled = enabled
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def self.enabled? = ENV["CLAUDE_INBOX_NO_REAP"].to_s.empty?
|
|
33
|
+
|
|
34
|
+
# For --fixture runs and tests: selects nothing, deletes nothing, and
|
|
35
|
+
# needs neither a client nor a store to do it.
|
|
36
|
+
def self.disabled = new(nil, nil, enabled: false)
|
|
37
|
+
|
|
38
|
+
# Reaps everything due, returning the keys it actually deleted so the
|
|
39
|
+
# caller can drop those rows before they reach the store. Refusals come
|
|
40
|
+
# back as survivors rather than exceptions: one worktree with unpushed
|
|
41
|
+
# commits must not stop the rest of the sweep.
|
|
42
|
+
#
|
|
43
|
+
# Raises if the log cannot be opened, before anything is deleted. No
|
|
44
|
+
# audit trail, no reaping.
|
|
45
|
+
def sweep(sessions, now)
|
|
46
|
+
return [] unless @enabled
|
|
47
|
+
now_i = now.to_i
|
|
48
|
+
due = sessions.filter_map { |s| row_if_due(s, now_i) }
|
|
49
|
+
return [] if due.empty?
|
|
50
|
+
with_log { |log| due.filter_map { |row| reap(row, log, now_i) } }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Keys `sweep` would go after right now, without touching any of them,
|
|
54
|
+
# so the poller can hand the list over minus these before `claude rm`.
|
|
55
|
+
def due(sessions, now)
|
|
56
|
+
return [] unless @enabled
|
|
57
|
+
now_i = now.to_i
|
|
58
|
+
sessions.filter_map { |s| row_if_due(s, now_i)&.key }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
def row_if_due(session, now_i)
|
|
64
|
+
row = @store.row(session)
|
|
65
|
+
return nil unless row.reapable?(now_i)
|
|
66
|
+
backing_off?(row, now_i) ? nil : row
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def backing_off?(row, now_i)
|
|
70
|
+
at = row.reap_failed_at
|
|
71
|
+
!at.nil? && now_i - at.to_i < RETRY_AFTER
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def reap(row, log, now_i)
|
|
75
|
+
@client.rm(row.session.id)
|
|
76
|
+
write(log, row, now_i, "reaped")
|
|
77
|
+
@store.forget(row.key)
|
|
78
|
+
row.key
|
|
79
|
+
rescue AgentsClient::Error => e
|
|
80
|
+
reason = e.message.lines.first.to_s.strip
|
|
81
|
+
@store.mark_reap_failed(row.key, reason)
|
|
82
|
+
write(log, row, now_i, "kept — #{reason}")
|
|
83
|
+
nil
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def write(log, row, now_i, outcome)
|
|
87
|
+
idle_days = (now_i - row.state_since.to_i) / 86_400
|
|
88
|
+
log.puts([
|
|
89
|
+
Time.at(now_i).utc.strftime("%FT%TZ"),
|
|
90
|
+
row.session.id,
|
|
91
|
+
"idle #{idle_days}d",
|
|
92
|
+
row.session.display_name.inspect,
|
|
93
|
+
row.session.cwd,
|
|
94
|
+
outcome
|
|
95
|
+
].join(" "))
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def with_log
|
|
99
|
+
FileUtils.mkdir_p(File.dirname(@log_path))
|
|
100
|
+
File.open(@log_path, "a") { |f| yield f }
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
|
|
6
|
+
module ClaudeInbox
|
|
7
|
+
# The inbox's own records — the snooze table, the resolved PRs — kept
|
|
8
|
+
# under ~/.config/claude-inbox between launches. Saved atomically (a temp
|
|
9
|
+
# file beside the target, renamed over it) so a poll mid-write never
|
|
10
|
+
# reads half a record. Reading a missing or unparsable record yields {}
|
|
11
|
+
# so callers start from empty instead of failing.
|
|
12
|
+
module Records
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
def read(path)
|
|
16
|
+
(path && File.exist?(path)) ? JSON.parse(File.read(path)) : {}
|
|
17
|
+
rescue JSON::ParserError
|
|
18
|
+
{}
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def save(path, data)
|
|
22
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
23
|
+
tmp = File.join(File.dirname(path), ".#{File.basename(path)}.#{Process.pid}.tmp")
|
|
24
|
+
File.write(tmp, JSON.pretty_generate(data))
|
|
25
|
+
File.rename(tmp, path)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|