claude-inbox 0.3.0 → 0.5.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 +93 -165
- data/exe/claude-inbox +49 -4
- data/lib/claude_inbox/actions.rb +91 -0
- data/lib/claude_inbox/agents_client.rb +15 -10
- data/lib/claude_inbox/app.rb +58 -18
- data/lib/claude_inbox/headless.rb +30 -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 +67 -31
- data/lib/claude_inbox/painter.rb +3 -6
- data/lib/claude_inbox/paste.rb +2 -0
- data/lib/claude_inbox/poller.rb +46 -25
- data/lib/claude_inbox/pull_requests.rb +32 -18
- data/lib/claude_inbox/rate_limits.rb +2 -4
- data/lib/claude_inbox/reaper.rb +1 -3
- data/lib/claude_inbox/remote/http.rb +11 -8
- data/lib/claude_inbox/remote/listener.rb +4 -9
- data/lib/claude_inbox/remote/start.rb +4 -11
- data/lib/claude_inbox/renderer.rb +8 -16
- data/lib/claude_inbox/session_request.rb +10 -0
- data/lib/claude_inbox/settings.rb +1 -0
- data/lib/claude_inbox/slash_commands.rb +13 -12
- data/lib/claude_inbox/snapshot.rb +47 -0
- data/lib/claude_inbox/store/entry.rb +0 -2
- data/lib/claude_inbox/store/row.rb +7 -5
- data/lib/claude_inbox/store.rb +24 -9
- data/lib/claude_inbox/switch.rb +38 -0
- data/lib/claude_inbox/terminal.rb +8 -0
- data/lib/claude_inbox/text_buffer.rb +84 -33
- data/lib/claude_inbox/writer_lock.rb +58 -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 +491 -0
- data/mod/tests/contract.test.tsx +21 -0
- data/mod/tests/pane.test.tsx +217 -0
- data/mod/tests/snapshot.fixture.ts +44 -0
- data/mod/tests/snooze.test.tsx +31 -0
- data/mod/tsconfig.json +3 -0
- data/mod/types/index.d.ts +28 -0
- metadata +16 -2
- data/lib/claude_inbox/remote.rb +0 -10
data/lib/claude_inbox/poller.rb
CHANGED
|
@@ -4,45 +4,41 @@ require_relative "sessions"
|
|
|
4
4
|
|
|
5
5
|
module ClaudeInbox
|
|
6
6
|
# 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
|
-
#
|
|
7
|
+
# seconds and on demand, puts what it finds in the store and the snapshot,
|
|
8
|
+
# and tells App over the shared queue: [:sessions, list] for each
|
|
9
|
+
# hand-over, [:error, msg] when a poll fails, [:notice, text] when the
|
|
10
|
+
# reaper took something. Between polls it applies what another front end
|
|
11
|
+
# asked for. Nothing here touches App's state directly; the queue is the
|
|
12
|
+
# whole of the interface. One worker runs every poll, so two can never
|
|
13
|
+
# overlap and publish the list out of order.
|
|
13
14
|
class Poller
|
|
14
15
|
INTERVAL = 4
|
|
16
|
+
ACTIONS_INTERVAL = 0.5
|
|
15
17
|
|
|
16
|
-
def initialize(client:, store:, pull_requests:, reaper:, queue:,
|
|
18
|
+
def initialize(client:, store:, pull_requests:, reaper:, queue:, snapshot:, actions:,
|
|
19
|
+
interval: INTERVAL, clock: -> { Time.now })
|
|
17
20
|
@client = client
|
|
18
21
|
@clock = clock
|
|
19
22
|
@store = store
|
|
20
23
|
@pull_requests = pull_requests
|
|
21
24
|
@reaper = reaper
|
|
22
25
|
@queue = queue
|
|
26
|
+
@snapshot = snapshot
|
|
27
|
+
@actions = actions
|
|
23
28
|
@interval = interval
|
|
24
29
|
@wake = Queue.new
|
|
25
|
-
@paused = false
|
|
26
|
-
@lock = Mutex.new
|
|
27
30
|
end
|
|
28
31
|
|
|
29
32
|
def start
|
|
30
33
|
return if @thread&.alive?
|
|
31
34
|
soon
|
|
32
35
|
@thread = Thread.new { worker }
|
|
36
|
+
@applier = Thread.new { applier }
|
|
33
37
|
end
|
|
34
38
|
|
|
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
|
|
39
|
+
def stop
|
|
40
|
+
@thread&.kill
|
|
41
|
+
@applier&.kill
|
|
46
42
|
end
|
|
47
43
|
|
|
48
44
|
def soon = @wake << true
|
|
@@ -59,35 +55,60 @@ module ClaudeInbox
|
|
|
59
55
|
sessions = Sessions.load(client: @client, pull_requests: @pull_requests, overrides: @store.pr_overrides)
|
|
60
56
|
doomed = @reaper.due(sessions, now)
|
|
61
57
|
@store.hide(doomed)
|
|
62
|
-
|
|
58
|
+
publish(sessions)
|
|
63
59
|
reaped = []
|
|
64
60
|
begin
|
|
65
61
|
reaped = @reaper.sweep(sessions.select { |s| doomed.include?(s.key) }, now)
|
|
66
62
|
ensure
|
|
67
63
|
@store.release(doomed - reaped)
|
|
68
64
|
end
|
|
69
|
-
|
|
65
|
+
publish(sessions) if reaped != doomed
|
|
70
66
|
@queue << [:notice, @reaper.report(reaped)] if reaped.any?
|
|
71
67
|
fresh, moved = @pull_requests.refresh(sessions)
|
|
72
|
-
|
|
68
|
+
publish(fresh) if moved
|
|
73
69
|
rescue => e
|
|
74
70
|
@queue << [:error, e.message]
|
|
75
71
|
end
|
|
76
72
|
|
|
73
|
+
# Applies the requests another front end left, and says whether any landed.
|
|
74
|
+
def apply
|
|
75
|
+
return false unless @actions.drain(@store) > 0
|
|
76
|
+
write_snapshot
|
|
77
|
+
true
|
|
78
|
+
end
|
|
79
|
+
|
|
77
80
|
private
|
|
78
81
|
|
|
82
|
+
def publish(sessions)
|
|
83
|
+
@store.update(sessions)
|
|
84
|
+
write_snapshot
|
|
85
|
+
@queue << [:sessions, sessions]
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def write_snapshot
|
|
89
|
+
now = @clock.call
|
|
90
|
+
@snapshot.write(@store.sections(now), now)
|
|
91
|
+
end
|
|
92
|
+
|
|
79
93
|
# Past StandardError `once` does not catch, and a dead worker would end
|
|
80
94
|
# polling with nothing on screen to say so.
|
|
81
95
|
def worker
|
|
82
96
|
loop do
|
|
83
97
|
@wake.pop(timeout: @interval)
|
|
84
98
|
@wake.clear
|
|
85
|
-
once
|
|
99
|
+
once
|
|
86
100
|
rescue SystemStackError, ScriptError, SecurityError => e
|
|
87
101
|
@queue << [:error, e.message]
|
|
88
102
|
end
|
|
89
103
|
end
|
|
90
104
|
|
|
91
|
-
def
|
|
105
|
+
def applier
|
|
106
|
+
loop do
|
|
107
|
+
sleep ACTIONS_INTERVAL
|
|
108
|
+
apply
|
|
109
|
+
rescue => e
|
|
110
|
+
@queue << [:error, e.message]
|
|
111
|
+
end
|
|
112
|
+
end
|
|
92
113
|
end
|
|
93
114
|
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
|
|
@@ -65,7 +76,7 @@ module ClaudeInbox
|
|
|
65
76
|
# one (interactive, or forgotten) has none; Sessions.load sees to the
|
|
66
77
|
# order. `overrides` maps session key => url for links set by hand; an
|
|
67
78
|
# override replaces the scanned list.
|
|
68
|
-
def enrich(sessions, overrides
|
|
79
|
+
def enrich(sessions, overrides)
|
|
69
80
|
sessions.map do |s|
|
|
70
81
|
urls = overrides[s.key] ? [overrides[s.key]] : (s.job_state&.pr_urls || [])
|
|
71
82
|
s.with(prs: urls.map { |u| known(u) })
|
|
@@ -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
|
|
@@ -32,7 +30,7 @@ module ClaudeInbox
|
|
|
32
30
|
# time, or nil.
|
|
33
31
|
# Parses only when the file has changed, since render asks several times
|
|
34
32
|
# a second.
|
|
35
|
-
def windows(now
|
|
33
|
+
def windows(now)
|
|
36
34
|
mtime = File.mtime(@path)
|
|
37
35
|
return nil if now - mtime > STALE_AFTER
|
|
38
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)
|
|
@@ -32,7 +32,7 @@ module ClaudeInbox
|
|
|
32
32
|
# the JSON body, and the headers that status calls for. `note`, when
|
|
33
33
|
# set, is what the refusal is logged as in place of its message.
|
|
34
34
|
class Error < StandardError
|
|
35
|
-
attr_reader :status, :headers, :
|
|
35
|
+
attr_reader :status, :headers, :note
|
|
36
36
|
|
|
37
37
|
def initialize(status, message, headers: {}, note: nil, **details)
|
|
38
38
|
super(message)
|
|
@@ -43,6 +43,10 @@ module ClaudeInbox
|
|
|
43
43
|
end
|
|
44
44
|
|
|
45
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
|
|
46
50
|
end
|
|
47
51
|
|
|
48
52
|
# A request head. `headers` are keyed in lower case, and a repeated
|
|
@@ -50,12 +54,11 @@ module ClaudeInbox
|
|
|
50
54
|
# pass for the first. The bytes read past the head stay here for the
|
|
51
55
|
# body.
|
|
52
56
|
class Request
|
|
53
|
-
attr_reader :verb, :path, :
|
|
57
|
+
attr_reader :verb, :path, :headers
|
|
54
58
|
|
|
55
|
-
def initialize(verb, target,
|
|
59
|
+
def initialize(verb, target, headers, rest)
|
|
56
60
|
@verb = verb
|
|
57
|
-
@path
|
|
58
|
-
@version = version
|
|
61
|
+
@path = target.split("?", 2).first
|
|
59
62
|
@headers = headers
|
|
60
63
|
@rest = rest
|
|
61
64
|
end
|
|
@@ -88,13 +91,13 @@ module ClaudeInbox
|
|
|
88
91
|
def self.read_head(io, deadline:)
|
|
89
92
|
rest = +"".b
|
|
90
93
|
line = read_line(io, rest, deadline)
|
|
91
|
-
request = line.match(%r{\A(\S+) (/\S*) HTTP/
|
|
94
|
+
request = line.match(%r{\A(\S+) (/\S*) HTTP/1\.[01]\z})
|
|
92
95
|
raise Error.new(400, "not an HTTP/1.x request line") unless request && request[1].match?(TOKEN)
|
|
93
96
|
headers = {}
|
|
94
97
|
count = 0
|
|
95
98
|
loop do
|
|
96
99
|
line = read_line(io, rest, deadline)
|
|
97
|
-
return Request.new(request[1], request[2],
|
|
100
|
+
return Request.new(request[1], request[2], headers, rest) if line.empty?
|
|
98
101
|
raise Error.new(400, "more than #{MAX_HEADERS} headers") if (count += 1) > MAX_HEADERS
|
|
99
102
|
name, value = line.split(":", 2)
|
|
100
103
|
raise Error.new(400, "malformed header") unless value && name.match?(TOKEN)
|
|
@@ -104,7 +107,7 @@ module ClaudeInbox
|
|
|
104
107
|
end
|
|
105
108
|
end
|
|
106
109
|
|
|
107
|
-
def self.write(io, status, headers
|
|
110
|
+
def self.write(io, status, headers, body)
|
|
108
111
|
body = body.b
|
|
109
112
|
head = ["HTTP/1.1 #{status} #{REASONS.fetch(status)}"]
|
|
110
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.
|
|
@@ -109,7 +105,7 @@ module ClaudeInbox
|
|
|
109
105
|
def self.disabled = new(client: nil, store: nil, queue: nil, pairing: nil, port: nil)
|
|
110
106
|
|
|
111
107
|
# `start_options` are Start's own.
|
|
112
|
-
def initialize(
|
|
108
|
+
def initialize(queue:, port:, pairing: Pairing.new, lan: false, allowed_modes: DEFAULT_MODES,
|
|
113
109
|
lock_path: LOCK_PATH, fixture: false, retry_every: RETRY_EVERY, **start_options)
|
|
114
110
|
@queue = queue
|
|
115
111
|
@pairing = pairing
|
|
@@ -119,8 +115,7 @@ module ClaudeInbox
|
|
|
119
115
|
@lock_path = lock_path
|
|
120
116
|
@fixture = fixture
|
|
121
117
|
@retry_every = retry_every
|
|
122
|
-
@start = Start.new(
|
|
123
|
-
fixture: fixture, **start_options)
|
|
118
|
+
@start = Start.new(queue: queue, allowed_modes: allowed_modes, fixture: fixture, **start_options)
|
|
124
119
|
@mutex = Mutex.new
|
|
125
120
|
@state = :off
|
|
126
121
|
@recent = []
|
|
@@ -26,15 +26,14 @@ module ClaudeInbox
|
|
|
26
26
|
BODY_TIMEOUT = 120
|
|
27
27
|
KEYS_KEPT = 16
|
|
28
28
|
|
|
29
|
-
def initialize(client:, store:, queue:, allowed_modes:, fixture
|
|
30
|
-
|
|
29
|
+
def initialize(client:, store:, queue:, allowed_modes:, fixture:, images_dir: Images::DEFAULT_DIR,
|
|
30
|
+
trust: Trust.method(:projects), settings: Settings.method(:defaults), bridge_wait: 3)
|
|
31
31
|
@client = client
|
|
32
32
|
@store = store
|
|
33
33
|
@queue = queue
|
|
34
34
|
@allowed_modes = allowed_modes
|
|
35
35
|
@fixture = fixture
|
|
36
36
|
@images_dir = images_dir
|
|
37
|
-
@jobs_dir = jobs_dir
|
|
38
37
|
@trust = trust
|
|
39
38
|
@settings = settings
|
|
40
39
|
@bridge_wait = bridge_wait
|
|
@@ -63,13 +62,7 @@ module ClaudeInbox
|
|
|
63
62
|
}
|
|
64
63
|
end
|
|
65
64
|
|
|
66
|
-
|
|
67
|
-
# trust dialog was accepted: the likely choices first. Only an order;
|
|
68
|
-
# the CLI still decides where a session may start.
|
|
69
|
-
def dir_paths
|
|
70
|
-
recent = @store.sessions.sort_by { |s| -s.started_at.to_i }.filter_map { |s| s.cwd && SessionRequest.strip_worktree(s.cwd) }
|
|
71
|
-
(recent + @trust.call).uniq.select { |dir| File.directory?(dir) }
|
|
72
|
-
end
|
|
65
|
+
def dir_paths = SessionRequest.directories(@store.sessions, @trust.call)
|
|
73
66
|
|
|
74
67
|
# Checked cheapest first, and nothing is written to disk until every
|
|
75
68
|
# check has passed. A retry with the same Idempotency-Key gets the
|
|
@@ -192,7 +185,7 @@ module ClaudeInbox
|
|
|
192
185
|
def remote_url(id, wait:)
|
|
193
186
|
deadline = Http.monotonic + wait
|
|
194
187
|
loop do
|
|
195
|
-
url = Session.new(id: id, job_state: JobState.read(id, jobs_dir: @jobs_dir)).remote_url
|
|
188
|
+
url = Session.new(id: id, job_state: JobState.read(id, jobs_dir: @client.jobs_dir)).remote_url
|
|
196
189
|
return url if url || Http.monotonic >= deadline
|
|
197
190
|
sleep 0.2
|
|
198
191
|
end
|
|
@@ -11,12 +11,6 @@ module ClaudeInbox
|
|
|
11
11
|
# `width` columns wide) plus a parallel Array of selectable items.
|
|
12
12
|
# Pure: no terminal, no IO, no clock beyond the `now` it is handed.
|
|
13
13
|
class Renderer
|
|
14
|
-
Item = Struct.new(:kind, :row, :section) do
|
|
15
|
-
def selection = (kind == :fold_toggle) ? Store::Selection.fold(section) : Store::Selection.row(row.key)
|
|
16
|
-
|
|
17
|
-
def key = selection.key
|
|
18
|
-
end
|
|
19
|
-
|
|
20
14
|
Frame = Struct.new(:lines, :items, :top, :list_width)
|
|
21
15
|
|
|
22
16
|
CHROME_ROWS = 2 # header + footer
|
|
@@ -81,14 +75,14 @@ module ClaudeInbox
|
|
|
81
75
|
|
|
82
76
|
def frame(sections, view)
|
|
83
77
|
return full_screen(sections, view) if view.screen
|
|
84
|
-
width, height,
|
|
78
|
+
width, height, selected = view.width, view.height, view.selected
|
|
85
79
|
list_w = width_for_list(width, view.peek)
|
|
86
80
|
view_h = Renderer.body_height(height)
|
|
87
81
|
body, items =
|
|
88
82
|
if view.loading
|
|
89
83
|
[loading_state(list_w, view_h, view.loading, view.tick), []]
|
|
90
84
|
else
|
|
91
|
-
body_lines(sections, list_w, view
|
|
85
|
+
body_lines(sections, list_w, view)
|
|
92
86
|
end
|
|
93
87
|
|
|
94
88
|
top = clamp_top(view.top, body.size, view_h, items, selected)
|
|
@@ -175,7 +169,7 @@ module ClaudeInbox
|
|
|
175
169
|
end
|
|
176
170
|
end
|
|
177
171
|
|
|
178
|
-
def header_chips(sections, compact:, listening:
|
|
172
|
+
def header_chips(sections, compact:, listening:)
|
|
179
173
|
pinned = sections.pinned.size
|
|
180
174
|
needing = sections.needs_you.size
|
|
181
175
|
working = sections.active.count { |r| r.session.effective_state == "working" && !r.session.waiting_on_work? }
|
|
@@ -214,7 +208,7 @@ module ClaudeInbox
|
|
|
214
208
|
|
|
215
209
|
def footer(width, view)
|
|
216
210
|
text =
|
|
217
|
-
if view.filter_editing then " " + @theme.cyan_bold("/") +
|
|
211
|
+
if view.filter_editing then " " + @theme.cyan_bold("/") + view.filter.row(width - 2, cursor: caret)
|
|
218
212
|
elsif view.filter then " " + @theme.cyan_bold("/") + view.filter.to_s + @p.dim(" esc clears")
|
|
219
213
|
else " " + footer_keys(view).map { |k, d| @theme.cyan_bold(k) + " " + @p.dim(d) }.join(" ")
|
|
220
214
|
end
|
|
@@ -226,8 +220,6 @@ module ClaudeInbox
|
|
|
226
220
|
KEYS.flat_map { |key| (key[0] == "n") ? [key, ["N", "pair"]] : [key] }
|
|
227
221
|
end
|
|
228
222
|
|
|
229
|
-
def line(buffer, width) = buffer.row(width, cursor: caret)
|
|
230
|
-
|
|
231
223
|
def section_title(name, count, width)
|
|
232
224
|
title = " #{SECTION_TITLES[name]} "
|
|
233
225
|
count_s = " #{count} "
|
|
@@ -243,7 +235,7 @@ module ClaudeInbox
|
|
|
243
235
|
|
|
244
236
|
# ----- body ---------------------------------------------------------------
|
|
245
237
|
|
|
246
|
-
def body_lines(sections, width, view
|
|
238
|
+
def body_lines(sections, width, view)
|
|
247
239
|
lines = []
|
|
248
240
|
items = []
|
|
249
241
|
if sections.all.empty?
|
|
@@ -256,13 +248,13 @@ module ClaudeInbox
|
|
|
256
248
|
items << nil << nil
|
|
257
249
|
if Store.folded?(name, view.expanded)
|
|
258
250
|
lines << fold_toggle_line(name, rows.size, selected, width)
|
|
259
|
-
items <<
|
|
251
|
+
items << Store::Selection.fold(name)
|
|
260
252
|
next
|
|
261
253
|
end
|
|
262
254
|
rows.each do |row|
|
|
263
|
-
row_lines(row, name, selected, width, now, view.tick).each_with_index do |l, i|
|
|
255
|
+
row_lines(row, name, selected, width, view.now, view.tick).each_with_index do |l, i|
|
|
264
256
|
lines << l
|
|
265
|
-
items << ((i.zero? && row.selectable?) ?
|
|
257
|
+
items << ((i.zero? && row.selectable?) ? Store::Selection.row(row.key) : nil)
|
|
266
258
|
end
|
|
267
259
|
end
|
|
268
260
|
end
|
|
@@ -80,6 +80,16 @@ module ClaudeInbox
|
|
|
80
80
|
# writing over the same files. Fall back to the repo it was cut from.
|
|
81
81
|
def self.strip_worktree(cwd) = cwd.to_s.sub(%r{/\.claude/worktrees/[^/]+(?:/.*)?\z}, "")
|
|
82
82
|
|
|
83
|
+
# Where sessions ran lately, newest first, then every directory whose
|
|
84
|
+
# trust dialog was accepted: the likely choices first. Only an order;
|
|
85
|
+
# the CLI still decides where a session may start. Every worktree gets
|
|
86
|
+
# trusted on its own, so each folds into its repo rather than crowding
|
|
87
|
+
# the list.
|
|
88
|
+
def self.directories(sessions, trusted)
|
|
89
|
+
recent = sessions.sort_by { |s| -s.started_at.to_i }.filter_map(&:cwd)
|
|
90
|
+
(recent + trusted).map { |dir| strip_worktree(dir) }.uniq.select { |dir| File.directory?(dir) }
|
|
91
|
+
end
|
|
92
|
+
|
|
83
93
|
# The daemon's summary line can echo the prompt to the terminal, so no
|
|
84
94
|
# control character but tab and newline gets that far. A CRLF, the
|
|
85
95
|
# newline an HTTP client may send, is taken as a newline.
|
|
@@ -13,7 +13,7 @@ module ClaudeInbox
|
|
|
13
13
|
#
|
|
14
14
|
# Pure: filesystem in, plain values out. Nothing here runs `claude`.
|
|
15
15
|
module SlashCommands
|
|
16
|
-
Command = Struct.new(:name, :description
|
|
16
|
+
Command = Struct.new(:name, :description) do
|
|
17
17
|
def to_s = "/#{name}"
|
|
18
18
|
end
|
|
19
19
|
|
|
@@ -26,8 +26,8 @@ module ClaudeInbox
|
|
|
26
26
|
def list(cwd:, home: Dir.home)
|
|
27
27
|
found = {}
|
|
28
28
|
add = ->(cmd) { found[cmd.name] ||= cmd }
|
|
29
|
-
from_dir(File.join(cwd, ".claude")
|
|
30
|
-
from_dir(File.join(home, ".claude")
|
|
29
|
+
from_dir(File.join(cwd, ".claude")).each(&add)
|
|
30
|
+
from_dir(File.join(home, ".claude")).each(&add)
|
|
31
31
|
plugins(home).each(&add)
|
|
32
32
|
synced(home).each(&add)
|
|
33
33
|
found.values.sort_by(&:name)
|
|
@@ -39,25 +39,25 @@ module ClaudeInbox
|
|
|
39
39
|
starts + rest.select { |c| c.name.downcase.include?(q) }
|
|
40
40
|
end
|
|
41
41
|
|
|
42
|
-
def from_dir(dir,
|
|
43
|
-
skills(File.join(dir, "skills"),
|
|
44
|
-
commands(File.join(dir, "commands"),
|
|
42
|
+
def from_dir(dir, prefix: nil)
|
|
43
|
+
skills(File.join(dir, "skills"), prefix:) +
|
|
44
|
+
commands(File.join(dir, "commands"), prefix:)
|
|
45
45
|
end
|
|
46
46
|
|
|
47
|
-
def skills(dir,
|
|
47
|
+
def skills(dir, prefix: nil)
|
|
48
48
|
Dir.glob(File.join(dir, "*", "SKILL.md")).sort.filter_map do |path|
|
|
49
49
|
meta = frontmatter(path)
|
|
50
50
|
next if meta["user-invocable"] == "false"
|
|
51
51
|
name = File.basename(File.dirname(path))
|
|
52
|
-
Command.new(qualify(prefix, name), meta["description"].to_s
|
|
52
|
+
Command.new(qualify(prefix, name), meta["description"].to_s)
|
|
53
53
|
end
|
|
54
54
|
end
|
|
55
55
|
|
|
56
56
|
# A command file's name is its own; a subdirectory only groups them.
|
|
57
|
-
def commands(dir,
|
|
57
|
+
def commands(dir, prefix: nil)
|
|
58
58
|
Dir.glob(File.join(dir, "**", "*.md")).sort.map do |path|
|
|
59
59
|
name = File.basename(path, ".md")
|
|
60
|
-
Command.new(qualify(prefix, name), frontmatter(path)["description"].to_s
|
|
60
|
+
Command.new(qualify(prefix, name), frontmatter(path)["description"].to_s)
|
|
61
61
|
end
|
|
62
62
|
end
|
|
63
63
|
|
|
@@ -71,7 +71,7 @@ module ClaudeInbox
|
|
|
71
71
|
(installed["plugins"] || {}).flat_map do |key, entries|
|
|
72
72
|
plugin = key.split("@").first
|
|
73
73
|
Array(entries).filter_map { |e| e["installPath"] if e.is_a?(Hash) }.uniq.flat_map do |root|
|
|
74
|
-
from_dir(root,
|
|
74
|
+
from_dir(root, prefix: plugin)
|
|
75
75
|
end
|
|
76
76
|
end
|
|
77
77
|
end
|
|
@@ -80,7 +80,7 @@ module ClaudeInbox
|
|
|
80
80
|
# ~/.claude/skills/synced and answer to the anthropic-skills prefix.
|
|
81
81
|
def synced(home)
|
|
82
82
|
Dir.glob(File.join(home, ".claude", "skills", "synced", "*", "")).flat_map do |bucket|
|
|
83
|
-
skills(bucket,
|
|
83
|
+
skills(bucket, prefix: SYNCED_PLUGIN)
|
|
84
84
|
end
|
|
85
85
|
end
|
|
86
86
|
|
|
@@ -112,5 +112,6 @@ module ClaudeInbox
|
|
|
112
112
|
s = s[1..-2] if quoted
|
|
113
113
|
s.gsub("''", "'")
|
|
114
114
|
end
|
|
115
|
+
private_class_method :from_dir, :skills, :commands, :qualify, :plugins, :synced, :frontmatter, :unquote
|
|
115
116
|
end
|
|
116
117
|
end
|