claude-inbox 0.3.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 +86 -164
- data/exe/claude-inbox +44 -0
- data/lib/claude_inbox/actions.rb +91 -0
- data/lib/claude_inbox/agents_client.rb +11 -7
- data/lib/claude_inbox/app.rb +48 -14
- data/lib/claude_inbox/headless.rb +31 -0
- data/lib/claude_inbox/job_state.rb +4 -4
- data/lib/claude_inbox/keymap.rb +0 -2
- data/lib/claude_inbox/mod.rb +71 -0
- data/lib/claude_inbox/new_session_form.rb +18 -17
- data/lib/claude_inbox/painter.rb +3 -4
- data/lib/claude_inbox/paste.rb +2 -0
- data/lib/claude_inbox/poller.rb +48 -25
- data/lib/claude_inbox/pull_requests.rb +31 -17
- data/lib/claude_inbox/rate_limits.rb +1 -3
- data/lib/claude_inbox/remote/http.rb +10 -7
- data/lib/claude_inbox/remote/listener.rb +2 -3
- data/lib/claude_inbox/remote/start.rb +2 -3
- data/lib/claude_inbox/renderer.rb +4 -4
- data/lib/claude_inbox/settings.rb +1 -0
- 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 -9
- data/lib/claude_inbox/switch.rb +40 -0
- data/lib/claude_inbox/text_buffer.rb +84 -33
- 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 +16 -1
data/lib/claude_inbox/app.rb
CHANGED
|
@@ -20,6 +20,9 @@ require_relative "reaper"
|
|
|
20
20
|
require_relative "poller"
|
|
21
21
|
require_relative "rate_limits"
|
|
22
22
|
require_relative "session_request"
|
|
23
|
+
require_relative "actions"
|
|
24
|
+
require_relative "snapshot"
|
|
25
|
+
require_relative "switch"
|
|
23
26
|
|
|
24
27
|
module ClaudeInbox
|
|
25
28
|
# Owns the terminal and the key loop. The only class allowed to spawn a
|
|
@@ -28,19 +31,23 @@ module ClaudeInbox
|
|
|
28
31
|
# The reaper deletes sessions and the listener lets other machines in,
|
|
29
32
|
# so both are off unless `bin/claude-inbox` switches them on.
|
|
30
33
|
def initialize(client: AgentsClient.new, store: Store.new, pull_requests: PullRequests.new,
|
|
31
|
-
rate_limits: RateLimits.new, reaper: Reaper.disabled,
|
|
34
|
+
rate_limits: RateLimits.new, reaper: Reaper.disabled, snapshot: Snapshot.disabled, switch: Switch.disabled,
|
|
35
|
+
actions: Actions.disabled, listen: nil, out: $stdout, input: $stdin, color: true,
|
|
32
36
|
terminal: Terminal.new(out, input), queue: Queue.new)
|
|
33
37
|
@client = client
|
|
34
38
|
@store = store
|
|
39
|
+
@snapshot = snapshot
|
|
40
|
+
@switch = switch
|
|
35
41
|
@rate_limits = rate_limits
|
|
36
42
|
@terminal = terminal
|
|
37
43
|
@color = color
|
|
38
44
|
@renderer = Renderer.new(color: color)
|
|
45
|
+
@input = input
|
|
39
46
|
@reader = TTY::Reader.new(input: input, output: out, interrupt: :noop)
|
|
40
47
|
@queue = queue
|
|
41
48
|
@poller = Poller.new(client: client, store: store, pull_requests: pull_requests,
|
|
42
|
-
reaper: reaper, queue: @queue)
|
|
43
|
-
@listener = listen ? Remote::Listener.new(client: client, store: store, queue: @queue,
|
|
49
|
+
reaper: reaper, queue: @queue, snapshot: snapshot, actions: actions)
|
|
50
|
+
@listener = listen ? Remote::Listener.new(client: client, store: store, queue: @queue, **listen) : Remote::Listener.disabled
|
|
44
51
|
@logs = Logs.new(client)
|
|
45
52
|
@peek = Peek.new(@logs)
|
|
46
53
|
@selected = nil
|
|
@@ -80,8 +87,10 @@ module ClaudeInbox
|
|
|
80
87
|
@resize = false
|
|
81
88
|
@terminal.resized
|
|
82
89
|
end
|
|
83
|
-
render
|
|
84
90
|
handle_input(input) if input
|
|
91
|
+
# A paste arrives a character per read and shows nothing until it
|
|
92
|
+
# closes, so a frame per character would only stall a long one.
|
|
93
|
+
render unless @paste.pasting?
|
|
85
94
|
end
|
|
86
95
|
|
|
87
96
|
private
|
|
@@ -109,7 +118,6 @@ module ClaudeInbox
|
|
|
109
118
|
kind, *rest = @queue.pop(true)
|
|
110
119
|
case kind
|
|
111
120
|
when :sessions
|
|
112
|
-
@store.update(rest[0])
|
|
113
121
|
@last_poll = Time.now
|
|
114
122
|
@error = nil
|
|
115
123
|
when :error then @error = rest[0]
|
|
@@ -128,7 +136,19 @@ module ClaudeInbox
|
|
|
128
136
|
# ----- main loop --------------------------------------------------------
|
|
129
137
|
|
|
130
138
|
def main_loop
|
|
131
|
-
step(@reader.read_keypress(echo: false, raw: false, nonblock: true)) until @quit
|
|
139
|
+
step(@paste.pasting? ? read_pasted : @reader.read_keypress(echo: false, raw: false, nonblock: true)) until @quit
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# As much of an open paste as has arrived, read straight off the tty:
|
|
143
|
+
# the reader switches echo off and back on around every character, and
|
|
144
|
+
# on a long paste that is most of the wait.
|
|
145
|
+
def read_pasted
|
|
146
|
+
return nil unless @input.wait_readable(0.1)
|
|
147
|
+
out = +""
|
|
148
|
+
while out.size < 65_536 && @input.wait_readable(0) && (c = @input.getc)
|
|
149
|
+
out << c
|
|
150
|
+
end
|
|
151
|
+
out
|
|
132
152
|
end
|
|
133
153
|
|
|
134
154
|
def handle_input(raw)
|
|
@@ -153,7 +173,9 @@ module ClaudeInbox
|
|
|
153
173
|
def render
|
|
154
174
|
t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
155
175
|
now = Time.now
|
|
156
|
-
|
|
176
|
+
all = @store.sections(now)
|
|
177
|
+
@snapshot.write(all, now)
|
|
178
|
+
sections = filtered(all)
|
|
157
179
|
width, height = @terminal.size
|
|
158
180
|
body_h = Renderer.body_height(height)
|
|
159
181
|
ensure_selection(sections)
|
|
@@ -229,6 +251,13 @@ module ClaudeInbox
|
|
|
229
251
|
false
|
|
230
252
|
end
|
|
231
253
|
|
|
254
|
+
# The edits with no request form, which a second inbox cannot keep.
|
|
255
|
+
def require_writable(verb)
|
|
256
|
+
return true if @store.writable?
|
|
257
|
+
notice("another claude-inbox owns state.json — #{verb} there")
|
|
258
|
+
false
|
|
259
|
+
end
|
|
260
|
+
|
|
232
261
|
def notice(msg)
|
|
233
262
|
@notice = [msg, Time.now + 4]
|
|
234
263
|
end
|
|
@@ -389,12 +418,17 @@ module ClaudeInbox
|
|
|
389
418
|
notice("settled — u brings it back")
|
|
390
419
|
end
|
|
391
420
|
|
|
421
|
+
# The pane in the attached session may ask for a different one: the
|
|
422
|
+
# attach ends and the next starts without passing through the list. The
|
|
423
|
+
# poller keeps running meanwhile, so the pane's snapshot stays fresh.
|
|
392
424
|
def attach(id)
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
425
|
+
while id
|
|
426
|
+
@store.acknowledge(id)
|
|
427
|
+
@switch.clear
|
|
428
|
+
from = @store.sessions.find { |s| s.key == id }&.session_id
|
|
429
|
+
@terminal.release { @client.attach(id) { @switch.requested_for?(from) } }
|
|
430
|
+
id = @switch.take(@store.sessions.select(&:actionable?).map(&:key))
|
|
431
|
+
end
|
|
398
432
|
end
|
|
399
433
|
|
|
400
434
|
# ----- modals -----------------------------------------------------------
|
|
@@ -410,13 +444,13 @@ module ClaudeInbox
|
|
|
410
444
|
end
|
|
411
445
|
|
|
412
446
|
def open_alias_editor
|
|
413
|
-
return unless require_storable
|
|
447
|
+
return unless require_storable && require_writable("rename")
|
|
414
448
|
current = @store.alias_for(@selected.key) || ""
|
|
415
449
|
@modal = Dialog::Prompt.new(:alias, @selected.key, current)
|
|
416
450
|
end
|
|
417
451
|
|
|
418
452
|
def open_pr_editor
|
|
419
|
-
return unless require_storable
|
|
453
|
+
return unless require_storable && require_writable("link a pull request")
|
|
420
454
|
current = @store.pr_for(@selected.key) || selected_session&.pr&.url || ""
|
|
421
455
|
@modal = Dialog::Prompt.new(:pr, @selected.key, current)
|
|
422
456
|
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "actions"
|
|
4
|
+
require_relative "poller"
|
|
5
|
+
require_relative "pull_requests"
|
|
6
|
+
require_relative "reaper"
|
|
7
|
+
require_relative "snapshot"
|
|
8
|
+
require_relative "writer_lock"
|
|
9
|
+
|
|
10
|
+
module ClaudeInbox
|
|
11
|
+
# The poller with no terminal, for a front end that only reads the
|
|
12
|
+
# snapshot: it polls, writes the file and applies the requests. It never
|
|
13
|
+
# reaps, since deleting sessions stays with the inbox you can see, and it
|
|
14
|
+
# exits at once while an inbox holds the writer lock.
|
|
15
|
+
class Headless
|
|
16
|
+
def initialize(client:, store:, pull_requests: PullRequests.new, snapshot: Snapshot.new, actions: Actions.new,
|
|
17
|
+
lock: WriterLock.new, queue: Queue.new)
|
|
18
|
+
@lock = lock
|
|
19
|
+
@queue = queue
|
|
20
|
+
@poller = Poller.new(client: client, store: store, pull_requests: pull_requests, reaper: Reaper.disabled,
|
|
21
|
+
queue: queue, snapshot: snapshot, actions: actions)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# False when the lock is held, by an inbox or another headless process.
|
|
25
|
+
def run
|
|
26
|
+
return false unless @lock.take(role: "headless")
|
|
27
|
+
@poller.start
|
|
28
|
+
loop { @queue.pop }
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -38,19 +38,19 @@ module ClaudeInbox
|
|
|
38
38
|
# Each background session with its job file read onto `job_state`, or nil
|
|
39
39
|
# when there is none: interactive sessions have no job file, and neither
|
|
40
40
|
# does one the daemon has already forgotten. Sessions.load calls this.
|
|
41
|
-
def self.enrich(sessions, jobs_dir:
|
|
41
|
+
def self.enrich(sessions, jobs_dir:)
|
|
42
42
|
sessions.map { |s| s.background? ? s.with(job_state: read(s.id, jobs_dir: jobs_dir)) : s }
|
|
43
43
|
end
|
|
44
44
|
|
|
45
45
|
# => JobState, or nil when there is no readable file for this id.
|
|
46
|
-
def self.read(id, jobs_dir:
|
|
46
|
+
def self.read(id, jobs_dir:)
|
|
47
47
|
return nil unless id
|
|
48
48
|
new(JSON.parse(File.read(File.join(jobs_dir, id, "state.json"))))
|
|
49
49
|
rescue JSON::ParserError, SystemCallError
|
|
50
50
|
nil
|
|
51
51
|
end
|
|
52
52
|
|
|
53
|
-
attr_reader :
|
|
53
|
+
attr_reader :pr_urls, :color, :intent, :bridge_id
|
|
54
54
|
|
|
55
55
|
def initialize(hash)
|
|
56
56
|
@detail = hash["detail"]
|
|
@@ -96,7 +96,7 @@ module ClaudeInbox
|
|
|
96
96
|
|
|
97
97
|
private
|
|
98
98
|
|
|
99
|
-
attr_reader :tempo, :kinds, :tasks, :flags
|
|
99
|
+
attr_reader :detail, :needs, :result, :tempo, :kinds, :tasks, :flags
|
|
100
100
|
|
|
101
101
|
# The agent itself is not thinking. On its own this means little — a
|
|
102
102
|
# session whose process died leaves the same reading behind — so it only
|
data/lib/claude_inbox/keymap.rb
CHANGED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module ClaudeInbox
|
|
7
|
+
# Where the Claude Code pane ships, and a path to it that holds still: the
|
|
8
|
+
# gem's own folder moves with every version, so a link under ~/.config is
|
|
9
|
+
# what settings.json names, refreshed by the installed gem on every launch.
|
|
10
|
+
module Mod
|
|
11
|
+
DIR = File.expand_path("../../mod", __dir__)
|
|
12
|
+
LINK = File.join(Dir.home, ".config", "claude-inbox", "mod")
|
|
13
|
+
NAME = "inbox-pane"
|
|
14
|
+
CHECKOUT = File.exist?(File.expand_path("../../.git", __dir__))
|
|
15
|
+
# What the pane runs headless: a checkout's own, or the gem's from PATH.
|
|
16
|
+
COMMAND = CHECKOUT ? File.expand_path("../../bin/claude-inbox", __dir__) : nil
|
|
17
|
+
|
|
18
|
+
# Points the link at this gem's mod folder and returns the link. A folder
|
|
19
|
+
# of that name that is not a link is left alone. From a checkout only a
|
|
20
|
+
# missing or dangling link is made, unless forced (`--mod-dir` asks for
|
|
21
|
+
# it), so a worktree's launch never takes the path from the installed gem.
|
|
22
|
+
def self.link(link: LINK, dir: DIR, force: false, checkout: CHECKOUT)
|
|
23
|
+
FileUtils.mkdir_p(File.dirname(link))
|
|
24
|
+
return link if File.exist?(link) && !File.symlink?(link)
|
|
25
|
+
if File.symlink?(link)
|
|
26
|
+
return link if File.readlink(link) == dir
|
|
27
|
+
return link if checkout && !force && File.directory?(File.readlink(link))
|
|
28
|
+
File.delete(link)
|
|
29
|
+
end
|
|
30
|
+
File.symlink(dir, link)
|
|
31
|
+
link
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
Installed = Data.define(:added, :removed, :command_set)
|
|
35
|
+
|
|
36
|
+
# Links the mod and adds the link to CLAUDE_CODE_PLUGIN_DIRS in Claude
|
|
37
|
+
# Code's user settings, keeping any folders already there; forced, it
|
|
38
|
+
# drops the other folders holding this plugin, such as a worktree's, and
|
|
39
|
+
# sets the command the pane runs to this copy's. Raises JSON::ParserError
|
|
40
|
+
# rather than rewrite a settings file it cannot read.
|
|
41
|
+
def self.install(settings: File.join(Dir.home, ".claude", "settings.json"), link: LINK, dir: DIR, force: false, command: COMMAND)
|
|
42
|
+
self.link(link: link, dir: dir, force: true)
|
|
43
|
+
json = File.exist?(settings) ? JSON.parse(File.read(settings)) : {}
|
|
44
|
+
env = json["env"] ||= {}
|
|
45
|
+
dirs = env["CLAUDE_CODE_PLUGIN_DIRS"].to_s.split(File::PATH_SEPARATOR).map(&:strip).reject(&:empty?)
|
|
46
|
+
kept = dirs.reject { |d| force && d != link && plugin?(d) }
|
|
47
|
+
wanted = kept.include?(link) ? kept : [*kept, link]
|
|
48
|
+
options = json.dig("pluginConfigs", NAME, "options") || {}
|
|
49
|
+
replace = force && options["command"] != command
|
|
50
|
+
installed = Installed.new(added: !kept.include?(link), removed: dirs - kept, command_set: replace)
|
|
51
|
+
return installed if wanted == dirs && !replace
|
|
52
|
+
|
|
53
|
+
if replace
|
|
54
|
+
command ? options["command"] = command : options.delete("command")
|
|
55
|
+
((json["pluginConfigs"] ||= {})[NAME] ||= {})["options"] = options
|
|
56
|
+
end
|
|
57
|
+
env["CLAUDE_CODE_PLUGIN_DIRS"] = wanted.join(File::PATH_SEPARATOR)
|
|
58
|
+
FileUtils.mkdir_p(File.dirname(settings))
|
|
59
|
+
File.write("#{settings}.tmp", JSON.pretty_generate(json) + "\n")
|
|
60
|
+
File.rename("#{settings}.tmp", settings)
|
|
61
|
+
installed
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def self.plugin?(folder)
|
|
65
|
+
JSON.parse(File.read(File.join(folder, ".claude-plugin", "plugin.json")))["name"] == NAME
|
|
66
|
+
rescue Errno::ENOENT, Errno::ENOTDIR, Errno::EACCES, JSON::ParserError
|
|
67
|
+
false
|
|
68
|
+
end
|
|
69
|
+
private_class_method :plugin?
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -103,11 +103,6 @@ module ClaudeInbox
|
|
|
103
103
|
:changed
|
|
104
104
|
end
|
|
105
105
|
|
|
106
|
-
def command_query
|
|
107
|
-
return nil unless focused.key == :prompt
|
|
108
|
-
focused.value.head[/(?:\A|\s)\/(\S*)\z/, 1]
|
|
109
|
-
end
|
|
110
|
-
|
|
111
106
|
def menu
|
|
112
107
|
q = command_query
|
|
113
108
|
return nil if q.nil? || @dismissed == q
|
|
@@ -124,26 +119,18 @@ module ClaudeInbox
|
|
|
124
119
|
v[:name] = nil if v[:name].strip.empty?
|
|
125
120
|
v[:cwd] = directory
|
|
126
121
|
v[:remote] = SessionRequest::FLAGS[v[:remote]]
|
|
127
|
-
SessionRequest.resolve(v, defaults
|
|
122
|
+
SessionRequest.resolve(v, defaults)
|
|
128
123
|
end
|
|
129
124
|
|
|
130
125
|
# Settings resolve against the directory the session will run in, so
|
|
131
126
|
# they follow the Directory field.
|
|
132
|
-
def defaults
|
|
127
|
+
def defaults
|
|
128
|
+
cwd = directory
|
|
133
129
|
return @defaults if @defaults_for == cwd
|
|
134
130
|
@defaults_for = cwd
|
|
135
131
|
@defaults = Settings.defaults(cwd, home: @home)
|
|
136
132
|
end
|
|
137
133
|
|
|
138
|
-
# Project commands live under the Directory field's path, so they
|
|
139
|
-
# follow it as the defaults do.
|
|
140
|
-
def commands
|
|
141
|
-
cwd = directory
|
|
142
|
-
return @commands if @commands_for == cwd
|
|
143
|
-
@commands_for = cwd
|
|
144
|
-
@commands = SlashCommands.list(cwd: cwd, home: @home)
|
|
145
|
-
end
|
|
146
|
-
|
|
147
134
|
# Full-screen body: a tall prompt editor, then one row per setting with
|
|
148
135
|
# every choice visible. Exactly `height` lines.
|
|
149
136
|
def screen(width, height)
|
|
@@ -192,6 +179,20 @@ module ClaudeInbox
|
|
|
192
179
|
|
|
193
180
|
private
|
|
194
181
|
|
|
182
|
+
def command_query
|
|
183
|
+
return nil unless focused.key == :prompt
|
|
184
|
+
focused.value.head[/(?:\A|\s)\/(\S*)\z/, 1]
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# Project commands live under the Directory field's path, so they
|
|
188
|
+
# follow it as the defaults do.
|
|
189
|
+
def commands
|
|
190
|
+
cwd = directory
|
|
191
|
+
return @commands if @commands_for == cwd
|
|
192
|
+
@commands_for = cwd
|
|
193
|
+
@commands = SlashCommands.list(cwd: cwd, home: @home)
|
|
194
|
+
end
|
|
195
|
+
|
|
195
196
|
# Esc with a prompt typed asks first, so a stray keypress can't lose it.
|
|
196
197
|
def escape_pressed
|
|
197
198
|
return :cancel if field(:prompt).value.empty?
|
|
@@ -219,7 +220,7 @@ module ClaudeInbox
|
|
|
219
220
|
|
|
220
221
|
def insert(text)
|
|
221
222
|
return unless editable?
|
|
222
|
-
|
|
223
|
+
(focused.kind == :multiline) ? focused.value.paste(text) : focused.value.insert(text.tr("\n", " "))
|
|
223
224
|
end
|
|
224
225
|
|
|
225
226
|
def menu_press(name)
|
data/lib/claude_inbox/painter.rb
CHANGED
|
@@ -7,9 +7,8 @@ module ClaudeInbox
|
|
|
7
7
|
# line after a row: in the terminal's last column the cursor stays put
|
|
8
8
|
# (pending wrap), so EL would eat the glyph just drawn.
|
|
9
9
|
class Painter
|
|
10
|
-
def initialize(out
|
|
10
|
+
def initialize(out)
|
|
11
11
|
@out = out
|
|
12
|
-
@cursor = cursor
|
|
13
12
|
@prev = []
|
|
14
13
|
end
|
|
15
14
|
|
|
@@ -17,10 +16,10 @@ module ClaudeInbox
|
|
|
17
16
|
buf = +""
|
|
18
17
|
lines.each_with_index do |line, i|
|
|
19
18
|
next if @prev[i] == line
|
|
20
|
-
buf <<
|
|
19
|
+
buf << TTY::Cursor.move_to(0, i) << line
|
|
21
20
|
end
|
|
22
21
|
if @prev.size > lines.size
|
|
23
|
-
(lines.size...@prev.size).each { |i| buf <<
|
|
22
|
+
(lines.size...@prev.size).each { |i| buf << TTY::Cursor.move_to(0, i) << TTY::Cursor.clear_line }
|
|
24
23
|
end
|
|
25
24
|
@out.print buf unless buf.empty?
|
|
26
25
|
@out.flush
|
data/lib/claude_inbox/paste.rb
CHANGED
data/lib/claude_inbox/poller.rb
CHANGED
|
@@ -1,48 +1,46 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "actions"
|
|
3
4
|
require_relative "sessions"
|
|
5
|
+
require_relative "snapshot"
|
|
4
6
|
|
|
5
7
|
module ClaudeInbox
|
|
6
8
|
# Asks `claude agents` for the list off the main thread, every INTERVAL
|
|
7
|
-
# seconds and on demand,
|
|
8
|
-
# queue: [:sessions, list] for each
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
9
|
+
# seconds and on demand, puts what it finds in the store and the snapshot,
|
|
10
|
+
# and tells App over the shared queue: [:sessions, list] for each
|
|
11
|
+
# hand-over, [:error, msg] when a poll fails, [:notice, text] when the
|
|
12
|
+
# reaper took something. Between polls it applies what another front end
|
|
13
|
+
# asked for. Nothing here touches App's state directly; the queue is the
|
|
14
|
+
# whole of the interface. One worker runs every poll, so two can never
|
|
15
|
+
# overlap and publish the list out of order.
|
|
13
16
|
class Poller
|
|
14
17
|
INTERVAL = 4
|
|
18
|
+
ACTIONS_INTERVAL = 0.5
|
|
15
19
|
|
|
16
|
-
def initialize(client:, store:, pull_requests:, reaper:, queue:,
|
|
20
|
+
def initialize(client:, store:, pull_requests:, reaper:, queue:, snapshot: Snapshot.disabled, actions: Actions.disabled,
|
|
21
|
+
interval: INTERVAL, clock: -> { Time.now })
|
|
17
22
|
@client = client
|
|
18
23
|
@clock = clock
|
|
19
24
|
@store = store
|
|
20
25
|
@pull_requests = pull_requests
|
|
21
26
|
@reaper = reaper
|
|
22
27
|
@queue = queue
|
|
28
|
+
@snapshot = snapshot
|
|
29
|
+
@actions = actions
|
|
23
30
|
@interval = interval
|
|
24
31
|
@wake = Queue.new
|
|
25
|
-
@paused = false
|
|
26
|
-
@lock = Mutex.new
|
|
27
32
|
end
|
|
28
33
|
|
|
29
34
|
def start
|
|
30
35
|
return if @thread&.alive?
|
|
31
36
|
soon
|
|
32
37
|
@thread = Thread.new { worker }
|
|
38
|
+
@applier = Thread.new { applier }
|
|
33
39
|
end
|
|
34
40
|
|
|
35
|
-
def stop
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
# already under way finishes. Forking from another thread meanwhile, as
|
|
39
|
-
# a remote start does, is fine: Subprocess puts every child in its own
|
|
40
|
-
# session, away from the tty.
|
|
41
|
-
def pause = @lock.synchronize { @paused = true }
|
|
42
|
-
|
|
43
|
-
def resume
|
|
44
|
-
@lock.synchronize { @paused = false }
|
|
45
|
-
soon
|
|
41
|
+
def stop
|
|
42
|
+
@thread&.kill
|
|
43
|
+
@applier&.kill
|
|
46
44
|
end
|
|
47
45
|
|
|
48
46
|
def soon = @wake << true
|
|
@@ -59,35 +57,60 @@ module ClaudeInbox
|
|
|
59
57
|
sessions = Sessions.load(client: @client, pull_requests: @pull_requests, overrides: @store.pr_overrides)
|
|
60
58
|
doomed = @reaper.due(sessions, now)
|
|
61
59
|
@store.hide(doomed)
|
|
62
|
-
|
|
60
|
+
publish(sessions)
|
|
63
61
|
reaped = []
|
|
64
62
|
begin
|
|
65
63
|
reaped = @reaper.sweep(sessions.select { |s| doomed.include?(s.key) }, now)
|
|
66
64
|
ensure
|
|
67
65
|
@store.release(doomed - reaped)
|
|
68
66
|
end
|
|
69
|
-
|
|
67
|
+
publish(sessions) if reaped != doomed
|
|
70
68
|
@queue << [:notice, @reaper.report(reaped)] if reaped.any?
|
|
71
69
|
fresh, moved = @pull_requests.refresh(sessions)
|
|
72
|
-
|
|
70
|
+
publish(fresh) if moved
|
|
73
71
|
rescue => e
|
|
74
72
|
@queue << [:error, e.message]
|
|
75
73
|
end
|
|
76
74
|
|
|
75
|
+
# Applies the requests another front end left, and says whether any landed.
|
|
76
|
+
def apply
|
|
77
|
+
return false unless @actions.drain(@store) > 0
|
|
78
|
+
write_snapshot
|
|
79
|
+
true
|
|
80
|
+
end
|
|
81
|
+
|
|
77
82
|
private
|
|
78
83
|
|
|
84
|
+
def publish(sessions)
|
|
85
|
+
@store.update(sessions)
|
|
86
|
+
write_snapshot
|
|
87
|
+
@queue << [:sessions, sessions]
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def write_snapshot
|
|
91
|
+
now = @clock.call
|
|
92
|
+
@snapshot.write(@store.sections(now), now)
|
|
93
|
+
end
|
|
94
|
+
|
|
79
95
|
# Past StandardError `once` does not catch, and a dead worker would end
|
|
80
96
|
# polling with nothing on screen to say so.
|
|
81
97
|
def worker
|
|
82
98
|
loop do
|
|
83
99
|
@wake.pop(timeout: @interval)
|
|
84
100
|
@wake.clear
|
|
85
|
-
once
|
|
101
|
+
once
|
|
86
102
|
rescue SystemStackError, ScriptError, SecurityError => e
|
|
87
103
|
@queue << [:error, e.message]
|
|
88
104
|
end
|
|
89
105
|
end
|
|
90
106
|
|
|
91
|
-
def
|
|
107
|
+
def applier
|
|
108
|
+
loop do
|
|
109
|
+
sleep ACTIONS_INTERVAL
|
|
110
|
+
apply
|
|
111
|
+
rescue => e
|
|
112
|
+
@queue << [:error, e.message]
|
|
113
|
+
end
|
|
114
|
+
end
|
|
92
115
|
end
|
|
93
116
|
end
|
|
@@ -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,13 +84,14 @@ 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]
|
|
@@ -89,11 +101,11 @@ module ClaudeInbox
|
|
|
89
101
|
def status(url)
|
|
90
102
|
@mutex.synchronize do
|
|
91
103
|
pr = @known[url] ||= seed(url)
|
|
92
|
-
return pr if pr.
|
|
104
|
+
return pr if pr.final? || !due?(url)
|
|
93
105
|
@checked_at[url] = @clock.call.to_i
|
|
94
106
|
fresh = fetch(url)
|
|
95
107
|
return pr unless fresh
|
|
96
|
-
remember(fresh) if fresh.
|
|
108
|
+
remember(fresh) if fresh.final?
|
|
97
109
|
@known[url] = fresh
|
|
98
110
|
end
|
|
99
111
|
end
|
|
@@ -102,8 +114,9 @@ module ClaudeInbox
|
|
|
102
114
|
def self.parse(url, json)
|
|
103
115
|
h = JSON.parse(json)
|
|
104
116
|
state = (h["state"] == "OPEN" && h["isDraft"]) ? "DRAFT" : h["state"]
|
|
105
|
-
|
|
106
|
-
|
|
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
|
|
107
120
|
nil
|
|
108
121
|
end
|
|
109
122
|
|
|
@@ -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
|