claude-inbox 0.5.0 → 0.6.1
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 +8 -3
- data/exe/claude-inbox +2 -91
- data/lib/claude_inbox/actions.rb +7 -4
- data/lib/claude_inbox/agents_client.rb +17 -12
- data/lib/claude_inbox/app.rb +64 -6
- data/lib/claude_inbox/cli.rb +201 -0
- data/lib/claude_inbox/dialog.rb +40 -0
- data/lib/claude_inbox/headless.rb +20 -3
- data/lib/claude_inbox/images.rb +1 -1
- data/lib/claude_inbox/job_state.rb +26 -4
- 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 +8 -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 +4 -6
- data/lib/claude_inbox/pull_requests.rb +5 -5
- data/lib/claude_inbox/reaper.rb +1 -1
- data/lib/claude_inbox/remote/http.rb +1 -1
- data/lib/claude_inbox/remote/listener.rb +6 -7
- 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 +1 -0
- data/lib/claude_inbox/renderer.rb +1 -1
- data/lib/claude_inbox/session.rb +4 -0
- data/lib/claude_inbox/session_request.rb +6 -4
- data/lib/claude_inbox/slash_commands.rb +3 -3
- data/lib/claude_inbox/snapshot.rb +8 -3
- data/lib/claude_inbox/store/entry.rb +4 -5
- data/lib/claude_inbox/store/row.rb +1 -1
- data/lib/claude_inbox/store/sections.rb +3 -3
- data/lib/claude_inbox/store.rb +1 -2
- 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.rb +1 -1
- data/mod/hooks/register.tsx +37 -7
- data/mod/tests/contract.test.tsx +2 -1
- data/mod/tests/pane.test.tsx +95 -2
- data/mod/tests/snapshot.fixture.ts +4 -3
- data/mod/types/index.d.ts +3 -1
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: cea52c058efc847eede80d9633f507fb0bcc88352351d2e8df98d57d531cbd57
|
|
4
|
+
data.tar.gz: 73953113b090f01dfd646bbcb9295be96d2f6616ecccab116b9f75c2f43f69ed
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: '080712923012ed2b164ce10dc6e4fbbf511319b7065ee54da07ffac2a6561deb62888f73012d8b7bf1ed3a78ae2d8fae078680fd4102a962bb4a05e345346c77'
|
|
7
|
+
data.tar.gz: dd9a7ca33067f30d0eb8cb98742bcc229cb981acfdfa0697966d59b43be9cf4c2451a6683378f6389b3226410261d615b9c5fb711df9c1019e659863ca76796c
|
data/README.md
CHANGED
|
@@ -18,6 +18,8 @@ gem install claude-inbox
|
|
|
18
18
|
claude-inbox
|
|
19
19
|
```
|
|
20
20
|
|
|
21
|
+
`claude-inbox update` installs the latest release.
|
|
22
|
+
|
|
21
23
|
Put arguments you want on every launch in `~/.config/claude-inbox/config`.
|
|
22
24
|
|
|
23
25
|
To add the pane to every Claude Code session:
|
|
@@ -26,6 +28,7 @@ To add the pane to every Claude Code session:
|
|
|
26
28
|
claude-inbox install
|
|
27
29
|
```
|
|
28
30
|
|
|
31
|
+
The inbox offers to do this once, the first time it starts without the pane.
|
|
29
32
|
`claude-inbox install --force` also replaces any other copy of the pane.
|
|
30
33
|
|
|
31
34
|
## Claude Code pane
|
|
@@ -37,7 +40,7 @@ a fullscreen terminal.
|
|
|
37
40
|
|
|
38
41
|
`/inbox` opens it, `ctrl+x tab` focuses it and `esc` leaves. The keys are the
|
|
39
42
|
ones below, with `ge` for `G`, `n` `p` for `Tab` `Shift+Tab`, and no `a`,
|
|
40
|
-
`P`, `X`, `Ctrl-x`,
|
|
43
|
+
`P`, `X`, `Ctrl-x`, `/`, `R` or `U`. `Enter`, or a click on a row, switches to the
|
|
41
44
|
session when you attached through `claude-inbox`.
|
|
42
45
|
|
|
43
46
|
It opens when a session starts. To stop that, set
|
|
@@ -88,6 +91,7 @@ Vim bindings. Arrows and the mouse work too.
|
|
|
88
91
|
| `Ctrl-x` | delete the session, transcript and worktree for good |
|
|
89
92
|
| `/` | filter; `Enter` keeps it, `Esc` clears |
|
|
90
93
|
| `R` | poll now |
|
|
94
|
+
| `U` | update claude-inbox when the header says a newer one is out |
|
|
91
95
|
| `q` | quit |
|
|
92
96
|
|
|
93
97
|
`X` and `Ctrl-x` both ask for `y` first.
|
|
@@ -95,8 +99,9 @@ Vim bindings. Arrows and the mouse work too.
|
|
|
95
99
|
### New session
|
|
96
100
|
|
|
97
101
|
`n` opens a form for the prompt, name, directory, model, effort, permissions,
|
|
98
|
-
worktree
|
|
99
|
-
|
|
102
|
+
worktree, Remote Control and color, which tints the row as `/color` would.
|
|
103
|
+
`Tab` moves between fields, `Ctrl-S` starts the session and `Ctrl-O` starts
|
|
104
|
+
it and attaches.
|
|
100
105
|
|
|
101
106
|
Paste an image on macOS, or drop a file into the prompt, and refer to it as
|
|
102
107
|
`[Image #1]`. Type `/` for a menu of your skills.
|
data/exe/claude-inbox
CHANGED
|
@@ -2,95 +2,6 @@
|
|
|
2
2
|
# frozen_string_literal: true
|
|
3
3
|
|
|
4
4
|
require "claude_inbox"
|
|
5
|
+
require "claude_inbox/cli"
|
|
5
6
|
|
|
6
|
-
|
|
7
|
-
puts ClaudeInbox::VERSION
|
|
8
|
-
exit
|
|
9
|
-
end
|
|
10
|
-
|
|
11
|
-
require "claude_inbox/mod"
|
|
12
|
-
|
|
13
|
-
# The stable path to the Claude Code pane, for CLAUDE_CODE_PLUGIN_DIRS.
|
|
14
|
-
if ARGV.include?("--mod-dir")
|
|
15
|
-
puts ClaudeInbox::Mod.link(force: true)
|
|
16
|
-
exit
|
|
17
|
-
end
|
|
18
|
-
|
|
19
|
-
if ARGV.first == "install"
|
|
20
|
-
begin
|
|
21
|
-
installed = ClaudeInbox::Mod.install(force: ARGV.include?("--force"))
|
|
22
|
-
rescue JSON::ParserError
|
|
23
|
-
abort "claude-inbox: ~/.claude/settings.json is not valid JSON, so it was left alone"
|
|
24
|
-
end
|
|
25
|
-
installed.removed.each { |dir| puts "Removed #{dir} from ~/.claude/settings.json." }
|
|
26
|
-
puts "The pane now runs #{ClaudeInbox::Mod::COMMAND || "claude-inbox from your PATH"}." if installed.command_set
|
|
27
|
-
puts installed.added ? "Added the pane to ~/.claude/settings.json. New Claude Code sessions load it; /inbox opens it." :
|
|
28
|
-
"The pane is already in ~/.claude/settings.json."
|
|
29
|
-
exit
|
|
30
|
-
end
|
|
31
|
-
|
|
32
|
-
require "claude_inbox/app"
|
|
33
|
-
require "claude_inbox/config"
|
|
34
|
-
require "claude_inbox/writer_lock"
|
|
35
|
-
require "tmpdir"
|
|
36
|
-
|
|
37
|
-
fixture = ARGV.include?("--fixture")
|
|
38
|
-
# A fixture run takes the listen flags, for trying the API with curl, but
|
|
39
|
-
# not the environment or the config file, which are for the real inbox.
|
|
40
|
-
begin
|
|
41
|
-
argv = fixture ? ARGV : ClaudeInbox::Config.argv(ARGV)
|
|
42
|
-
listen = ClaudeInbox::Remote::Listener.options(argv, fixture ? {} : ENV)
|
|
43
|
-
rescue ArgumentError => e
|
|
44
|
-
abort "claude-inbox: #{e.message}"
|
|
45
|
-
end
|
|
46
|
-
|
|
47
|
-
# Crash traces go here instead of the alt screen when set.
|
|
48
|
-
$stderr.reopen(ENV["CLAUDE_INBOX_STDERR"], "a") if ENV["CLAUDE_INBOX_STDERR"]
|
|
49
|
-
|
|
50
|
-
opts = {}
|
|
51
|
-
if fixture
|
|
52
|
-
agents = argv[argv.index("--fixture") + 1]
|
|
53
|
-
fixtures = File.dirname(agents)
|
|
54
|
-
opts[:client] = ClaudeInbox::FixtureClient.new(agents, logs: File.binread(File.join(fixtures, "logs_raw.txt")))
|
|
55
|
-
opts[:store] = ClaudeInbox::Store.new(path: nil)
|
|
56
|
-
opts[:pull_requests] = ClaudeInbox::PullRequests.new(cache_path: File.join(fixtures, "gh-pr-status-cache.json"), resolved_path: nil, gh: nil)
|
|
57
|
-
# Its own token, lock and images, so a test inbox can neither rotate the
|
|
58
|
-
# token phones are paired with nor hold the real inbox's lock.
|
|
59
|
-
dir = File.join(Dir.tmpdir, "claude-inbox-fixture")
|
|
60
|
-
opts[:listen] = listen&.merge(fixture: true, pairing: ClaudeInbox::Remote::Pairing.new(path: File.join(dir, "listen.json")),
|
|
61
|
-
lock_path: File.join(dir, "listen.lock"), images_dir: File.join(dir, "images"))
|
|
62
|
-
else
|
|
63
|
-
# The only place the reaper is armed, and only with --reap: deleting
|
|
64
|
-
# sessions is opt-in. Everything else — tests, --fixture, anything
|
|
65
|
-
# embedding App — gets one that deletes nothing. The listener, likewise,
|
|
66
|
-
# comes only from this file, and only when asked for.
|
|
67
|
-
opts[:client] = ClaudeInbox::AgentsClient.new
|
|
68
|
-
opts[:store] = ClaudeInbox::Store.new
|
|
69
|
-
opts[:reaper] = argv.include?("--reap") ? ClaudeInbox::Reaper.new(opts[:client], opts[:store]) : ClaudeInbox::Reaper.disabled
|
|
70
|
-
opts[:snapshot] = ClaudeInbox::Snapshot.new
|
|
71
|
-
opts[:switch] = ClaudeInbox::Switch.new
|
|
72
|
-
opts[:actions] = ClaudeInbox::Actions.new
|
|
73
|
-
opts[:listen] = listen
|
|
74
|
-
end
|
|
75
|
-
opts[:color] = false if argv.include?("--no-color")
|
|
76
|
-
|
|
77
|
-
if argv.include?("--headless")
|
|
78
|
-
abort "claude-inbox: --headless takes no --fixture" if fixture
|
|
79
|
-
require "claude_inbox/headless"
|
|
80
|
-
exit(ClaudeInbox::Headless.new(client: opts[:client], store: opts[:store]).run ? 0 : 1)
|
|
81
|
-
end
|
|
82
|
-
|
|
83
|
-
# The first inbox you can see is the snapshot's writer: a headless one gives
|
|
84
|
-
# way to it, and a second inbox reads along, writing and deleting nothing.
|
|
85
|
-
# Held for the life of this process by this reference.
|
|
86
|
-
writer_lock = fixture ? ClaudeInbox::WriterLock.disabled : ClaudeInbox::WriterLock.new
|
|
87
|
-
unless writer_lock.take(role: "inbox")
|
|
88
|
-
opts[:store] = ClaudeInbox::Store.new(writable: false, relay: ClaudeInbox::Actions.new)
|
|
89
|
-
opts[:reaper] = ClaudeInbox::Reaper.disabled
|
|
90
|
-
opts[:snapshot] = ClaudeInbox::Snapshot.disabled
|
|
91
|
-
opts[:actions] = ClaudeInbox::Actions.disabled
|
|
92
|
-
end
|
|
93
|
-
# So the pane settings.json names is the installed version's, after a gem update too.
|
|
94
|
-
ClaudeInbox::Mod.link unless fixture
|
|
95
|
-
|
|
96
|
-
ClaudeInbox::App.new(**opts).run
|
|
7
|
+
exit ClaudeInbox::CLI.new(ARGV).run
|
data/lib/claude_inbox/actions.rb
CHANGED
|
@@ -11,7 +11,6 @@ module ClaudeInbox
|
|
|
11
11
|
# the store cannot honour after that, or a verb it lacks, is dropped.
|
|
12
12
|
class Actions
|
|
13
13
|
DEFAULT_DIR = File.join(Dir.home, ".config", "claude-inbox", "actions")
|
|
14
|
-
VERBS = %w[settle wake pin snooze acknowledge].freeze
|
|
15
14
|
SNOOZES = %w[m15 h1 tomorrow_9am until_woken].freeze
|
|
16
15
|
# A file still being written parses as nothing for milliseconds; a
|
|
17
16
|
# session not yet polled appears within one poll.
|
|
@@ -62,12 +61,14 @@ module ClaudeInbox
|
|
|
62
61
|
def parse(path)
|
|
63
62
|
request = JSON.parse(File.read(path))
|
|
64
63
|
request = {} unless request.is_a?(Hash)
|
|
65
|
-
return request if
|
|
64
|
+
return request if stale?(path)
|
|
66
65
|
(request.key?("id") && request.key?("action")) ? request : nil
|
|
67
66
|
rescue JSON::ParserError
|
|
68
|
-
(
|
|
67
|
+
stale?(path) ? {} : nil
|
|
69
68
|
end
|
|
70
69
|
|
|
70
|
+
def stale?(path) = File.mtime(path) < @clock.call - GRACE
|
|
71
|
+
|
|
71
72
|
def young?(request)
|
|
72
73
|
at = request["at"].to_i / 1000
|
|
73
74
|
at.positive? && Time.at(at) >= @clock.call - GRACE
|
|
@@ -75,7 +76,7 @@ module ClaudeInbox
|
|
|
75
76
|
|
|
76
77
|
def apply(store, request, keys)
|
|
77
78
|
id = request["id"]
|
|
78
|
-
return false unless keys.include?(id)
|
|
79
|
+
return false unless keys.include?(id)
|
|
79
80
|
case request["action"]
|
|
80
81
|
when "settle" then store.settle(id)
|
|
81
82
|
when "wake" then store.wake(id)
|
|
@@ -84,6 +85,8 @@ module ClaudeInbox
|
|
|
84
85
|
when "snooze"
|
|
85
86
|
return false unless SNOOZES.include?(request["choice"])
|
|
86
87
|
store.snooze(id, request["choice"].to_sym)
|
|
88
|
+
else
|
|
89
|
+
return false
|
|
87
90
|
end
|
|
88
91
|
true
|
|
89
92
|
end
|
|
@@ -59,17 +59,23 @@ module ClaudeInbox
|
|
|
59
59
|
watchdog&.kill
|
|
60
60
|
end
|
|
61
61
|
|
|
62
|
-
def stop(id) = run(
|
|
62
|
+
def stop(id) = run("stop", id)
|
|
63
63
|
|
|
64
|
-
def rm(id) = run(
|
|
64
|
+
def rm(id) = run("rm", id)
|
|
65
65
|
|
|
66
66
|
MODELS = %w[default fable opus sonnet haiku].freeze
|
|
67
67
|
EFFORTS = %w[default low medium high xhigh max].freeze
|
|
68
68
|
PERMISSION_MODES = %w[default acceptEdits auto plan bypassPermissions].freeze
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
69
|
+
# The colors `/color` offers, in its order.
|
|
70
|
+
COLORS = %w[default red blue green yellow purple orange pink cyan].freeze
|
|
71
|
+
|
|
72
|
+
# Start a background session. Returns its short id. `claude --bg` takes
|
|
73
|
+
# no color, and `/color` refuses a prompt after the color, so the color
|
|
74
|
+
# goes into the job file the way `/color` would put it there.
|
|
75
|
+
def spawn(prompt:, cwd:, color: nil, **opts)
|
|
76
|
+
id = start_bg(self.class.spawn_args(@bin, prompt: prompt, **opts), cwd, "claude --bg")
|
|
77
|
+
JobState.write_color(id, color, jobs_dir: @jobs_dir) if color
|
|
78
|
+
id
|
|
73
79
|
end
|
|
74
80
|
|
|
75
81
|
# How a prompt attaches a file: the same @ mention the CLI's own prompt
|
|
@@ -239,10 +245,9 @@ module ClaudeInbox
|
|
|
239
245
|
nil
|
|
240
246
|
end
|
|
241
247
|
|
|
242
|
-
def run(
|
|
243
|
-
r = Subprocess.capture(
|
|
244
|
-
raise Error, "#{
|
|
245
|
-
true
|
|
248
|
+
def run(verb, id)
|
|
249
|
+
r = Subprocess.capture(@bin, verb, id)
|
|
250
|
+
raise Error, "#{verb} failed: #{r.err.strip}" unless r.success?
|
|
246
251
|
end
|
|
247
252
|
|
|
248
253
|
def start_bg(argv, cwd, what)
|
|
@@ -275,7 +280,7 @@ module ClaudeInbox
|
|
|
275
280
|
# terminal round-trips through cooked mode and back.
|
|
276
281
|
def attach(id) = system("sh", "-c", "printf 'fake attach to %s\\npress enter to detach: ' \"$1\"; read -r _", "attach", id)
|
|
277
282
|
|
|
278
|
-
def stop(_id) =
|
|
283
|
+
def stop(_id) = nil
|
|
279
284
|
|
|
280
285
|
def spawn(prompt:, cwd:, **)
|
|
281
286
|
sleep 0.5
|
|
@@ -284,6 +289,6 @@ module ClaudeInbox
|
|
|
284
289
|
|
|
285
290
|
def adopt(session_id:, cwd:, pid:) = "adop7ed0"
|
|
286
291
|
|
|
287
|
-
def rm(_id) =
|
|
292
|
+
def rm(_id) = nil
|
|
288
293
|
end
|
|
289
294
|
end
|
data/lib/claude_inbox/app.rb
CHANGED
|
@@ -24,6 +24,8 @@ require_relative "actions"
|
|
|
24
24
|
require_relative "snapshot"
|
|
25
25
|
require_relative "switch"
|
|
26
26
|
require_relative "trust"
|
|
27
|
+
require_relative "mod"
|
|
28
|
+
require_relative "updater"
|
|
27
29
|
|
|
28
30
|
module ClaudeInbox
|
|
29
31
|
# Owns the terminal and the key loop. The only class allowed to spawn a
|
|
@@ -33,14 +35,17 @@ module ClaudeInbox
|
|
|
33
35
|
# so both are off unless `bin/claude-inbox` switches them on.
|
|
34
36
|
def initialize(client: AgentsClient.new, store: Store.new, pull_requests: PullRequests.new,
|
|
35
37
|
rate_limits: RateLimits.new, reaper: Reaper.disabled, snapshot: Snapshot.disabled, switch: Switch.disabled,
|
|
36
|
-
actions: Actions.disabled, listen: nil, out: $stdout, input: $stdin, color: true,
|
|
37
|
-
terminal: Terminal.new(out, input), queue: Queue.new)
|
|
38
|
+
actions: Actions.disabled, listen: nil, updater: nil, argv: [], out: $stdout, input: $stdin, color: true,
|
|
39
|
+
trust: Trust.method(:projects), terminal: Terminal.new(out, input), queue: Queue.new, pane_offer: Mod::Offer.disabled)
|
|
38
40
|
@client = client
|
|
39
41
|
@store = store
|
|
40
42
|
@snapshot = snapshot
|
|
41
43
|
@switch = switch
|
|
42
44
|
@rate_limits = rate_limits
|
|
43
45
|
@terminal = terminal
|
|
46
|
+
@updater = updater
|
|
47
|
+
@argv = argv
|
|
48
|
+
@out = out
|
|
44
49
|
@color = color
|
|
45
50
|
@trust = trust
|
|
46
51
|
@renderer = Renderer.new(color: color)
|
|
@@ -65,6 +70,8 @@ module ClaudeInbox
|
|
|
65
70
|
@last_poll = nil
|
|
66
71
|
@quit = false
|
|
67
72
|
@resize = false
|
|
73
|
+
@pane_offer = pane_offer
|
|
74
|
+
@modal = Dialog::PaneOffer.new if pane_offer.take
|
|
68
75
|
end
|
|
69
76
|
|
|
70
77
|
def run
|
|
@@ -83,6 +90,7 @@ module ClaudeInbox
|
|
|
83
90
|
end
|
|
84
91
|
|
|
85
92
|
def step(input = nil)
|
|
93
|
+
check_for_update unless @update_status
|
|
86
94
|
drain_queue
|
|
87
95
|
@logs.tick
|
|
88
96
|
if @resize
|
|
@@ -124,6 +132,7 @@ module ClaudeInbox
|
|
|
124
132
|
@error = nil
|
|
125
133
|
when :error then @error = rest[0]
|
|
126
134
|
when :notice then notice(rest[0])
|
|
135
|
+
when :update then update_checked(*rest)
|
|
127
136
|
when :select then @pending_select = rest[0]
|
|
128
137
|
when :attach then attach(rest[0])
|
|
129
138
|
when :form_started then @modal = nil if @modal.equal?(rest[0])
|
|
@@ -131,8 +140,6 @@ module ClaudeInbox
|
|
|
131
140
|
when :remote_started then remote_started(*rest)
|
|
132
141
|
end
|
|
133
142
|
end
|
|
134
|
-
rescue ThreadError
|
|
135
|
-
nil
|
|
136
143
|
end
|
|
137
144
|
|
|
138
145
|
# ----- main loop --------------------------------------------------------
|
|
@@ -208,7 +215,49 @@ module ClaudeInbox
|
|
|
208
215
|
def status_text(now)
|
|
209
216
|
return @notice[0] if @notice && now < @notice[1]
|
|
210
217
|
return "⚠ #{@error}" if @error
|
|
211
|
-
"polling…" unless @last_poll
|
|
218
|
+
return "polling…" unless @last_poll
|
|
219
|
+
"#{@update.notice} · U updates" if @update
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# ----- updates ----------------------------------------------------------
|
|
223
|
+
|
|
224
|
+
# Once, on the first step: a stale cache means a network round trip, so
|
|
225
|
+
# it runs off the main thread. A laptop that is offline stays quiet
|
|
226
|
+
# rather than show an error at every launch; U says what went wrong.
|
|
227
|
+
def check_for_update
|
|
228
|
+
@update_status = @updater ? "checking for updates…" : "update check is off — claude-inbox update installs the latest"
|
|
229
|
+
return unless @updater
|
|
230
|
+
in_background do
|
|
231
|
+
@queue << [:update, @updater.check]
|
|
232
|
+
rescue Updater::Error => e
|
|
233
|
+
@queue << [:update, nil, e.message]
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def update_checked(update, failure = nil)
|
|
238
|
+
@update = update
|
|
239
|
+
@snapshot.update = update&.notice
|
|
240
|
+
@update_status = failure ? "couldn't check for updates: #{failure}" : "up to date"
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
# The install prints on the real screen, and the relaunch replaces this
|
|
244
|
+
# process. A failed install relaunches this version instead, once its
|
|
245
|
+
# error has been read, rather than leave the shell showing.
|
|
246
|
+
def update_inbox
|
|
247
|
+
return notice(@update_status) unless @update
|
|
248
|
+
@terminal.release do
|
|
249
|
+
@out.puts @update.installing
|
|
250
|
+
applied = @updater.apply
|
|
251
|
+
@out.puts applied.output unless applied.output.empty?
|
|
252
|
+
@terminal.pause unless applied.success?
|
|
253
|
+
relaunch
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def relaunch
|
|
258
|
+
@updater.relaunch(@argv)
|
|
259
|
+
rescue SystemCallError => e
|
|
260
|
+
@error = "relaunch failed: #{e.message}"
|
|
212
261
|
end
|
|
213
262
|
|
|
214
263
|
def filtered(sections = @store.sections) = sections.matching(@filter&.to_s)
|
|
@@ -227,7 +276,7 @@ module ClaudeInbox
|
|
|
227
276
|
|
|
228
277
|
def select(selection)
|
|
229
278
|
@selected = selection
|
|
230
|
-
@peek.select(
|
|
279
|
+
@peek.select(selected_session)
|
|
231
280
|
end
|
|
232
281
|
|
|
233
282
|
def session_for(key) = @store.sessions.find { |s| s.key == key }
|
|
@@ -349,6 +398,7 @@ module ClaudeInbox
|
|
|
349
398
|
when :stop then open_confirm(:stop)
|
|
350
399
|
when :delete then open_confirm(:delete)
|
|
351
400
|
when :refresh then @poller.soon
|
|
401
|
+
when :update then update_inbox
|
|
352
402
|
when :toggle_peek then toggle_peek
|
|
353
403
|
when :new_session then open_new_session
|
|
354
404
|
when :remote_pairing then @modal = @listener.pairing_dialog
|
|
@@ -538,6 +588,7 @@ module ClaudeInbox
|
|
|
538
588
|
when :stop then stop_session(id)
|
|
539
589
|
when :delete then delete_session(id)
|
|
540
590
|
when :adopt then adopt_session(id)
|
|
591
|
+
when :install then install_pane
|
|
541
592
|
end
|
|
542
593
|
when :save then save_prompt
|
|
543
594
|
when :copy then @listener.copy_pairing_url
|
|
@@ -576,6 +627,13 @@ module ClaudeInbox
|
|
|
576
627
|
end
|
|
577
628
|
end
|
|
578
629
|
|
|
630
|
+
def install_pane
|
|
631
|
+
@pane_offer.install
|
|
632
|
+
notice("added the pane — new Claude Code sessions load it; /inbox opens it")
|
|
633
|
+
rescue JSON::ParserError
|
|
634
|
+
notice("~/.claude/settings.json is not valid JSON, so it was left alone")
|
|
635
|
+
end
|
|
636
|
+
|
|
579
637
|
def delete_session(id)
|
|
580
638
|
notice("deleting #{id}…")
|
|
581
639
|
in_background do
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
require "tmpdir"
|
|
5
|
+
require_relative "app"
|
|
6
|
+
require_relative "config"
|
|
7
|
+
require_relative "headless"
|
|
8
|
+
require_relative "mod"
|
|
9
|
+
require_relative "updater"
|
|
10
|
+
require_relative "writer_lock"
|
|
11
|
+
|
|
12
|
+
module ClaudeInbox
|
|
13
|
+
# What `claude-inbox` does with its arguments: install the pane, update
|
|
14
|
+
# claude-inbox, or launch the inbox. `run` is true when it succeeded.
|
|
15
|
+
class CLI
|
|
16
|
+
USAGE = <<~USAGE
|
|
17
|
+
Usage: claude-inbox [options]
|
|
18
|
+
claude-inbox install [--force]
|
|
19
|
+
claude-inbox update
|
|
20
|
+
USAGE
|
|
21
|
+
|
|
22
|
+
# `lock` stands in for the writer lock a live launch takes.
|
|
23
|
+
def initialize(argv, env: ENV, config: Config::PATH, lock: nil, out: $stdout)
|
|
24
|
+
@argv = argv
|
|
25
|
+
@env = env
|
|
26
|
+
@config = config
|
|
27
|
+
@lock = lock
|
|
28
|
+
@out = out
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def run
|
|
32
|
+
case @argv.first
|
|
33
|
+
when "install" then install
|
|
34
|
+
when "update" then update
|
|
35
|
+
else launch
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# The launch flags, the config file's before the typed ones. A fixture
|
|
40
|
+
# run takes the listen flags, for trying the API with curl, but not the
|
|
41
|
+
# environment or the config file, which are for the real inbox.
|
|
42
|
+
def options
|
|
43
|
+
@options ||= begin
|
|
44
|
+
typed = parse(@argv)
|
|
45
|
+
options = typed[:fixture] ? typed : parse(Config.argv(@argv, path: @config))
|
|
46
|
+
env = typed[:fixture] ? {} : @env
|
|
47
|
+
options.merge(listen: Remote::Listener.options(env, flag: options[:listen], modes: options[:allow_modes]))
|
|
48
|
+
end
|
|
49
|
+
rescue OptionParser::ParseError, ArgumentError => e
|
|
50
|
+
abort "claude-inbox: #{e.message}"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# The keywords App is built with. A live launch takes the writer lock
|
|
54
|
+
# here: the first inbox you can see is the snapshot's writer, and a
|
|
55
|
+
# second one reads along, writing and deleting nothing.
|
|
56
|
+
def app_options
|
|
57
|
+
opts = options[:fixture] ? fixture_options : live_options
|
|
58
|
+
opts[:color] = options[:color]
|
|
59
|
+
return opts if options[:fixture] || writer_lock.take(role: "inbox")
|
|
60
|
+
opts.merge(store: Store.new(writable: false, relay: Actions.new), reaper: Reaper.disabled,
|
|
61
|
+
snapshot: Snapshot.disabled, actions: Actions.disabled, pane_offer: Mod::Offer.disabled)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
def install
|
|
67
|
+
force = false
|
|
68
|
+
subcommand("install") { |p| p.on("--force", "Replace any other copy of the pane") { force = true } }
|
|
69
|
+
begin
|
|
70
|
+
installed = Mod.install(force: force)
|
|
71
|
+
rescue JSON::ParserError
|
|
72
|
+
abort "claude-inbox: ~/.claude/settings.json is not valid JSON, so it was left alone"
|
|
73
|
+
end
|
|
74
|
+
installed.removed.each { |dir| @out.puts "Removed #{dir} from ~/.claude/settings.json." }
|
|
75
|
+
@out.puts "The pane now runs #{Mod::COMMAND || "claude-inbox from your PATH"}." if installed.command_set
|
|
76
|
+
@out.puts(installed.added ? "Added the pane to ~/.claude/settings.json. New Claude Code sessions load it; /inbox opens it." :
|
|
77
|
+
"The pane is already in ~/.claude/settings.json.")
|
|
78
|
+
true
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def update
|
|
82
|
+
subcommand("update")
|
|
83
|
+
updater = Updater.new
|
|
84
|
+
begin
|
|
85
|
+
update = updater.check(force: true)
|
|
86
|
+
rescue Updater::Error => e
|
|
87
|
+
abort "claude-inbox: #{e.message}"
|
|
88
|
+
end
|
|
89
|
+
unless update
|
|
90
|
+
@out.puts((updater.kind == :gem) ? "claude-inbox #{VERSION} is the latest release." : "main is up to date.")
|
|
91
|
+
return true
|
|
92
|
+
end
|
|
93
|
+
@out.puts update.installing
|
|
94
|
+
applied = updater.apply
|
|
95
|
+
abort "claude-inbox: #{applied.output.strip}" unless applied.success?
|
|
96
|
+
@out.puts((update.kind == :gem) ? "Installed claude-inbox #{update.latest}." : "main is at #{update.latest}.")
|
|
97
|
+
true
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def launch
|
|
101
|
+
options
|
|
102
|
+
# Crash traces go here instead of the alt screen when set.
|
|
103
|
+
$stderr.reopen(@env["CLAUDE_INBOX_STDERR"], "a") if @env["CLAUDE_INBOX_STDERR"]
|
|
104
|
+
return headless if options[:headless]
|
|
105
|
+
auto_update if options[:auto_update]
|
|
106
|
+
# So the pane settings.json names is the installed version's, after a gem update too.
|
|
107
|
+
Mod.link unless options[:fixture]
|
|
108
|
+
App.new(**app_options).run
|
|
109
|
+
true
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def headless
|
|
113
|
+
abort "claude-inbox: --headless takes no --fixture" if options[:fixture]
|
|
114
|
+
Headless.new(client: AgentsClient.new, store: Store.new, updater: updater).run
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Only what a check within the day already found: a cold launch never waits
|
|
118
|
+
# on the network. The relaunch is the new version's launch; a failure leaves
|
|
119
|
+
# this one to start as usual.
|
|
120
|
+
def auto_update
|
|
121
|
+
return unless (update = updater&.cached)
|
|
122
|
+
@out.puts update.installing
|
|
123
|
+
applied = updater.apply
|
|
124
|
+
applied.success? ? updater.relaunch(@argv) : warn("claude-inbox: #{applied.output.strip}")
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def fixture_options
|
|
128
|
+
fixtures = File.dirname(options[:fixture])
|
|
129
|
+
# Its own token, lock and images, so a test inbox can neither rotate the
|
|
130
|
+
# token phones are paired with nor hold the real inbox's lock.
|
|
131
|
+
dir = File.join(Dir.tmpdir, "claude-inbox-fixture")
|
|
132
|
+
{client: FixtureClient.new(options[:fixture], logs: File.binread(File.join(fixtures, "logs_raw.txt"))),
|
|
133
|
+
store: Store.new(path: nil),
|
|
134
|
+
pull_requests: PullRequests.new(cache_path: File.join(fixtures, "gh-pr-status-cache.json"), resolved_path: nil, gh: nil),
|
|
135
|
+
listen: options[:listen]&.merge(fixture: true, pairing: Remote::Pairing.new(path: File.join(dir, "listen.json")),
|
|
136
|
+
lock_path: File.join(dir, "listen.lock"), images_dir: File.join(dir, "images"))}
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# The only place the reaper is armed, and only with --reap: deleting
|
|
140
|
+
# sessions is opt-in. Everything else — tests, --fixture, anything
|
|
141
|
+
# embedding App — gets one that deletes nothing. The listener, likewise,
|
|
142
|
+
# comes only from here, and only when asked for.
|
|
143
|
+
def live_options
|
|
144
|
+
client = AgentsClient.new
|
|
145
|
+
store = Store.new
|
|
146
|
+
{client: client, store: store, reaper: options[:reap] ? Reaper.new(client, store) : Reaper.disabled,
|
|
147
|
+
snapshot: Snapshot.new, switch: Switch.new, actions: Actions.new, pane_offer: Mod::Offer.new,
|
|
148
|
+
listen: options[:listen], updater: updater,
|
|
149
|
+
# The typed arguments, not the config's: a relaunch reads the config again.
|
|
150
|
+
argv: @argv}
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def updater
|
|
154
|
+
@updater ||= Updater.new if options[:update_check] && !options[:fixture]
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Held for the life of this process by this reference.
|
|
158
|
+
def writer_lock = (@lock ||= WriterLock.new)
|
|
159
|
+
|
|
160
|
+
def parse(argv)
|
|
161
|
+
opts = {color: true, update_check: true}
|
|
162
|
+
parser = OptionParser.new(USAGE) do |p|
|
|
163
|
+
p.on("--version", "Print the version") {
|
|
164
|
+
@out.puts VERSION
|
|
165
|
+
exit
|
|
166
|
+
}
|
|
167
|
+
p.on("--mod-dir", "Print the pane's directory, for CLAUDE_CODE_PLUGIN_DIRS") {
|
|
168
|
+
@out.puts Mod.link(force: true)
|
|
169
|
+
exit
|
|
170
|
+
}
|
|
171
|
+
p.on("--fixture PATH", "Read sessions from an agents.json instead of the daemon") { |path| opts[:fixture] = path }
|
|
172
|
+
p.on("--headless", "Keep the pane's snapshot fresh without a screen") { opts[:headless] = true }
|
|
173
|
+
p.on("--reap", "Delete background sessions idle 14 days") { opts[:reap] = true }
|
|
174
|
+
p.on("--no-color", "Draw without color") { opts[:color] = false }
|
|
175
|
+
p.on("--auto-update", "Install a release found today before launching") { opts[:auto_update] = true }
|
|
176
|
+
p.on("--no-update-check", "Never look for a newer release") { opts[:update_check] = false }
|
|
177
|
+
# --listen-lan wins over --listen, whichever came last.
|
|
178
|
+
p.on("--listen[=PORT]", "Let a phone on this Mac start sessions") { |port| opts[:listen] = {lan: false, port: port} unless opts.dig(:listen, :lan) }
|
|
179
|
+
p.on("--listen-lan[=PORT]", "Let a phone on your network start sessions") { |port| opts[:listen] = {lan: true, port: port} }
|
|
180
|
+
p.on("--listen-allow-modes=MODES", "The permission modes a phone may start in") { |modes| opts[:allow_modes] = modes }
|
|
181
|
+
end
|
|
182
|
+
strictly(parser, argv)
|
|
183
|
+
opts
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def subcommand(name)
|
|
187
|
+
parser = OptionParser.new(USAGE)
|
|
188
|
+
yield parser if block_given?
|
|
189
|
+
strictly(parser, @argv.drop(1))
|
|
190
|
+
rescue OptionParser::ParseError => e
|
|
191
|
+
abort "claude-inbox #{name}: #{e.message}"
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# Exact names only, so a typo is an error rather than the flag it abbreviates.
|
|
195
|
+
def strictly(parser, argv)
|
|
196
|
+
parser.require_exact = true
|
|
197
|
+
rest = parser.parse(argv)
|
|
198
|
+
raise OptionParser::InvalidArgument, rest.first if rest.any?
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
end
|
data/lib/claude_inbox/dialog.rb
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "tty-box"
|
|
4
|
+
require_relative "text"
|
|
4
5
|
require_relative "text_buffer"
|
|
5
6
|
|
|
6
7
|
module ClaudeInbox
|
|
@@ -78,6 +79,45 @@ module ClaudeInbox
|
|
|
78
79
|
end
|
|
79
80
|
end
|
|
80
81
|
|
|
82
|
+
# The first launch without the Claude Code pane asks once whether to add
|
|
83
|
+
# it, and has to say what the pane is to someone who has never seen it.
|
|
84
|
+
class PaneOffer < Confirm
|
|
85
|
+
WIDTH = 60
|
|
86
|
+
SKETCH = [
|
|
87
|
+
" ┌──────────────────────────┬─────────────────────┐",
|
|
88
|
+
" │ > fix the flaky spec │ 📥 Inbox │",
|
|
89
|
+
" │ │ ● needs you docs │",
|
|
90
|
+
" │ Reading app.rb… │ ⠋ working api │",
|
|
91
|
+
" │ │ ✓ done auth │",
|
|
92
|
+
" └──────────────────────────┴─────────────────────┘"
|
|
93
|
+
].freeze
|
|
94
|
+
|
|
95
|
+
def initialize = super(:install, nil)
|
|
96
|
+
|
|
97
|
+
def title = " ✨ The inbox, inside Claude Code "
|
|
98
|
+
|
|
99
|
+
# Drawn by hand: TTY::Box measures an emoji as one column, which
|
|
100
|
+
# pushes the right border out on every line that has one.
|
|
101
|
+
def frame(width, _caret = nil)
|
|
102
|
+
inner = [width - 4, WIDTH].min - 2
|
|
103
|
+
["┌#{title}#{"─" * (inner - Text.width(title))}┐",
|
|
104
|
+
*lines(inner, nil).map { |l| "│#{Text.pad(Text.truncate(l, inner), inner)}│" },
|
|
105
|
+
"└#{"─" * inner}┘"]
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def lines(_width, _caret)
|
|
109
|
+
["", *SKETCH, "",
|
|
110
|
+
" • Every session, docked beside the transcript",
|
|
111
|
+
" • Switch sessions without leaving the one you're in",
|
|
112
|
+
" • Snooze, pin and settle with the same keys",
|
|
113
|
+
" • /inbox opens it, ctrl+x tab focuses it",
|
|
114
|
+
"",
|
|
115
|
+
" You can install this later with claude-inbox install",
|
|
116
|
+
"",
|
|
117
|
+
" y add it to Claude Code esc not now"]
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
81
121
|
# `a` alias and `P` pull request: one line of text, read from `value`
|
|
82
122
|
# once :save comes back. Empty means clear.
|
|
83
123
|
class Prompt < Dialog
|