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.
Files changed (60) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +107 -177
  3. data/exe/claude-inbox +67 -4
  4. data/lib/claude_inbox/actions.rb +91 -0
  5. data/lib/claude_inbox/agents_client.rb +68 -62
  6. data/lib/claude_inbox/app.rb +88 -38
  7. data/lib/claude_inbox/config.rb +24 -0
  8. data/lib/claude_inbox/dialog.rb +3 -1
  9. data/lib/claude_inbox/headless.rb +31 -0
  10. data/lib/claude_inbox/images.rb +37 -6
  11. data/lib/claude_inbox/job_state.rb +25 -10
  12. data/lib/claude_inbox/keymap.rb +1 -3
  13. data/lib/claude_inbox/mod.rb +71 -0
  14. data/lib/claude_inbox/new_session_form.rb +43 -52
  15. data/lib/claude_inbox/painter.rb +31 -0
  16. data/lib/claude_inbox/palette.rb +4 -7
  17. data/lib/claude_inbox/paste.rb +2 -0
  18. data/lib/claude_inbox/peek.rb +2 -2
  19. data/lib/claude_inbox/poller.rb +48 -24
  20. data/lib/claude_inbox/pull_requests.rb +36 -22
  21. data/lib/claude_inbox/rate_limits.rb +1 -3
  22. data/lib/claude_inbox/records.rb +7 -2
  23. data/lib/claude_inbox/remote/http.rb +140 -0
  24. data/lib/claude_inbox/remote/icon.png +0 -0
  25. data/lib/claude_inbox/remote/icon.svg +8 -0
  26. data/lib/claude_inbox/remote/listener.rb +435 -0
  27. data/lib/claude_inbox/remote/page.html +477 -0
  28. data/lib/claude_inbox/remote/pairing.rb +111 -0
  29. data/lib/claude_inbox/remote/pairing_dialog.rb +93 -0
  30. data/lib/claude_inbox/remote/start.rb +201 -0
  31. data/lib/claude_inbox/remote.rb +10 -0
  32. data/lib/claude_inbox/renderer.rb +61 -61
  33. data/lib/claude_inbox/session.rb +7 -21
  34. data/lib/claude_inbox/session_request.rb +151 -0
  35. data/lib/claude_inbox/sessions.rb +0 -1
  36. data/lib/claude_inbox/settings.rb +20 -7
  37. data/lib/claude_inbox/slash_commands.rb +1 -0
  38. data/lib/claude_inbox/snapshot.rb +48 -0
  39. data/lib/claude_inbox/store/entry.rb +0 -2
  40. data/lib/claude_inbox/store/row.rb +7 -3
  41. data/lib/claude_inbox/store.rb +24 -11
  42. data/lib/claude_inbox/subprocess.rb +9 -1
  43. data/lib/claude_inbox/switch.rb +40 -0
  44. data/lib/claude_inbox/terminal.rb +9 -3
  45. data/lib/claude_inbox/text.rb +7 -1
  46. data/lib/claude_inbox/text_buffer.rb +84 -33
  47. data/lib/claude_inbox/trust.rb +23 -0
  48. data/lib/claude_inbox/vt_screen.rb +1 -1
  49. data/lib/claude_inbox/writer_lock.rb +65 -0
  50. data/lib/claude_inbox.rb +1 -10
  51. data/mod/.claude-plugin/plugin.json +20 -0
  52. data/mod/hooks/hooks.json +1 -0
  53. data/mod/hooks/register.tsx +470 -0
  54. data/mod/tests/contract.test.tsx +23 -0
  55. data/mod/tests/pane.test.tsx +199 -0
  56. data/mod/tests/snapshot.fixture.ts +47 -0
  57. data/mod/tests/snooze.test.tsx +31 -0
  58. data/mod/tsconfig.json +3 -0
  59. data/mod/types/index.d.ts +30 -0
  60. 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
- PullRequest = Struct.new(:number, :url, :state, :title) do
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 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.
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 state changed,
77
- # so the caller knows whether the list is worth publishing again.
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.state != pr.state }
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.resolved? || !due?(url)
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.resolved?
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
- PullRequest.new(number: h["number"], url: h["url"] || url, state: state, title: h["title"])
111
- rescue JSON::ParserError
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"), title: cached&.dig("title"))
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) do
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
@@ -18,10 +18,15 @@ module ClaudeInbox
18
18
  {}
19
19
  end
20
20
 
21
- def save(path, data)
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.write(tmp, JSON.pretty_generate(data))
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>