claude-inbox 0.4.0 → 0.6.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 +20 -9
- data/exe/claude-inbox +40 -5
- data/lib/claude_inbox/actions.rb +11 -8
- data/lib/claude_inbox/agents_client.rb +21 -15
- data/lib/claude_inbox/app.rb +77 -13
- data/lib/claude_inbox/dialog.rb +40 -0
- data/lib/claude_inbox/headless.rb +20 -4
- data/lib/claude_inbox/images.rb +1 -1
- data/lib/claude_inbox/job_state.rb +16 -2
- data/lib/claude_inbox/keymap.rb +1 -1
- data/lib/claude_inbox/logs.rb +0 -1
- data/lib/claude_inbox/mod.rb +36 -1
- data/lib/claude_inbox/mouse.rb +1 -1
- data/lib/claude_inbox/new_session_form.rb +59 -19
- data/lib/claude_inbox/painter.rb +1 -3
- data/lib/claude_inbox/palette.rb +1 -1
- data/lib/claude_inbox/paste.rb +1 -1
- data/lib/claude_inbox/peek.rb +5 -9
- data/lib/claude_inbox/poller.rb +5 -9
- data/lib/claude_inbox/pull_requests.rb +6 -6
- data/lib/claude_inbox/rate_limits.rb +1 -1
- data/lib/claude_inbox/reaper.rb +2 -4
- data/lib/claude_inbox/remote/http.rb +2 -2
- data/lib/claude_inbox/remote/listener.rb +2 -6
- data/lib/claude_inbox/remote/page.html +7 -4
- data/lib/claude_inbox/remote/pairing.rb +2 -1
- data/lib/claude_inbox/remote/start.rb +3 -8
- data/lib/claude_inbox/renderer.rb +5 -13
- data/lib/claude_inbox/session_request.rb +16 -4
- data/lib/claude_inbox/slash_commands.rb +13 -13
- data/lib/claude_inbox/snapshot.rb +8 -4
- data/lib/claude_inbox/store/entry.rb +4 -5
- data/lib/claude_inbox/store/row.rb +0 -2
- data/lib/claude_inbox/store/sections.rb +3 -3
- data/lib/claude_inbox/store.rb +1 -2
- data/lib/claude_inbox/switch.rb +1 -3
- data/lib/claude_inbox/terminal.rb +8 -0
- data/lib/claude_inbox/text_buffer.rb +4 -9
- data/lib/claude_inbox/theme.rb +1 -1
- data/lib/claude_inbox/updater.rb +163 -0
- data/lib/claude_inbox/writer_lock.rb +1 -8
- data/lib/claude_inbox.rb +1 -1
- data/mod/hooks/register.tsx +90 -45
- data/mod/tests/contract.test.tsx +2 -3
- data/mod/tests/pane.test.tsx +87 -8
- data/mod/tests/snapshot.fixture.ts +6 -8
- data/mod/types/index.d.ts +3 -3
- metadata +2 -2
- data/lib/claude_inbox/remote.rb +0 -10
|
@@ -4,7 +4,8 @@ require "json"
|
|
|
4
4
|
|
|
5
5
|
module ClaudeInbox
|
|
6
6
|
# What the daemon writes about a background session in
|
|
7
|
-
# ~/.claude/jobs/<id>/state.json. The only reader of that file
|
|
7
|
+
# ~/.claude/jobs/<id>/state.json. The only reader of that file, and the
|
|
8
|
+
# only writer: a new session's color.
|
|
8
9
|
#
|
|
9
10
|
# `claude agents --json` reports one word, `state`, and "working" covers two
|
|
10
11
|
# different situations: the agent is thinking, or the agent is idle and
|
|
@@ -50,6 +51,19 @@ module ClaudeInbox
|
|
|
50
51
|
nil
|
|
51
52
|
end
|
|
52
53
|
|
|
54
|
+
# The job file is there by the time `claude --bg` returns, and the daemon
|
|
55
|
+
# updates it read-modify-write, so the color stays through work, stop and
|
|
56
|
+
# respawn (checked on 2.1.296). A color is cosmetic: failing to write one
|
|
57
|
+
# must not report a started session as failed.
|
|
58
|
+
# ponytail: a daemon write racing this one can drop the color; re-apply on poll if that shows up.
|
|
59
|
+
def self.write_color(id, color, jobs_dir:)
|
|
60
|
+
path = File.join(jobs_dir, id, "state.json")
|
|
61
|
+
File.write("#{path}.tmp", JSON.generate(JSON.parse(File.read(path)).merge("color" => color)))
|
|
62
|
+
File.rename("#{path}.tmp", path)
|
|
63
|
+
rescue JSON::ParserError, SystemCallError
|
|
64
|
+
nil
|
|
65
|
+
end
|
|
66
|
+
|
|
53
67
|
attr_reader :pr_urls, :color, :intent, :bridge_id
|
|
54
68
|
|
|
55
69
|
def initialize(hash)
|
|
@@ -59,7 +73,7 @@ module ClaudeInbox
|
|
|
59
73
|
@tempo = hash["tempo"]
|
|
60
74
|
@kinds = (hash["fan"] || []).filter_map { |f| f["kind"] }
|
|
61
75
|
@tasks = (hash["inFlight"] || {})["tasks"].to_i
|
|
62
|
-
@pr_urls = (hash["children"] || []).
|
|
76
|
+
@pr_urls = (hash["children"] || []).filter_map { |c| c["href"] if c["kind"] == "pr" }
|
|
63
77
|
@color = hash["color"]
|
|
64
78
|
@intent = hash["intent"]
|
|
65
79
|
@bridge_id = hash["bridgeSessionId"]
|
data/lib/claude_inbox/keymap.rb
CHANGED
|
@@ -27,7 +27,7 @@ module ClaudeInbox
|
|
|
27
27
|
"s" => :snooze, "u" => :wake, "a" => :alias, "x" => :settle, "X" => :stop,
|
|
28
28
|
:ctrl_x => :delete,
|
|
29
29
|
"o" => :open_pr, "P" => :link_pr, "t" => :toggle_pin, "w" => :open_remote,
|
|
30
|
-
"R" => :refresh, "p" => :toggle_peek, "n" => :new_session, "N" => :remote_pairing,
|
|
30
|
+
"R" => :refresh, "U" => :update, "p" => :toggle_peek, "n" => :new_session, "N" => :remote_pairing,
|
|
31
31
|
:tab => :next_section, :back_tab => :prev_section,
|
|
32
32
|
"/" => :filter, :escape => :escape,
|
|
33
33
|
"q" => :quit, :ctrl_c => :quit
|
data/lib/claude_inbox/logs.rb
CHANGED
data/lib/claude_inbox/mod.rb
CHANGED
|
@@ -11,6 +11,7 @@ module ClaudeInbox
|
|
|
11
11
|
DIR = File.expand_path("../../mod", __dir__)
|
|
12
12
|
LINK = File.join(Dir.home, ".config", "claude-inbox", "mod")
|
|
13
13
|
NAME = "inbox-pane"
|
|
14
|
+
SETTINGS = File.join(Dir.home, ".claude", "settings.json")
|
|
14
15
|
CHECKOUT = File.exist?(File.expand_path("../../.git", __dir__))
|
|
15
16
|
# What the pane runs headless: a checkout's own, or the gem's from PATH.
|
|
16
17
|
COMMAND = CHECKOUT ? File.expand_path("../../bin/claude-inbox", __dir__) : nil
|
|
@@ -38,7 +39,7 @@ module ClaudeInbox
|
|
|
38
39
|
# drops the other folders holding this plugin, such as a worktree's, and
|
|
39
40
|
# sets the command the pane runs to this copy's. Raises JSON::ParserError
|
|
40
41
|
# rather than rewrite a settings file it cannot read.
|
|
41
|
-
def self.install(settings:
|
|
42
|
+
def self.install(settings: SETTINGS, link: LINK, dir: DIR, force: false, command: COMMAND)
|
|
42
43
|
self.link(link: link, dir: dir, force: true)
|
|
43
44
|
json = File.exist?(settings) ? JSON.parse(File.read(settings)) : {}
|
|
44
45
|
env = json["env"] ||= {}
|
|
@@ -61,6 +62,40 @@ module ClaudeInbox
|
|
|
61
62
|
installed
|
|
62
63
|
end
|
|
63
64
|
|
|
65
|
+
# Whether any folder in CLAUDE_CODE_PLUGIN_DIRS holds the pane, a
|
|
66
|
+
# worktree's included. A settings file it cannot read counts as yes, since
|
|
67
|
+
# install would refuse to touch it.
|
|
68
|
+
def self.installed?(settings: SETTINGS)
|
|
69
|
+
json = File.exist?(settings) ? JSON.parse(File.read(settings)) : {}
|
|
70
|
+
json.dig("env", "CLAUDE_CODE_PLUGIN_DIRS").to_s.split(File::PATH_SEPARATOR).any? { |d| plugin?(d.strip) }
|
|
71
|
+
rescue JSON::ParserError, Errno::EACCES
|
|
72
|
+
true
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Asks once, on the first launch that finds the pane missing, whether to
|
|
76
|
+
# add it. Whatever the answer, a marker file keeps it from asking again.
|
|
77
|
+
class Offer
|
|
78
|
+
MARKER = File.join(Dir.home, ".config", "claude-inbox", "pane-offered")
|
|
79
|
+
|
|
80
|
+
def self.disabled = new(marker: nil)
|
|
81
|
+
|
|
82
|
+
def initialize(marker: MARKER, settings: SETTINGS, link: LINK)
|
|
83
|
+
@marker = marker
|
|
84
|
+
@settings = settings
|
|
85
|
+
@link = link
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# True once: the first time it is asked while the pane is missing.
|
|
89
|
+
def take
|
|
90
|
+
return false if @marker.nil? || File.exist?(@marker) || Mod.installed?(settings: @settings)
|
|
91
|
+
FileUtils.mkdir_p(File.dirname(@marker))
|
|
92
|
+
FileUtils.touch(@marker)
|
|
93
|
+
true
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def install = Mod.install(settings: @settings, link: @link)
|
|
97
|
+
end
|
|
98
|
+
|
|
64
99
|
def self.plugin?(folder)
|
|
65
100
|
JSON.parse(File.read(File.join(folder, ".claude-plugin", "plugin.json")))["name"] == NAME
|
|
66
101
|
rescue Errno::ENOENT, Errno::ENOTDIR, Errno::EACCES, JSON::ParserError
|
data/lib/claude_inbox/mouse.rb
CHANGED
|
@@ -14,7 +14,7 @@ module ClaudeInbox
|
|
|
14
14
|
# "esc gg" glues onto one read (see App#split_keys), so this scans
|
|
15
15
|
# rather than matching once.
|
|
16
16
|
def self.events(raw)
|
|
17
|
-
raw.
|
|
17
|
+
raw.scan(SEQUENCE).filter_map do |cb, col, row, type|
|
|
18
18
|
kind = kind_for(cb.to_i, type)
|
|
19
19
|
Event.new(kind, row.to_i, col.to_i) if kind
|
|
20
20
|
end
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "pastel"
|
|
3
4
|
require_relative "agents_client"
|
|
4
5
|
require_relative "images"
|
|
6
|
+
require_relative "palette"
|
|
5
7
|
require_relative "session_request"
|
|
6
8
|
require_relative "settings"
|
|
7
9
|
require_relative "slash_commands"
|
|
@@ -18,6 +20,12 @@ module ClaudeInbox
|
|
|
18
20
|
# string on :choice ones — `kind` says which.
|
|
19
21
|
Field = Struct.new(:key, :label, :kind, :value, :choices)
|
|
20
22
|
|
|
23
|
+
# A directory the Directory field offers, shown by its shortest
|
|
24
|
+
# distinct label as the phone's form shows it.
|
|
25
|
+
Place = Struct.new(:path, :label, :description) do
|
|
26
|
+
def to_s = label
|
|
27
|
+
end
|
|
28
|
+
|
|
21
29
|
# The screen shows what "default" resolves to; `values` makes it nil.
|
|
22
30
|
DEFAULT = "default"
|
|
23
31
|
|
|
@@ -27,20 +35,24 @@ module ClaudeInbox
|
|
|
27
35
|
# editable field spends these on its own text instead.
|
|
28
36
|
CYCLE = {:left => -1, :right => 1, "h" => -1, "l" => 1, " " => 1}.freeze
|
|
29
37
|
|
|
30
|
-
def initialize(cwd:,
|
|
31
|
-
@p =
|
|
32
|
-
@theme = Theme.new(enabled:
|
|
38
|
+
def initialize(cwd:, color:, dirs: [], home: Dir.home, clipboard: Images.method(:from_clipboard))
|
|
39
|
+
@p = Pastel.new(enabled: color)
|
|
40
|
+
@theme = Theme.new(enabled: color)
|
|
41
|
+
@palette = Palette.new(enabled: color)
|
|
33
42
|
@home = home
|
|
34
43
|
@clipboard = clipboard
|
|
44
|
+
@places = dirs.map { |path| Place.new(path, SessionRequest.label(path, dirs), path.sub(/\A#{Regexp.escape(home)}(?=\/|\z)/, "~")) }
|
|
45
|
+
@dir_settled = cwd
|
|
35
46
|
@fields = [
|
|
36
47
|
Field.new(:prompt, "Prompt", :multiline, TextBuffer.new),
|
|
37
48
|
Field.new(:name, "Name", :text, TextBuffer.new),
|
|
38
|
-
Field.new(:cwd, "Directory", :text, TextBuffer.new(cwd
|
|
49
|
+
Field.new(:cwd, "Directory", :text, TextBuffer.new(cwd)),
|
|
39
50
|
Field.new(:model, "Model", :choice, DEFAULT, AgentsClient::MODELS),
|
|
40
51
|
Field.new(:effort, "Effort", :choice, DEFAULT, AgentsClient::EFFORTS),
|
|
41
52
|
Field.new(:permission_mode, "Permissions", :choice, DEFAULT, AgentsClient::PERMISSION_MODES),
|
|
42
53
|
Field.new(:worktree, "Worktree", :choice, "no", %w[no yes]),
|
|
43
|
-
Field.new(:remote, "Remote Control", :choice, DEFAULT, [DEFAULT, "no", "yes"])
|
|
54
|
+
Field.new(:remote, "Remote Control", :choice, DEFAULT, [DEFAULT, "no", "yes"]),
|
|
55
|
+
Field.new(:color, "Color", :choice, DEFAULT, AgentsClient::COLORS)
|
|
44
56
|
]
|
|
45
57
|
@focus = 0
|
|
46
58
|
@error = nil
|
|
@@ -66,7 +78,7 @@ module ClaudeInbox
|
|
|
66
78
|
@error = nil
|
|
67
79
|
@candidates = nil
|
|
68
80
|
return :changed if menu && menu_press(name)
|
|
69
|
-
before =
|
|
81
|
+
before = menu_query
|
|
70
82
|
case name
|
|
71
83
|
when :escape then return escape_pressed
|
|
72
84
|
when :ctrl_s then return submit(attach: false)
|
|
@@ -80,7 +92,7 @@ module ClaudeInbox
|
|
|
80
92
|
else
|
|
81
93
|
editable? ? focused.value.press(name, raw) : choose(name, raw)
|
|
82
94
|
end
|
|
83
|
-
edited = before &&
|
|
95
|
+
edited = before && menu_query && menu_query != before
|
|
84
96
|
@pick = 0 if edited
|
|
85
97
|
@dismissed = nil if edited
|
|
86
98
|
:changed
|
|
@@ -103,10 +115,12 @@ module ClaudeInbox
|
|
|
103
115
|
:changed
|
|
104
116
|
end
|
|
105
117
|
|
|
118
|
+
# Slash commands while one is being typed in the prompt; the recent and
|
|
119
|
+
# trusted directories once the Directory field is edited.
|
|
106
120
|
def menu
|
|
107
|
-
q =
|
|
121
|
+
q = menu_query
|
|
108
122
|
return nil if q.nil? || @dismissed == q
|
|
109
|
-
found = SlashCommands.match(commands, q)
|
|
123
|
+
found = (focused.key == :cwd) ? places(q) : SlashCommands.match(commands, q)
|
|
110
124
|
found.empty? ? nil : found
|
|
111
125
|
end
|
|
112
126
|
|
|
@@ -144,9 +158,12 @@ module ClaudeInbox
|
|
|
144
158
|
out << ""
|
|
145
159
|
out << " " + field_label(field(:prompt)) + @p.dim(" ⏎ newline")
|
|
146
160
|
out += prompt_box(field(:prompt), inner_w, prompt_h)
|
|
147
|
-
out += menu_rows
|
|
161
|
+
out += menu_rows if focused.key == :prompt
|
|
148
162
|
out << ""
|
|
149
|
-
@fields[1..].each
|
|
163
|
+
@fields[1..].each do |f|
|
|
164
|
+
out << " " + field_label(f) + field_value(f, inner_w - 16)
|
|
165
|
+
out += menu_rows if f.key == :cwd && focused.key == :cwd
|
|
166
|
+
end
|
|
150
167
|
out.first(height) + [""] * [height - out.size, 0].max
|
|
151
168
|
end
|
|
152
169
|
|
|
@@ -162,6 +179,7 @@ module ClaudeInbox
|
|
|
162
179
|
when :choice then [["← → h l", "change"], ["⏎", "next"]]
|
|
163
180
|
else [["⏎", "next"]]
|
|
164
181
|
end
|
|
182
|
+
keys.unshift(["^U", "recent"]) if focused.key == :cwd && @places.any?
|
|
165
183
|
keys += [["^S", "start"], ["^O", "start & open"],
|
|
166
184
|
["⇥", (focused.key == :cwd) ? "complete / next" : "next"], ["esc", "cancel"]]
|
|
167
185
|
hints(keys)
|
|
@@ -179,9 +197,25 @@ module ClaudeInbox
|
|
|
179
197
|
|
|
180
198
|
private
|
|
181
199
|
|
|
182
|
-
def
|
|
183
|
-
|
|
184
|
-
focused.value.head[/(?:\A|\s)\/(\S*)\z/, 1]
|
|
200
|
+
def menu_query
|
|
201
|
+
case focused.key
|
|
202
|
+
when :prompt then focused.value.head[/(?:\A|\s)\/(\S*)\z/, 1]
|
|
203
|
+
when :cwd then focused.value.to_s unless focused.value.to_s == @dir_settled
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def places(query)
|
|
208
|
+
q = query.downcase
|
|
209
|
+
found = @places.select { |d| d.path.downcase.include?(q) || d.description.downcase.include?(q) }
|
|
210
|
+
starts, rest = found.partition { |d| d.label.downcase.start_with?(q) }
|
|
211
|
+
starts + rest
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# A path put in the field by a pick or a completion rather than typed,
|
|
215
|
+
# so the menu stays shut until the next edit.
|
|
216
|
+
def settle_dir(path)
|
|
217
|
+
field(:cwd).value.replace(path)
|
|
218
|
+
@dir_settled = path
|
|
185
219
|
end
|
|
186
220
|
|
|
187
221
|
# Project commands live under the Directory field's path, so they
|
|
@@ -228,14 +262,18 @@ module ClaudeInbox
|
|
|
228
262
|
when :up, :ctrl_p then @pick = (@pick - 1) % menu.size
|
|
229
263
|
when :down, :ctrl_n then @pick = (@pick + 1) % menu.size
|
|
230
264
|
when :tab, :return, :enter then accept(picked)
|
|
231
|
-
when :escape then @dismissed =
|
|
265
|
+
when :escape then @dismissed = menu_query
|
|
232
266
|
else return false
|
|
233
267
|
end
|
|
234
268
|
true
|
|
235
269
|
end
|
|
236
270
|
|
|
237
|
-
def accept(
|
|
238
|
-
focused.
|
|
271
|
+
def accept(item)
|
|
272
|
+
if focused.key == :cwd
|
|
273
|
+
settle_dir(item.path)
|
|
274
|
+
else
|
|
275
|
+
focused.value.replace_before(menu_query.grapheme_clusters.size + 1, "#{item} ")
|
|
276
|
+
end
|
|
239
277
|
@pick = 0
|
|
240
278
|
end
|
|
241
279
|
|
|
@@ -285,6 +323,7 @@ module ClaudeInbox
|
|
|
285
323
|
f.choices.map { |c|
|
|
286
324
|
text = (c == DEFAULT) ? default_text(f) : c
|
|
287
325
|
if c == f.value then (on ? @theme.pill(" #{text} ", :cyan) : @theme.cyan_bold(" #{text} "))
|
|
326
|
+
elsif f.key == :color then @palette.paint(" #{text} ", c)
|
|
288
327
|
else @p.dim(" #{text} ")
|
|
289
328
|
end
|
|
290
329
|
}.join(" ")
|
|
@@ -292,6 +331,7 @@ module ClaudeInbox
|
|
|
292
331
|
end
|
|
293
332
|
|
|
294
333
|
def default_text(f)
|
|
334
|
+
return "none" if f.key == :color
|
|
295
335
|
resolved = defaults[f.key]
|
|
296
336
|
resolved ? "#{resolved} (settings)" : "auto (cli default)"
|
|
297
337
|
end
|
|
@@ -327,10 +367,10 @@ module ClaudeInbox
|
|
|
327
367
|
matches = Dir.glob(listing ? "#{base}/*" : "#{base}*").select { |d| File.directory?(d) }.sort
|
|
328
368
|
return nil if matches.empty?
|
|
329
369
|
if matches.size == 1
|
|
330
|
-
|
|
370
|
+
settle_dir("#{matches.first}/")
|
|
331
371
|
else
|
|
332
372
|
@candidates = matches.map { |d| File.basename(d) }
|
|
333
|
-
|
|
373
|
+
settle_dir(common_prefix(matches))
|
|
334
374
|
end
|
|
335
375
|
:changed
|
|
336
376
|
end
|
data/lib/claude_inbox/painter.rb
CHANGED
|
@@ -18,9 +18,7 @@ module ClaudeInbox
|
|
|
18
18
|
next if @prev[i] == line
|
|
19
19
|
buf << TTY::Cursor.move_to(0, i) << line
|
|
20
20
|
end
|
|
21
|
-
|
|
22
|
-
(lines.size...@prev.size).each { |i| buf << TTY::Cursor.move_to(0, i) << TTY::Cursor.clear_line }
|
|
23
|
-
end
|
|
21
|
+
(lines.size...@prev.size).each { |i| buf << TTY::Cursor.move_to(0, i) << TTY::Cursor.clear_line }
|
|
24
22
|
@out.print buf unless buf.empty?
|
|
25
23
|
@out.flush
|
|
26
24
|
@prev = lines
|
data/lib/claude_inbox/palette.rb
CHANGED
data/lib/claude_inbox/paste.rb
CHANGED
data/lib/claude_inbox/peek.rb
CHANGED
|
@@ -16,7 +16,6 @@ module ClaudeInbox
|
|
|
16
16
|
@logs = logs
|
|
17
17
|
@open = false
|
|
18
18
|
@offset = 0
|
|
19
|
-
@selected = nil
|
|
20
19
|
@session = nil
|
|
21
20
|
end
|
|
22
21
|
|
|
@@ -30,8 +29,7 @@ module ClaudeInbox
|
|
|
30
29
|
def close = @open = false
|
|
31
30
|
|
|
32
31
|
# Called on every selection change; a fold has no session.
|
|
33
|
-
def select(
|
|
34
|
-
@selected = selection
|
|
32
|
+
def select(session)
|
|
35
33
|
@session = session
|
|
36
34
|
@offset = 0
|
|
37
35
|
@logs.want(session.id) if session&.actionable?
|
|
@@ -42,17 +40,16 @@ module ClaudeInbox
|
|
|
42
40
|
@offset = [@offset + delta, 0].max
|
|
43
41
|
end
|
|
44
42
|
|
|
45
|
-
# `row` is the selected row as the frame shows it, or nil
|
|
46
|
-
# selected. Answers nil when there is no pane to paint.
|
|
43
|
+
# `row` is the selected row as the frame shows it, or nil on a fold or
|
|
44
|
+
# with nothing selected. Answers nil when there is no pane to paint.
|
|
47
45
|
def view(row, body_h)
|
|
48
|
-
return nil unless @open &&
|
|
49
|
-
View.new(scrolled(body(row), body_h), row
|
|
46
|
+
return nil unless @open && row
|
|
47
|
+
View.new(scrolled(body(row), body_h), row.label, subtitle(row))
|
|
50
48
|
end
|
|
51
49
|
|
|
52
50
|
private
|
|
53
51
|
|
|
54
52
|
def body(row)
|
|
55
|
-
return ["(nothing selected)"] unless row
|
|
56
53
|
return interactive_note(row.session) if row.session.interactive?
|
|
57
54
|
@logs.cached(row.session.id) || ["(loading…)"]
|
|
58
55
|
end
|
|
@@ -62,7 +59,6 @@ module ClaudeInbox
|
|
|
62
59
|
end
|
|
63
60
|
|
|
64
61
|
def subtitle(row)
|
|
65
|
-
return nil unless row
|
|
66
62
|
s = row.session
|
|
67
63
|
parts = [s.effective_state, s.status, s.waiting_for, s.id, s.started_at&.strftime("started %b %-d %H:%M")].compact
|
|
68
64
|
parts += s.prs.map { |pr| "#{pr.short} #{pr.state&.downcase || "?"}" }
|
data/lib/claude_inbox/poller.rb
CHANGED
|
@@ -1,13 +1,11 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require_relative "actions"
|
|
4
3
|
require_relative "sessions"
|
|
5
|
-
require_relative "snapshot"
|
|
6
4
|
|
|
7
5
|
module ClaudeInbox
|
|
8
6
|
# Asks `claude agents` for the list off the main thread, every INTERVAL
|
|
9
7
|
# seconds and on demand, puts what it finds in the store and the snapshot,
|
|
10
|
-
# and tells App over the shared queue: [:sessions
|
|
8
|
+
# and tells App over the shared queue: [:sessions] after each
|
|
11
9
|
# hand-over, [:error, msg] when a poll fails, [:notice, text] when the
|
|
12
10
|
# reaper took something. Between polls it applies what another front end
|
|
13
11
|
# asked for. Nothing here touches App's state directly; the queue is the
|
|
@@ -17,7 +15,7 @@ module ClaudeInbox
|
|
|
17
15
|
INTERVAL = 4
|
|
18
16
|
ACTIONS_INTERVAL = 0.5
|
|
19
17
|
|
|
20
|
-
def initialize(client:, store:, pull_requests:, reaper:, queue:, snapshot
|
|
18
|
+
def initialize(client:, store:, pull_requests:, reaper:, queue:, snapshot:, actions:,
|
|
21
19
|
interval: INTERVAL, clock: -> { Time.now })
|
|
22
20
|
@client = client
|
|
23
21
|
@clock = clock
|
|
@@ -72,11 +70,9 @@ module ClaudeInbox
|
|
|
72
70
|
@queue << [:error, e.message]
|
|
73
71
|
end
|
|
74
72
|
|
|
75
|
-
# Applies the requests another front end left
|
|
73
|
+
# Applies the requests another front end left.
|
|
76
74
|
def apply
|
|
77
|
-
|
|
78
|
-
write_snapshot
|
|
79
|
-
true
|
|
75
|
+
write_snapshot if @actions.drain(@store) > 0
|
|
80
76
|
end
|
|
81
77
|
|
|
82
78
|
private
|
|
@@ -84,7 +80,7 @@ module ClaudeInbox
|
|
|
84
80
|
def publish(sessions)
|
|
85
81
|
@store.update(sessions)
|
|
86
82
|
write_snapshot
|
|
87
|
-
@queue << [:sessions
|
|
83
|
+
@queue << [:sessions]
|
|
88
84
|
end
|
|
89
85
|
|
|
90
86
|
def write_snapshot
|
|
@@ -10,7 +10,7 @@ module ClaudeInbox
|
|
|
10
10
|
# DRAFT, the same as Claude Code's own cache: OPEN, DRAFT, MERGED, CLOSED,
|
|
11
11
|
# or nil when nothing has told us yet. `resolved_at` is when it was merged
|
|
12
12
|
# or closed, in epoch seconds, once gh has said.
|
|
13
|
-
PullRequest = Struct.new(:number, :url, :state, :
|
|
13
|
+
PullRequest = Struct.new(:number, :url, :state, :resolved_at) do
|
|
14
14
|
def short = number ? "##{number}" : url.to_s.sub(%r{\Ahttps?://(www\.)?}, "")
|
|
15
15
|
|
|
16
16
|
def merged? = state == "MERGED"
|
|
@@ -76,7 +76,7 @@ module ClaudeInbox
|
|
|
76
76
|
# one (interactive, or forgotten) has none; Sessions.load sees to the
|
|
77
77
|
# order. `overrides` maps session key => url for links set by hand; an
|
|
78
78
|
# override replaces the scanned list.
|
|
79
|
-
def enrich(sessions, overrides
|
|
79
|
+
def enrich(sessions, overrides)
|
|
80
80
|
sessions.map do |s|
|
|
81
81
|
urls = overrides[s.key] ? [overrides[s.key]] : (s.job_state&.pr_urls || [])
|
|
82
82
|
s.with(prs: urls.map { |u| known(u) })
|
|
@@ -115,7 +115,7 @@ module ClaudeInbox
|
|
|
115
115
|
h = JSON.parse(json)
|
|
116
116
|
state = (h["state"] == "OPEN" && h["isDraft"]) ? "DRAFT" : h["state"]
|
|
117
117
|
resolved_at = h["closedAt"] && Time.iso8601(h["closedAt"]).to_i
|
|
118
|
-
PullRequest.new(number: h["number"], url: h["url"] || url, state: state,
|
|
118
|
+
PullRequest.new(number: h["number"], url: h["url"] || url, state: state, resolved_at: resolved_at)
|
|
119
119
|
rescue JSON::ParserError, ArgumentError
|
|
120
120
|
nil
|
|
121
121
|
end
|
|
@@ -140,7 +140,7 @@ module ClaudeInbox
|
|
|
140
140
|
number = url[%r{/pull/(\d+)}, 1]&.to_i
|
|
141
141
|
cached = resolved[url] || claude_cache[url]
|
|
142
142
|
PullRequest.new(number: cached&.dig("number") || number, url: url, state: cached&.dig("state"),
|
|
143
|
-
|
|
143
|
+
resolved_at: cached&.dig("resolved_at"))
|
|
144
144
|
end
|
|
145
145
|
|
|
146
146
|
# Same shape as Claude Code's cache, so `seed` reads both alike.
|
|
@@ -154,13 +154,13 @@ module ClaudeInbox
|
|
|
154
154
|
|
|
155
155
|
# Under @mutex.
|
|
156
156
|
def remember(pr)
|
|
157
|
-
resolved[pr.url] = {"number" => pr.number, "state" => pr.state, "
|
|
157
|
+
resolved[pr.url] = {"number" => pr.number, "state" => pr.state, "resolved_at" => pr.resolved_at}
|
|
158
158
|
return unless @resolved_path
|
|
159
159
|
Records.save(@resolved_path, resolved)
|
|
160
160
|
end
|
|
161
161
|
|
|
162
162
|
def fetch(url)
|
|
163
|
-
r = Subprocess.capture(@gh, "pr", "view", url, "--json", "number,state,isDraft,
|
|
163
|
+
r = Subprocess.capture(@gh, "pr", "view", url, "--json", "number,state,isDraft,url,closedAt")
|
|
164
164
|
r.success? ? self.class.parse(url, r.out) : nil
|
|
165
165
|
rescue SystemCallError
|
|
166
166
|
nil
|
|
@@ -30,7 +30,7 @@ module ClaudeInbox
|
|
|
30
30
|
# time, or nil.
|
|
31
31
|
# Parses only when the file has changed, since render asks several times
|
|
32
32
|
# a second.
|
|
33
|
-
def windows(now
|
|
33
|
+
def windows(now)
|
|
34
34
|
mtime = File.mtime(@path)
|
|
35
35
|
return nil if now - mtime > STALE_AFTER
|
|
36
36
|
@data = parse if mtime != @mtime
|
data/lib/claude_inbox/reaper.rb
CHANGED
|
@@ -20,15 +20,13 @@ module ClaudeInbox
|
|
|
20
20
|
RETRY_AFTER = 24 * 3600
|
|
21
21
|
DEFAULT_LOG = File.join(Dir.home, ".config", "claude-inbox", "reaped.log")
|
|
22
22
|
|
|
23
|
-
def initialize(client, store, log_path: DEFAULT_LOG, enabled:
|
|
23
|
+
def initialize(client, store, log_path: DEFAULT_LOG, enabled: true)
|
|
24
24
|
@client = client
|
|
25
25
|
@store = store
|
|
26
26
|
@log_path = log_path
|
|
27
27
|
@enabled = enabled
|
|
28
28
|
end
|
|
29
29
|
|
|
30
|
-
def self.enabled? = ENV["CLAUDE_INBOX_NO_REAP"].to_s.empty?
|
|
31
|
-
|
|
32
30
|
# For --fixture runs and tests: selects nothing, deletes nothing, and
|
|
33
31
|
# needs neither a client nor a store to do it.
|
|
34
32
|
def self.disabled = new(nil, nil, enabled: false)
|
|
@@ -75,7 +73,7 @@ module ClaudeInbox
|
|
|
75
73
|
row.key
|
|
76
74
|
rescue AgentsClient::Error => e
|
|
77
75
|
reason = e.message.lines.first.to_s.strip
|
|
78
|
-
@store.mark_reap_failed(row.key
|
|
76
|
+
@store.mark_reap_failed(row.key)
|
|
79
77
|
write(log, row, now_i, "kept — #{reason}")
|
|
80
78
|
nil
|
|
81
79
|
end
|
|
@@ -12,7 +12,7 @@ module ClaudeInbox
|
|
|
12
12
|
TOKEN = /\A[!#$%&'*+\-.^_`|~0-9A-Za-z]+\z/
|
|
13
13
|
|
|
14
14
|
REASONS = {
|
|
15
|
-
|
|
15
|
+
200 => "OK", 201 => "Created", 400 => "Bad Request", 401 => "Unauthorized",
|
|
16
16
|
403 => "Forbidden", 404 => "Not Found", 405 => "Method Not Allowed", 409 => "Conflict",
|
|
17
17
|
411 => "Length Required", 413 => "Content Too Large", 415 => "Unsupported Media Type",
|
|
18
18
|
421 => "Misdirected Request", 422 => "Unprocessable Content", 500 => "Internal Server Error",
|
|
@@ -107,7 +107,7 @@ module ClaudeInbox
|
|
|
107
107
|
end
|
|
108
108
|
end
|
|
109
109
|
|
|
110
|
-
def self.write(io, status, headers
|
|
110
|
+
def self.write(io, status, headers, body)
|
|
111
111
|
body = body.b
|
|
112
112
|
head = ["HTTP/1.1 #{status} #{REASONS.fetch(status)}"]
|
|
113
113
|
HEADERS.merge(headers, "Content-Length" => body.bytesize.to_s).each { |name, value| head << "#{name}: #{value}" }
|
|
@@ -17,7 +17,7 @@ module ClaudeInbox
|
|
|
17
17
|
# connection limits, the static files and the Pairing token, in front of
|
|
18
18
|
# Start. Like the Poller it reaches App only through the queue, and it
|
|
19
19
|
# never writes to the terminal: every failure becomes a response or a
|
|
20
|
-
# notice.
|
|
20
|
+
# notice. It is App's only way into Remote.
|
|
21
21
|
class Listener
|
|
22
22
|
DEFAULT_PORT = 7433
|
|
23
23
|
DEFAULT_MODES = %w[default auto plan].freeze
|
|
@@ -58,17 +58,13 @@ module ClaudeInbox
|
|
|
58
58
|
# lock, as `held_by`) or :failed (for good, as `error` says). `urls` and
|
|
59
59
|
# `firewall` are nil until `refresh`.
|
|
60
60
|
Snapshot = Data.define(:state, :port, :lan, :urls, :firewall, :allowed_modes, :recent, :held_by, :fixture, :error) do
|
|
61
|
-
def initialize(error: nil, **) = super
|
|
62
|
-
|
|
63
61
|
# Over a VPN only an address reaches the Mac: multicast DNS, which
|
|
64
62
|
# the .local name needs, stays on the LAN.
|
|
65
63
|
def pairing_url = (lan && urls&.[](1)) || urls&.first
|
|
66
64
|
end
|
|
67
65
|
|
|
68
66
|
# `count` is how many times `via` got `result`, the last at `at`.
|
|
69
|
-
Outcome = Data.define(:at, :via, :result, :count)
|
|
70
|
-
def initialize(at:, via:, result:, count: 1) = super
|
|
71
|
-
end
|
|
67
|
+
Outcome = Data.define(:at, :via, :result, :count)
|
|
72
68
|
|
|
73
69
|
# App's `listen:` from the flags, else the environment; nil when neither
|
|
74
70
|
# asks for it. --listen-lan wins over --listen, whichever came last.
|
|
@@ -124,6 +124,9 @@ code { font: 15px ui-monospace, SFMono-Regular, Menlo, monospace; }
|
|
|
124
124
|
<label for="permission_mode">Permissions</label>
|
|
125
125
|
<select id="permission_mode"></select>
|
|
126
126
|
<p class="error" id="error-permission_mode"></p>
|
|
127
|
+
<label for="color">Color</label>
|
|
128
|
+
<select id="color"></select>
|
|
129
|
+
<p class="error" id="error-color"></p>
|
|
127
130
|
|
|
128
131
|
<div class="toggles">
|
|
129
132
|
<label class="toggle">Worktree <input type="checkbox" id="worktree" switch></label>
|
|
@@ -152,7 +155,7 @@ code { font: 15px ui-monospace, SFMono-Regular, Menlo, monospace; }
|
|
|
152
155
|
const MAX_IMAGES = 8;
|
|
153
156
|
const MAX_BODY = 16777216;
|
|
154
157
|
const LONG_EDGE = 2000;
|
|
155
|
-
const FIELDS = ["prompt", "name", "cwd", "model", "effort", "permission_mode", "images"];
|
|
158
|
+
const FIELDS = ["prompt", "name", "cwd", "model", "effort", "permission_mode", "color", "images"];
|
|
156
159
|
const NO_ANSWER = "no answer from the inbox. Is it open with --listen-lan, and is the Mac awake?";
|
|
157
160
|
const REJECTED = "token rejected: press N in the inbox and pair again";
|
|
158
161
|
const TOKEN = /^[A-Za-z0-9_-]{32,}$/;
|
|
@@ -223,9 +226,9 @@ function fill() {
|
|
|
223
226
|
$("cwd").replaceChildren(...dirs.map((d) => new Option(d.label, d.path)), new Option("another directory…", ""));
|
|
224
227
|
$("cwd").value = typed || !dirs.length ? "" : last || dirs[0].path;
|
|
225
228
|
if (typed) $("cwd-other").value = last;
|
|
226
|
-
for (const [id, list] of [["model", options.models], ["effort", options.efforts], ["permission_mode", options.permission_modes]]) {
|
|
229
|
+
for (const [id, list] of [["model", options.models], ["effort", options.efforts], ["permission_mode", options.permission_modes], ["color", options.colors]]) {
|
|
227
230
|
const picked = $(id).value;
|
|
228
|
-
$(id).replaceChildren(...(list || []).map((v) => new Option(v, v)));
|
|
231
|
+
$(id).replaceChildren(...(list || []).map((v) => new Option(id === "color" && v === "default" ? "none" : v, v)));
|
|
229
232
|
if ((list || []).includes(picked)) $(id).value = picked;
|
|
230
233
|
}
|
|
231
234
|
relabel();
|
|
@@ -380,7 +383,7 @@ async function start() {
|
|
|
380
383
|
const fields = {
|
|
381
384
|
prompt: $("prompt").value, name: $("name").value, cwd: cwd(),
|
|
382
385
|
model: $("model").value, effort: $("effort").value, permission_mode: $("permission_mode").value,
|
|
383
|
-
worktree: $("worktree").checked, remote: remote
|
|
386
|
+
color: $("color").value, worktree: $("worktree").checked, remote: remote
|
|
384
387
|
};
|
|
385
388
|
const body = JSON.stringify({...fields, images: images.map((image) => ({data: image.url.slice(image.url.indexOf(",") + 1)}))});
|
|
386
389
|
const size = new Blob([body]).size;
|
|
@@ -28,7 +28,8 @@ module ClaudeInbox
|
|
|
28
28
|
# Ethernet and Wi-Fi (en*) first: the first private address is the one
|
|
29
29
|
# `c` copies, and a VM's bridge is no use to a phone.
|
|
30
30
|
def self.addresses(interfaces = Socket.getifaddrs)
|
|
31
|
-
interfaces.select { |i| i.addr&.ip? }.
|
|
31
|
+
en, rest = interfaces.select { |i| i.addr&.ip? }.partition { |i| i.name.start_with?("en") }
|
|
32
|
+
(en + rest).map(&:addr)
|
|
32
33
|
end
|
|
33
34
|
|
|
34
35
|
# :off, :on or :block_all; nil without macOS's application firewall.
|
|
@@ -26,7 +26,7 @@ module ClaudeInbox
|
|
|
26
26
|
BODY_TIMEOUT = 120
|
|
27
27
|
KEYS_KEPT = 16
|
|
28
28
|
|
|
29
|
-
def initialize(client:, store:, queue:, allowed_modes:, fixture
|
|
29
|
+
def initialize(client:, store:, queue:, allowed_modes:, fixture:, images_dir: Images::DEFAULT_DIR,
|
|
30
30
|
trust: Trust.method(:projects), settings: Settings.method(:defaults), bridge_wait: 3)
|
|
31
31
|
@client = client
|
|
32
32
|
@store = store
|
|
@@ -58,17 +58,12 @@ module ClaudeInbox
|
|
|
58
58
|
models: AgentsClient::MODELS,
|
|
59
59
|
efforts: AgentsClient::EFFORTS,
|
|
60
60
|
permission_modes: AgentsClient::PERMISSION_MODES & @allowed_modes,
|
|
61
|
+
colors: AgentsClient::COLORS,
|
|
61
62
|
dirs: paths.map { |path| {path: path, label: SessionRequest.label(path, paths), defaults: @settings.call(path).to_h} }
|
|
62
63
|
}
|
|
63
64
|
end
|
|
64
65
|
|
|
65
|
-
|
|
66
|
-
# trust dialog was accepted: the likely choices first. Only an order;
|
|
67
|
-
# the CLI still decides where a session may start.
|
|
68
|
-
def dir_paths
|
|
69
|
-
recent = @store.sessions.sort_by { |s| -s.started_at.to_i }.filter_map { |s| s.cwd && SessionRequest.strip_worktree(s.cwd) }
|
|
70
|
-
(recent + @trust.call).uniq.select { |dir| File.directory?(dir) }
|
|
71
|
-
end
|
|
66
|
+
def dir_paths = SessionRequest.directories(@store.sessions, @trust.call)
|
|
72
67
|
|
|
73
68
|
# Checked cheapest first, and nothing is written to disk until every
|
|
74
69
|
# check has passed. A retry with the same Idempotency-Key gets the
|