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.
- checksums.yaml +4 -4
- data/README.md +107 -177
- data/exe/claude-inbox +67 -4
- data/lib/claude_inbox/actions.rb +91 -0
- data/lib/claude_inbox/agents_client.rb +68 -62
- data/lib/claude_inbox/app.rb +88 -38
- data/lib/claude_inbox/config.rb +24 -0
- data/lib/claude_inbox/dialog.rb +3 -1
- data/lib/claude_inbox/headless.rb +31 -0
- data/lib/claude_inbox/images.rb +37 -6
- data/lib/claude_inbox/job_state.rb +25 -10
- data/lib/claude_inbox/keymap.rb +1 -3
- data/lib/claude_inbox/mod.rb +71 -0
- data/lib/claude_inbox/new_session_form.rb +43 -52
- data/lib/claude_inbox/painter.rb +31 -0
- data/lib/claude_inbox/palette.rb +4 -7
- data/lib/claude_inbox/paste.rb +2 -0
- data/lib/claude_inbox/peek.rb +2 -2
- data/lib/claude_inbox/poller.rb +48 -24
- data/lib/claude_inbox/pull_requests.rb +36 -22
- data/lib/claude_inbox/rate_limits.rb +1 -3
- data/lib/claude_inbox/records.rb +7 -2
- data/lib/claude_inbox/remote/http.rb +140 -0
- data/lib/claude_inbox/remote/icon.png +0 -0
- data/lib/claude_inbox/remote/icon.svg +8 -0
- data/lib/claude_inbox/remote/listener.rb +435 -0
- data/lib/claude_inbox/remote/page.html +477 -0
- data/lib/claude_inbox/remote/pairing.rb +111 -0
- data/lib/claude_inbox/remote/pairing_dialog.rb +93 -0
- data/lib/claude_inbox/remote/start.rb +201 -0
- data/lib/claude_inbox/remote.rb +10 -0
- data/lib/claude_inbox/renderer.rb +61 -61
- data/lib/claude_inbox/session.rb +7 -21
- data/lib/claude_inbox/session_request.rb +151 -0
- data/lib/claude_inbox/sessions.rb +0 -1
- data/lib/claude_inbox/settings.rb +20 -7
- data/lib/claude_inbox/slash_commands.rb +1 -0
- data/lib/claude_inbox/snapshot.rb +48 -0
- data/lib/claude_inbox/store/entry.rb +0 -2
- data/lib/claude_inbox/store/row.rb +7 -3
- data/lib/claude_inbox/store.rb +24 -11
- data/lib/claude_inbox/subprocess.rb +9 -1
- data/lib/claude_inbox/switch.rb +40 -0
- data/lib/claude_inbox/terminal.rb +9 -3
- data/lib/claude_inbox/text.rb +7 -1
- data/lib/claude_inbox/text_buffer.rb +84 -33
- data/lib/claude_inbox/trust.rb +23 -0
- data/lib/claude_inbox/vt_screen.rb +1 -1
- data/lib/claude_inbox/writer_lock.rb +65 -0
- data/lib/claude_inbox.rb +1 -10
- data/mod/.claude-plugin/plugin.json +20 -0
- data/mod/hooks/hooks.json +1 -0
- data/mod/hooks/register.tsx +470 -0
- data/mod/tests/contract.test.tsx +23 -0
- data/mod/tests/pane.test.tsx +199 -0
- data/mod/tests/snapshot.fixture.ts +47 -0
- data/mod/tests/snooze.test.tsx +31 -0
- data/mod/tsconfig.json +3 -0
- data/mod/types/index.d.ts +30 -0
- metadata +29 -1
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
|
+
require "time"
|
|
4
5
|
require_relative "records"
|
|
5
6
|
require_relative "subprocess"
|
|
6
7
|
|
|
7
8
|
module ClaudeInbox
|
|
8
9
|
# A pull request tied to a session. `state` uses GitHub's vocabulary plus
|
|
9
10
|
# DRAFT, the same as Claude Code's own cache: OPEN, DRAFT, MERGED, CLOSED,
|
|
10
|
-
# or nil when nothing has told us yet.
|
|
11
|
-
|
|
11
|
+
# or nil when nothing has told us yet. `resolved_at` is when it was merged
|
|
12
|
+
# or closed, in epoch seconds, once gh has said.
|
|
13
|
+
PullRequest = Struct.new(:number, :url, :state, :title, :resolved_at) do
|
|
12
14
|
def short = number ? "##{number}" : url.to_s.sub(%r{\Ahttps?://(www\.)?}, "")
|
|
13
15
|
|
|
14
16
|
def merged? = state == "MERGED"
|
|
@@ -19,6 +21,16 @@ module ClaudeInbox
|
|
|
19
21
|
def resolved? = merged? || closed?
|
|
20
22
|
|
|
21
23
|
def known? = !state.nil?
|
|
24
|
+
|
|
25
|
+
# Nothing more to learn: resolved, and gh has said when.
|
|
26
|
+
def final? = resolved? && !resolved_at.nil?
|
|
27
|
+
|
|
28
|
+
# Resolved before `time`, so a session started then can only have mentioned it.
|
|
29
|
+
def resolved_before?(time) = final? && resolved_at < time.to_i
|
|
30
|
+
|
|
31
|
+
# Resolved at or after `time`, which is the only kind of resolution that
|
|
32
|
+
# can answer a session started then.
|
|
33
|
+
def resolved_since?(time) = final? && resolved_at >= time.to_i
|
|
22
34
|
end
|
|
23
35
|
|
|
24
36
|
# Finds the PRs a session is tied to and keeps their state fresh.
|
|
@@ -34,11 +46,10 @@ module ClaudeInbox
|
|
|
34
46
|
# State comes first from our own record of resolved PRs, then from
|
|
35
47
|
# ~/.claude/gh-pr-status-cache.json (whatever Claude Code last saw), then
|
|
36
48
|
# 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
|
|
38
|
-
#
|
|
39
|
-
#
|
|
40
|
-
# only covers PRs its own sessions opened
|
|
41
|
-
# of others.
|
|
49
|
+
# REFRESH_AFTER. A merged or closed PR never changes again, so once gh has
|
|
50
|
+
# said so, and when, it is written to RESOLVED_PATH and never asked about
|
|
51
|
+
# again. Claude Code's cache never says when, and says nothing at all about
|
|
52
|
+
# most of a link scan's PRs: it only covers PRs its own sessions opened.
|
|
42
53
|
#
|
|
43
54
|
# `enrich` never touches gh; it is what stands between `claude agents` and
|
|
44
55
|
# the first frame. `refresh` is the slow half, one network round trip per
|
|
@@ -73,32 +84,28 @@ module ClaudeInbox
|
|
|
73
84
|
end
|
|
74
85
|
|
|
75
86
|
# Asks gh about every PR on these sessions that is due. Returns the
|
|
76
|
-
# sessions with the answers on their `prs` and whether any
|
|
77
|
-
#
|
|
87
|
+
# sessions with the answers on their `prs` and whether any PR changed, so
|
|
88
|
+
# the caller knows whether the list is worth publishing again. A merge
|
|
89
|
+
# date alone counts: it is what lets a PR the cache called merged settle.
|
|
78
90
|
def refresh(sessions)
|
|
79
91
|
changed = false
|
|
80
92
|
fresh = sessions.map do |s|
|
|
81
93
|
s.with(prs: s.prs.map { |pr|
|
|
82
|
-
status(pr.url).tap { |now| changed = true if now
|
|
94
|
+
status(pr.url).tap { |now| changed = true if now != pr }
|
|
83
95
|
})
|
|
84
96
|
end
|
|
85
97
|
[fresh, changed]
|
|
86
98
|
end
|
|
87
99
|
|
|
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
100
|
# Best known state for a url, refreshed through gh when due.
|
|
94
101
|
def status(url)
|
|
95
102
|
@mutex.synchronize do
|
|
96
103
|
pr = @known[url] ||= seed(url)
|
|
97
|
-
return pr if pr.
|
|
104
|
+
return pr if pr.final? || !due?(url)
|
|
98
105
|
@checked_at[url] = @clock.call.to_i
|
|
99
106
|
fresh = fetch(url)
|
|
100
107
|
return pr unless fresh
|
|
101
|
-
remember(fresh) if fresh.
|
|
108
|
+
remember(fresh) if fresh.final?
|
|
102
109
|
@known[url] = fresh
|
|
103
110
|
end
|
|
104
111
|
end
|
|
@@ -107,8 +114,9 @@ module ClaudeInbox
|
|
|
107
114
|
def self.parse(url, json)
|
|
108
115
|
h = JSON.parse(json)
|
|
109
116
|
state = (h["state"] == "OPEN" && h["isDraft"]) ? "DRAFT" : h["state"]
|
|
110
|
-
|
|
111
|
-
|
|
117
|
+
resolved_at = h["closedAt"] && Time.iso8601(h["closedAt"]).to_i
|
|
118
|
+
PullRequest.new(number: h["number"], url: h["url"] || url, state: state, title: h["title"], resolved_at: resolved_at)
|
|
119
|
+
rescue JSON::ParserError, ArgumentError
|
|
112
120
|
nil
|
|
113
121
|
end
|
|
114
122
|
|
|
@@ -119,6 +127,11 @@ module ClaudeInbox
|
|
|
119
127
|
|
|
120
128
|
private
|
|
121
129
|
|
|
130
|
+
# Best known state for a url without asking gh.
|
|
131
|
+
def known(url)
|
|
132
|
+
@mutex.synchronize { @known[url] ||= seed(url) }
|
|
133
|
+
end
|
|
134
|
+
|
|
122
135
|
def due?(url)
|
|
123
136
|
@gh && @clock.call.to_i - @checked_at.fetch(url, 0) >= REFRESH_AFTER
|
|
124
137
|
end
|
|
@@ -126,7 +139,8 @@ module ClaudeInbox
|
|
|
126
139
|
def seed(url)
|
|
127
140
|
number = url[%r{/pull/(\d+)}, 1]&.to_i
|
|
128
141
|
cached = resolved[url] || claude_cache[url]
|
|
129
|
-
PullRequest.new(number: cached&.dig("number") || number, url: url, state: cached&.dig("state"),
|
|
142
|
+
PullRequest.new(number: cached&.dig("number") || number, url: url, state: cached&.dig("state"),
|
|
143
|
+
title: cached&.dig("title"), resolved_at: cached&.dig("resolved_at"))
|
|
130
144
|
end
|
|
131
145
|
|
|
132
146
|
# Same shape as Claude Code's cache, so `seed` reads both alike.
|
|
@@ -140,13 +154,13 @@ module ClaudeInbox
|
|
|
140
154
|
|
|
141
155
|
# Under @mutex.
|
|
142
156
|
def remember(pr)
|
|
143
|
-
resolved[pr.url] = {"number" => pr.number, "state" => pr.state, "title" => pr.title}
|
|
157
|
+
resolved[pr.url] = {"number" => pr.number, "state" => pr.state, "title" => pr.title, "resolved_at" => pr.resolved_at}
|
|
144
158
|
return unless @resolved_path
|
|
145
159
|
Records.save(@resolved_path, resolved)
|
|
146
160
|
end
|
|
147
161
|
|
|
148
162
|
def fetch(url)
|
|
149
|
-
r = Subprocess.capture(@gh, "pr", "view", url, "--json", "number,state,isDraft,title,url")
|
|
163
|
+
r = Subprocess.capture(@gh, "pr", "view", url, "--json", "number,state,isDraft,title,url,closedAt")
|
|
150
164
|
r.success? ? self.class.parse(url, r.out) : nil
|
|
151
165
|
rescue SystemCallError
|
|
152
166
|
nil
|
|
@@ -18,9 +18,7 @@ module ClaudeInbox
|
|
|
18
18
|
LABELS = {"five_hour" => "session", "seven_day" => "week"}.freeze
|
|
19
19
|
|
|
20
20
|
# `resets_at` is when the window rolls over, or nil if the file omits it.
|
|
21
|
-
Window = Data.define(:label, :percent, :resets_at)
|
|
22
|
-
def initialize(label:, percent:, resets_at: nil) = super
|
|
23
|
-
end
|
|
21
|
+
Window = Data.define(:label, :percent, :resets_at)
|
|
24
22
|
|
|
25
23
|
def initialize(path: DEFAULT_PATH)
|
|
26
24
|
@path = path
|
data/lib/claude_inbox/records.rb
CHANGED
|
@@ -18,10 +18,15 @@ module ClaudeInbox
|
|
|
18
18
|
{}
|
|
19
19
|
end
|
|
20
20
|
|
|
21
|
-
|
|
21
|
+
# `perm` is set on the temp file before anything is written to it, so
|
|
22
|
+
# a secret is never readable at a wider mode, not even briefly.
|
|
23
|
+
def save(path, data, perm: nil)
|
|
22
24
|
FileUtils.mkdir_p(File.dirname(path))
|
|
23
25
|
tmp = File.join(File.dirname(path), ".#{File.basename(path)}.#{Process.pid}.tmp")
|
|
24
|
-
File.
|
|
26
|
+
File.open(tmp, "w", perm || 0o666) do |f|
|
|
27
|
+
f.chmod(perm) if perm
|
|
28
|
+
f.write(JSON.pretty_generate(data))
|
|
29
|
+
end
|
|
25
30
|
File.rename(tmp, path)
|
|
26
31
|
end
|
|
27
32
|
end
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ClaudeInbox
|
|
4
|
+
module Remote
|
|
5
|
+
# The little of HTTP/1.1 the listener speaks: one request per
|
|
6
|
+
# connection, a body sized by Content-Length, and a response that closes
|
|
7
|
+
# the connection. Nothing here knows a route or a token.
|
|
8
|
+
module Http
|
|
9
|
+
MAX_LINE = 8 * 1024
|
|
10
|
+
MAX_HEADERS = 64
|
|
11
|
+
CHUNK = 64 * 1024
|
|
12
|
+
TOKEN = /\A[!#$%&'*+\-.^_`|~0-9A-Za-z]+\z/
|
|
13
|
+
|
|
14
|
+
REASONS = {
|
|
15
|
+
100 => "Continue", 200 => "OK", 201 => "Created", 400 => "Bad Request", 401 => "Unauthorized",
|
|
16
|
+
403 => "Forbidden", 404 => "Not Found", 405 => "Method Not Allowed", 409 => "Conflict",
|
|
17
|
+
411 => "Length Required", 413 => "Content Too Large", 415 => "Unsupported Media Type",
|
|
18
|
+
421 => "Misdirected Request", 422 => "Unprocessable Content", 500 => "Internal Server Error",
|
|
19
|
+
503 => "Service Unavailable"
|
|
20
|
+
}.freeze
|
|
21
|
+
|
|
22
|
+
HEADERS = {
|
|
23
|
+
"Connection" => "close",
|
|
24
|
+
"Cache-Control" => "no-store",
|
|
25
|
+
"X-Content-Type-Options" => "nosniff",
|
|
26
|
+
"Referrer-Policy" => "no-referrer"
|
|
27
|
+
}.freeze
|
|
28
|
+
|
|
29
|
+
JSON_TYPE = {"Content-Type" => "application/json"}.freeze
|
|
30
|
+
|
|
31
|
+
# A request refused: the status, the message and any other members for
|
|
32
|
+
# the JSON body, and the headers that status calls for. `note`, when
|
|
33
|
+
# set, is what the refusal is logged as in place of its message.
|
|
34
|
+
class Error < StandardError
|
|
35
|
+
attr_reader :status, :headers, :note
|
|
36
|
+
|
|
37
|
+
def initialize(status, message, headers: {}, note: nil, **details)
|
|
38
|
+
super(message)
|
|
39
|
+
@status = status
|
|
40
|
+
@headers = headers
|
|
41
|
+
@note = note
|
|
42
|
+
@details = details
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def body = {error: Http.utf8(message), **details.transform_values { |v| v.is_a?(Integer) ? v : Http.utf8(v) }}
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
attr_reader :details
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# A request head. `headers` are keyed in lower case, and a repeated
|
|
53
|
+
# header is joined with ", ", so a second Content-Length or Host can't
|
|
54
|
+
# pass for the first. The bytes read past the head stay here for the
|
|
55
|
+
# body.
|
|
56
|
+
class Request
|
|
57
|
+
attr_reader :verb, :path, :headers
|
|
58
|
+
|
|
59
|
+
def initialize(verb, target, headers, rest)
|
|
60
|
+
@verb = verb
|
|
61
|
+
@path = target.split("?", 2).first
|
|
62
|
+
@headers = headers
|
|
63
|
+
@rest = rest
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Read only once the request has earned it: a client that sent
|
|
67
|
+
# `Expect: 100-continue` holds the body back until it hears 100.
|
|
68
|
+
def read_body(io, max:, deadline:)
|
|
69
|
+
raise Error.new(400, "Transfer-Encoding isn't accepted; send Content-Length") if headers.key?("transfer-encoding")
|
|
70
|
+
length = headers["content-length"]
|
|
71
|
+
raise Error.new(411, "Content-Length is required") unless length
|
|
72
|
+
raise Error.new(400, "Content-Length isn't a number") unless length.match?(/\A\d{1,15}\z/)
|
|
73
|
+
size = length.to_i
|
|
74
|
+
raise Error.new(413, "the body is over #{max} bytes") if size > max
|
|
75
|
+
io.write("HTTP/1.1 100 Continue\r\n\r\n") if headers["expect"]&.casecmp?("100-continue") && @rest.bytesize < size
|
|
76
|
+
Http.fill(io, @rest, deadline) while @rest.bytesize < size
|
|
77
|
+
@rest.byteslice(0, size)
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def self.monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
82
|
+
|
|
83
|
+
# The JSON generator warns on stderr, which is the terminal, about a
|
|
84
|
+
# binary string and raises on a bad byte, and a message can quote CLI
|
|
85
|
+
# output or request bytes.
|
|
86
|
+
def self.utf8(text) = String.new(text.to_s, encoding: Encoding::UTF_8).scrub("\uFFFD")
|
|
87
|
+
|
|
88
|
+
# `deadline` (on the monotonic clock) bounds the whole head, not each
|
|
89
|
+
# read: a per-read timeout lets a byte every few seconds hold the
|
|
90
|
+
# connection for ever.
|
|
91
|
+
def self.read_head(io, deadline:)
|
|
92
|
+
rest = +"".b
|
|
93
|
+
line = read_line(io, rest, deadline)
|
|
94
|
+
request = line.match(%r{\A(\S+) (/\S*) HTTP/1\.[01]\z})
|
|
95
|
+
raise Error.new(400, "not an HTTP/1.x request line") unless request && request[1].match?(TOKEN)
|
|
96
|
+
headers = {}
|
|
97
|
+
count = 0
|
|
98
|
+
loop do
|
|
99
|
+
line = read_line(io, rest, deadline)
|
|
100
|
+
return Request.new(request[1], request[2], headers, rest) if line.empty?
|
|
101
|
+
raise Error.new(400, "more than #{MAX_HEADERS} headers") if (count += 1) > MAX_HEADERS
|
|
102
|
+
name, value = line.split(":", 2)
|
|
103
|
+
raise Error.new(400, "malformed header") unless value && name.match?(TOKEN)
|
|
104
|
+
key = name.downcase
|
|
105
|
+
value = value.strip
|
|
106
|
+
headers[key] = headers.key?(key) ? "#{headers[key]}, #{value}" : value
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def self.write(io, status, headers = {}, body = "")
|
|
111
|
+
body = body.b
|
|
112
|
+
head = ["HTTP/1.1 #{status} #{REASONS.fetch(status)}"]
|
|
113
|
+
HEADERS.merge(headers, "Content-Length" => body.bytesize.to_s).each { |name, value| head << "#{name}: #{value}" }
|
|
114
|
+
io.write((head.join("\r\n") + "\r\n\r\n").b + body)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def self.read_line(io, rest, deadline)
|
|
118
|
+
loop do
|
|
119
|
+
i = rest.index("\n")
|
|
120
|
+
raise Error.new(400, "a line of the request head is over #{MAX_LINE} bytes") if (i || rest.bytesize) > MAX_LINE
|
|
121
|
+
return rest.slice!(0..i).chomp if i
|
|
122
|
+
fill(io, rest, deadline)
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def self.fill(io, rest, deadline)
|
|
127
|
+
loop do
|
|
128
|
+
case (chunk = io.read_nonblock(CHUNK, exception: false))
|
|
129
|
+
when nil then raise Error.new(400, "the connection closed mid-request")
|
|
130
|
+
when :wait_readable
|
|
131
|
+
left = deadline - monotonic
|
|
132
|
+
raise Error.new(400, "the request took too long") unless left > 0 && IO.select([io], nil, nil, left)
|
|
133
|
+
else return rest << chunk
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
private_class_method :read_line
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
Binary file
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
|
2
|
+
<rect width="512" height="512" fill="#0a7385"/>
|
|
3
|
+
<g fill="none" stroke="#fff" stroke-width="28" stroke-linejoin="round" stroke-linecap="round">
|
|
4
|
+
<path d="M136 150h240l44 132v92a28 28 0 0 1-28 28H120a28 28 0 0 1-28-28v-92z"/>
|
|
5
|
+
<path d="M92 282h92l26 46h92l26-46h92"/>
|
|
6
|
+
<path d="M256 170v88M220 222l36 36 36-36"/>
|
|
7
|
+
</g>
|
|
8
|
+
</svg>
|