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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b325ed415502366088de4b6e9b7634835aea1fb0a6be708e211344dfeba8fa33
|
|
4
|
+
data.tar.gz: 53fbfbeac341e7f5643724911eb5feecb4165a6aaa367dad73c2a4002be9bee9
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 072a5ce3e44c1e5317d105fd49aea27fc5e1365c146c165e8ad87b61b50c24f0046bd659278a41b3848ec15523c1eaa255748f5f080a4e26b25476ba6f7e1218
|
|
7
|
+
data.tar.gz: 31715b40f83b6749d3064c323018c74f62e1176f248bcc00b3cf16769b81c202b49cb0e7228e3ca8bd3b5519c8d7ca99001c026868d29605b488430a6662f5eb
|
data/README.md
CHANGED
|
@@ -3,6 +3,10 @@
|
|
|
3
3
|
An inbox for Claude Code's background sessions. It lists what `claude agents`
|
|
4
4
|
lists and adds snooze, settle, pinning and each session's pull request.
|
|
5
5
|
|
|
6
|
+
Inspired by [T3 Code](https://github.com/pingdotgg/t3code)'s functionality and
|
|
7
|
+
inbox-style UX, I wanted a better way to manage my Claude agents, so I built
|
|
8
|
+
this.
|
|
9
|
+
|
|
6
10
|

|
|
7
11
|
|
|
8
12
|
## Install
|
|
@@ -14,6 +18,8 @@ gem install claude-inbox
|
|
|
14
18
|
claude-inbox
|
|
15
19
|
```
|
|
16
20
|
|
|
21
|
+
`claude-inbox update` installs the latest release.
|
|
22
|
+
|
|
17
23
|
Put arguments you want on every launch in `~/.config/claude-inbox/config`.
|
|
18
24
|
|
|
19
25
|
To add the pane to every Claude Code session:
|
|
@@ -22,6 +28,7 @@ To add the pane to every Claude Code session:
|
|
|
22
28
|
claude-inbox install
|
|
23
29
|
```
|
|
24
30
|
|
|
31
|
+
The inbox offers to do this once, the first time it starts without the pane.
|
|
25
32
|
`claude-inbox install --force` also replaces any other copy of the pane.
|
|
26
33
|
|
|
27
34
|
## Claude Code pane
|
|
@@ -33,8 +40,8 @@ a fullscreen terminal.
|
|
|
33
40
|
|
|
34
41
|
`/inbox` opens it, `ctrl+x tab` focuses it and `esc` leaves. The keys are the
|
|
35
42
|
ones below, with `ge` for `G`, `n` `p` for `Tab` `Shift+Tab`, and no `a`,
|
|
36
|
-
`P`, `X`, `Ctrl-x`,
|
|
37
|
-
attached through `claude-inbox`.
|
|
43
|
+
`P`, `X`, `Ctrl-x`, `/`, `R` or `U`. `Enter`, or a click on a row, switches to the
|
|
44
|
+
session when you attached through `claude-inbox`.
|
|
38
45
|
|
|
39
46
|
It opens when a session starts. To stop that, set
|
|
40
47
|
`pluginConfigs.inbox-pane.options.openOnStart` to `false` in
|
|
@@ -84,6 +91,7 @@ Vim bindings. Arrows and the mouse work too.
|
|
|
84
91
|
| `Ctrl-x` | delete the session, transcript and worktree for good |
|
|
85
92
|
| `/` | filter; `Enter` keeps it, `Esc` clears |
|
|
86
93
|
| `R` | poll now |
|
|
94
|
+
| `U` | update claude-inbox when the header says a newer one is out |
|
|
87
95
|
| `q` | quit |
|
|
88
96
|
|
|
89
97
|
`X` and `Ctrl-x` both ask for `y` first.
|
|
@@ -91,12 +99,16 @@ Vim bindings. Arrows and the mouse work too.
|
|
|
91
99
|
### New session
|
|
92
100
|
|
|
93
101
|
`n` opens a form for the prompt, name, directory, model, effort, permissions,
|
|
94
|
-
worktree
|
|
95
|
-
|
|
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.
|
|
96
105
|
|
|
97
106
|
Paste an image on macOS, or drop a file into the prompt, and refer to it as
|
|
98
107
|
`[Image #1]`. Type `/` for a menu of your skills.
|
|
99
108
|
|
|
109
|
+
Type in Directory to pick from recent and trusted directories; `Ctrl-U`
|
|
110
|
+
lists them all.
|
|
111
|
+
|
|
100
112
|
## How sessions move
|
|
101
113
|
|
|
102
114
|
A snoozed session wakes when its timer runs out, when you press `u`, or when
|
|
@@ -108,11 +120,10 @@ again.
|
|
|
108
120
|
A session settles when you press `x` or when all its pull requests are merged
|
|
109
121
|
or closed. A session without a pull request only settles by hand.
|
|
110
122
|
|
|
111
|
-
|
|
112
|
-
`claude rm`. Working, pinned and snoozed sessions are kept, and
|
|
113
|
-
refuses a worktree with unpushed commits. Each deletion is logged
|
|
114
|
-
`~/.config/claude-inbox/reaped.log`.
|
|
115
|
-
off.
|
|
123
|
+
With `--reap`, the inbox deletes background sessions that have been idle for
|
|
124
|
+
14 days, with `claude rm`. Working, pinned and snoozed sessions are kept, and
|
|
125
|
+
`claude rm` refuses a worktree with unpushed commits. Each deletion is logged
|
|
126
|
+
to `~/.config/claude-inbox/reaped.log`.
|
|
116
127
|
|
|
117
128
|
## Remote Control
|
|
118
129
|
|
data/exe/claude-inbox
CHANGED
|
@@ -29,8 +29,28 @@ if ARGV.first == "install"
|
|
|
29
29
|
exit
|
|
30
30
|
end
|
|
31
31
|
|
|
32
|
+
if ARGV.first == "update"
|
|
33
|
+
require "claude_inbox/updater"
|
|
34
|
+
updater = ClaudeInbox::Updater.new
|
|
35
|
+
begin
|
|
36
|
+
update = updater.check(force: true)
|
|
37
|
+
rescue ClaudeInbox::Updater::Error => e
|
|
38
|
+
abort "claude-inbox: #{e.message}"
|
|
39
|
+
end
|
|
40
|
+
unless update
|
|
41
|
+
puts((updater.kind == :gem) ? "claude-inbox #{ClaudeInbox::VERSION} is the latest release." : "main is up to date.")
|
|
42
|
+
exit
|
|
43
|
+
end
|
|
44
|
+
puts update.installing
|
|
45
|
+
applied = updater.apply
|
|
46
|
+
abort "claude-inbox: #{applied.output.strip}" unless applied.success?
|
|
47
|
+
puts((update.kind == :gem) ? "Installed claude-inbox #{update.latest}." : "main is at #{update.latest}.")
|
|
48
|
+
exit
|
|
49
|
+
end
|
|
50
|
+
|
|
32
51
|
require "claude_inbox/app"
|
|
33
52
|
require "claude_inbox/config"
|
|
53
|
+
require "claude_inbox/updater"
|
|
34
54
|
require "claude_inbox/writer_lock"
|
|
35
55
|
require "tmpdir"
|
|
36
56
|
|
|
@@ -60,23 +80,37 @@ if fixture
|
|
|
60
80
|
opts[:listen] = listen&.merge(fixture: true, pairing: ClaudeInbox::Remote::Pairing.new(path: File.join(dir, "listen.json")),
|
|
61
81
|
lock_path: File.join(dir, "listen.lock"), images_dir: File.join(dir, "images"))
|
|
62
82
|
else
|
|
63
|
-
# The only place the reaper is armed
|
|
64
|
-
#
|
|
65
|
-
#
|
|
83
|
+
# The only place the reaper is armed, and only with --reap: deleting
|
|
84
|
+
# sessions is opt-in. Everything else — tests, --fixture, anything
|
|
85
|
+
# embedding App — gets one that deletes nothing. The listener, likewise,
|
|
86
|
+
# comes only from this file, and only when asked for.
|
|
66
87
|
opts[:client] = ClaudeInbox::AgentsClient.new
|
|
67
88
|
opts[:store] = ClaudeInbox::Store.new
|
|
68
|
-
opts[:reaper] = ClaudeInbox::Reaper.new(opts[:client], opts[:store])
|
|
89
|
+
opts[:reaper] = argv.include?("--reap") ? ClaudeInbox::Reaper.new(opts[:client], opts[:store]) : ClaudeInbox::Reaper.disabled
|
|
69
90
|
opts[:snapshot] = ClaudeInbox::Snapshot.new
|
|
70
91
|
opts[:switch] = ClaudeInbox::Switch.new
|
|
71
92
|
opts[:actions] = ClaudeInbox::Actions.new
|
|
93
|
+
opts[:pane_offer] = ClaudeInbox::Mod::Offer.new
|
|
72
94
|
opts[:listen] = listen
|
|
95
|
+
opts[:updater] = ClaudeInbox::Updater.new unless argv.include?("--no-update-check")
|
|
96
|
+
# The typed arguments, not the config's: a relaunch reads the config again.
|
|
97
|
+
opts[:argv] = ARGV
|
|
73
98
|
end
|
|
74
99
|
opts[:color] = false if argv.include?("--no-color")
|
|
75
100
|
|
|
76
101
|
if argv.include?("--headless")
|
|
77
102
|
abort "claude-inbox: --headless takes no --fixture" if fixture
|
|
78
103
|
require "claude_inbox/headless"
|
|
79
|
-
exit(ClaudeInbox::Headless.new(client: opts[:client], store: opts[:store]).run ? 0 : 1)
|
|
104
|
+
exit(ClaudeInbox::Headless.new(client: opts[:client], store: opts[:store], updater: opts[:updater]).run ? 0 : 1)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Only what a check within the day already found: a cold launch never waits
|
|
108
|
+
# on the network. The relaunch is the new version's launch; a failure leaves
|
|
109
|
+
# this one to start as usual.
|
|
110
|
+
if argv.include?("--auto-update") && (update = opts[:updater]&.cached)
|
|
111
|
+
puts update.installing
|
|
112
|
+
applied = opts[:updater].apply
|
|
113
|
+
applied.success? ? opts[:updater].relaunch(ARGV) : warn("claude-inbox: #{applied.output.strip}")
|
|
80
114
|
end
|
|
81
115
|
|
|
82
116
|
# The first inbox you can see is the snapshot's writer: a headless one gives
|
|
@@ -88,6 +122,7 @@ unless writer_lock.take(role: "inbox")
|
|
|
88
122
|
opts[:reaper] = ClaudeInbox::Reaper.disabled
|
|
89
123
|
opts[:snapshot] = ClaudeInbox::Snapshot.disabled
|
|
90
124
|
opts[:actions] = ClaudeInbox::Actions.disabled
|
|
125
|
+
opts[:pane_offer] = ClaudeInbox::Mod::Offer.disabled
|
|
91
126
|
end
|
|
92
127
|
# So the pane settings.json names is the installed version's, after a gem update too.
|
|
93
128
|
ClaudeInbox::Mod.link unless fixture
|
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.
|
|
@@ -25,16 +24,16 @@ module ClaudeInbox
|
|
|
25
24
|
@sent = 0
|
|
26
25
|
end
|
|
27
26
|
|
|
28
|
-
# Leaves one request for the writer, as the pane does
|
|
29
|
-
#
|
|
30
|
-
def request(action, id, choice: nil
|
|
27
|
+
# Leaves one request for the writer, as the pane does, named for this
|
|
28
|
+
# inbox; a count keeps two requests in one millisecond apart.
|
|
29
|
+
def request(action, id, choice: nil)
|
|
31
30
|
return unless @dir
|
|
32
31
|
FileUtils.mkdir_p(@dir)
|
|
33
32
|
at = (@clock.call.to_f * 1000).to_i
|
|
34
33
|
request = {"action" => action, "id" => id, "at" => at}
|
|
35
34
|
request["choice"] = choice if choice
|
|
36
35
|
@sent += 1
|
|
37
|
-
File.write(File.join(@dir, "
|
|
36
|
+
File.write(File.join(@dir, "inbox-#{Process.pid}-#{at}-#{@sent}.json"), JSON.generate(request))
|
|
38
37
|
end
|
|
39
38
|
|
|
40
39
|
# Applies every request on disk to `store` and returns how many it took.
|
|
@@ -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
|
|
@@ -39,6 +39,8 @@ module ClaudeInbox
|
|
|
39
39
|
|
|
40
40
|
# Hands the terminal to the child; caller must have restored cooked mode.
|
|
41
41
|
# Returns when the user detaches. Detaching never stops the session.
|
|
42
|
+
# False when the attach failed on its own, as a session that cannot be
|
|
43
|
+
# woken does: the CLI prints why and exits 1.
|
|
42
44
|
#
|
|
43
45
|
# Pressing ← inside an attached session detaches it and then `claude
|
|
44
46
|
# attach` execs itself in place as `claude agents` (same pid). There is
|
|
@@ -52,22 +54,28 @@ module ClaudeInbox
|
|
|
52
54
|
pid = Process.spawn(*Subprocess.command(@bin, "attach", id))
|
|
53
55
|
watchdog = Thread.new { kill_when_agents_view(pid, &stop) }
|
|
54
56
|
_, status = Process.wait2(pid)
|
|
55
|
-
status
|
|
57
|
+
status.success? || !status.exited?
|
|
56
58
|
ensure
|
|
57
59
|
watchdog&.kill
|
|
58
60
|
end
|
|
59
61
|
|
|
60
|
-
def stop(id) = run(
|
|
62
|
+
def stop(id) = run("stop", id)
|
|
61
63
|
|
|
62
|
-
def rm(id) = run(
|
|
64
|
+
def rm(id) = run("rm", id)
|
|
63
65
|
|
|
64
66
|
MODELS = %w[default fable opus sonnet haiku].freeze
|
|
65
67
|
EFFORTS = %w[default low medium high xhigh max].freeze
|
|
66
68
|
PERMISSION_MODES = %w[default acceptEdits auto plan bypassPermissions].freeze
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
|
71
79
|
end
|
|
72
80
|
|
|
73
81
|
# How a prompt attaches a file: the same @ mention the CLI's own prompt
|
|
@@ -188,14 +196,13 @@ module ClaudeInbox
|
|
|
188
196
|
# origin, terminal when unlisted) and the bridge a remote one is served
|
|
189
197
|
# over, and drops the unattended ones, whose parent is the row worth
|
|
190
198
|
# showing.
|
|
191
|
-
def assign_origins(sessions, origins, bridges
|
|
199
|
+
def assign_origins(sessions, origins, bridges)
|
|
192
200
|
sessions
|
|
193
201
|
.map { |s| s.interactive? ? s.with(origin: origins.fetch(s.pid, :terminal), bridge_id: bridges[s.pid]) : s }
|
|
194
202
|
.reject(&:unattended?)
|
|
195
203
|
end
|
|
196
204
|
|
|
197
205
|
def origins_from(rows)
|
|
198
|
-
return {} if rows.empty?
|
|
199
206
|
self.class.origins(rows, ps_commands(rows.map { |_, ppid, _| ppid }.uniq))
|
|
200
207
|
end
|
|
201
208
|
|
|
@@ -238,10 +245,9 @@ module ClaudeInbox
|
|
|
238
245
|
nil
|
|
239
246
|
end
|
|
240
247
|
|
|
241
|
-
def run(
|
|
242
|
-
r = Subprocess.capture(
|
|
243
|
-
raise Error, "#{
|
|
244
|
-
true
|
|
248
|
+
def run(verb, id)
|
|
249
|
+
r = Subprocess.capture(@bin, verb, id)
|
|
250
|
+
raise Error, "#{verb} failed: #{r.err.strip}" unless r.success?
|
|
245
251
|
end
|
|
246
252
|
|
|
247
253
|
def start_bg(argv, cwd, what)
|
|
@@ -274,7 +280,7 @@ module ClaudeInbox
|
|
|
274
280
|
# terminal round-trips through cooked mode and back.
|
|
275
281
|
def attach(id) = system("sh", "-c", "printf 'fake attach to %s\\npress enter to detach: ' \"$1\"; read -r _", "attach", id)
|
|
276
282
|
|
|
277
|
-
def stop(_id) =
|
|
283
|
+
def stop(_id) = nil
|
|
278
284
|
|
|
279
285
|
def spawn(prompt:, cwd:, **)
|
|
280
286
|
sleep 0.5
|
|
@@ -283,6 +289,6 @@ module ClaudeInbox
|
|
|
283
289
|
|
|
284
290
|
def adopt(session_id:, cwd:, pid:) = "adop7ed0"
|
|
285
291
|
|
|
286
|
-
def rm(_id) =
|
|
292
|
+
def rm(_id) = nil
|
|
287
293
|
end
|
|
288
294
|
end
|
data/lib/claude_inbox/app.rb
CHANGED
|
@@ -11,7 +11,7 @@ require_relative "terminal"
|
|
|
11
11
|
require_relative "logs"
|
|
12
12
|
require_relative "peek"
|
|
13
13
|
require_relative "keymap"
|
|
14
|
-
require_relative "remote"
|
|
14
|
+
require_relative "remote/listener"
|
|
15
15
|
require_relative "mouse"
|
|
16
16
|
require_relative "new_session_form"
|
|
17
17
|
require_relative "paste"
|
|
@@ -23,6 +23,9 @@ require_relative "session_request"
|
|
|
23
23
|
require_relative "actions"
|
|
24
24
|
require_relative "snapshot"
|
|
25
25
|
require_relative "switch"
|
|
26
|
+
require_relative "trust"
|
|
27
|
+
require_relative "mod"
|
|
28
|
+
require_relative "updater"
|
|
26
29
|
|
|
27
30
|
module ClaudeInbox
|
|
28
31
|
# Owns the terminal and the key loop. The only class allowed to spawn a
|
|
@@ -32,15 +35,19 @@ module ClaudeInbox
|
|
|
32
35
|
# so both are off unless `bin/claude-inbox` switches them on.
|
|
33
36
|
def initialize(client: AgentsClient.new, store: Store.new, pull_requests: PullRequests.new,
|
|
34
37
|
rate_limits: RateLimits.new, reaper: Reaper.disabled, snapshot: Snapshot.disabled, switch: Switch.disabled,
|
|
35
|
-
actions: Actions.disabled, listen: nil, out: $stdout, input: $stdin, color: true,
|
|
36
|
-
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)
|
|
37
40
|
@client = client
|
|
38
41
|
@store = store
|
|
39
42
|
@snapshot = snapshot
|
|
40
43
|
@switch = switch
|
|
41
44
|
@rate_limits = rate_limits
|
|
42
45
|
@terminal = terminal
|
|
46
|
+
@updater = updater
|
|
47
|
+
@argv = argv
|
|
48
|
+
@out = out
|
|
43
49
|
@color = color
|
|
50
|
+
@trust = trust
|
|
44
51
|
@renderer = Renderer.new(color: color)
|
|
45
52
|
@input = input
|
|
46
53
|
@reader = TTY::Reader.new(input: input, output: out, interrupt: :noop)
|
|
@@ -63,6 +70,8 @@ module ClaudeInbox
|
|
|
63
70
|
@last_poll = nil
|
|
64
71
|
@quit = false
|
|
65
72
|
@resize = false
|
|
73
|
+
@pane_offer = pane_offer
|
|
74
|
+
@modal = Dialog::PaneOffer.new if pane_offer.take
|
|
66
75
|
end
|
|
67
76
|
|
|
68
77
|
def run
|
|
@@ -81,6 +90,7 @@ module ClaudeInbox
|
|
|
81
90
|
end
|
|
82
91
|
|
|
83
92
|
def step(input = nil)
|
|
93
|
+
check_for_update unless @update_status
|
|
84
94
|
drain_queue
|
|
85
95
|
@logs.tick
|
|
86
96
|
if @resize
|
|
@@ -122,6 +132,7 @@ module ClaudeInbox
|
|
|
122
132
|
@error = nil
|
|
123
133
|
when :error then @error = rest[0]
|
|
124
134
|
when :notice then notice(rest[0])
|
|
135
|
+
when :update then update_checked(*rest)
|
|
125
136
|
when :select then @pending_select = rest[0]
|
|
126
137
|
when :attach then attach(rest[0])
|
|
127
138
|
when :form_started then @modal = nil if @modal.equal?(rest[0])
|
|
@@ -129,8 +140,6 @@ module ClaudeInbox
|
|
|
129
140
|
when :remote_started then remote_started(*rest)
|
|
130
141
|
end
|
|
131
142
|
end
|
|
132
|
-
rescue ThreadError
|
|
133
|
-
nil
|
|
134
143
|
end
|
|
135
144
|
|
|
136
145
|
# ----- main loop --------------------------------------------------------
|
|
@@ -206,7 +215,49 @@ module ClaudeInbox
|
|
|
206
215
|
def status_text(now)
|
|
207
216
|
return @notice[0] if @notice && now < @notice[1]
|
|
208
217
|
return "⚠ #{@error}" if @error
|
|
209
|
-
"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}"
|
|
210
261
|
end
|
|
211
262
|
|
|
212
263
|
def filtered(sections = @store.sections) = sections.matching(@filter&.to_s)
|
|
@@ -225,7 +276,7 @@ module ClaudeInbox
|
|
|
225
276
|
|
|
226
277
|
def select(selection)
|
|
227
278
|
@selected = selection
|
|
228
|
-
@peek.select(
|
|
279
|
+
@peek.select(selected_session)
|
|
229
280
|
end
|
|
230
281
|
|
|
231
282
|
def session_for(key) = @store.sessions.find { |s| s.key == key }
|
|
@@ -305,7 +356,7 @@ module ClaudeInbox
|
|
|
305
356
|
return if @list_width && col > @list_width
|
|
306
357
|
item = row_item_at(row)
|
|
307
358
|
return unless item
|
|
308
|
-
select(item
|
|
359
|
+
select(item)
|
|
309
360
|
activate
|
|
310
361
|
end
|
|
311
362
|
|
|
@@ -313,7 +364,7 @@ module ClaudeInbox
|
|
|
313
364
|
# no item of its own; a click there resolves to the row above it.
|
|
314
365
|
def row_item_at(row)
|
|
315
366
|
idx = row - 1
|
|
316
|
-
@row_items[idx] || (@row_items[idx - 1] if idx > 0 && @row_items[idx - 1]&.
|
|
367
|
+
@row_items[idx] || (@row_items[idx - 1] if idx > 0 && @row_items[idx - 1]&.row?)
|
|
317
368
|
end
|
|
318
369
|
|
|
319
370
|
def perform(action)
|
|
@@ -347,6 +398,7 @@ module ClaudeInbox
|
|
|
347
398
|
when :stop then open_confirm(:stop)
|
|
348
399
|
when :delete then open_confirm(:delete)
|
|
349
400
|
when :refresh then @poller.soon
|
|
401
|
+
when :update then update_inbox
|
|
350
402
|
when :toggle_peek then toggle_peek
|
|
351
403
|
when :new_session then open_new_session
|
|
352
404
|
when :remote_pairing then @modal = @listener.pairing_dialog
|
|
@@ -420,13 +472,16 @@ module ClaudeInbox
|
|
|
420
472
|
|
|
421
473
|
# The pane in the attached session may ask for a different one: the
|
|
422
474
|
# attach ends and the next starts without passing through the list. The
|
|
423
|
-
# poller keeps running meanwhile, so the pane's snapshot stays fresh.
|
|
475
|
+
# poller keeps running meanwhile, so the pane's snapshot stays fresh. A
|
|
476
|
+
# failed attach leaves its reason on screen until a key is pressed.
|
|
424
477
|
def attach(id)
|
|
425
478
|
while id
|
|
426
479
|
@store.acknowledge(id)
|
|
427
480
|
@switch.clear
|
|
428
|
-
from =
|
|
429
|
-
@terminal.release
|
|
481
|
+
from = session_for(id)&.session_id
|
|
482
|
+
@terminal.release do
|
|
483
|
+
@terminal.pause unless @client.attach(id) { @switch.requested_for?(from) }
|
|
484
|
+
end
|
|
430
485
|
id = @switch.take(@store.sessions.select(&:actionable?).map(&:key))
|
|
431
486
|
end
|
|
432
487
|
end
|
|
@@ -475,7 +530,8 @@ module ClaudeInbox
|
|
|
475
530
|
|
|
476
531
|
def open_new_session
|
|
477
532
|
cwd = selected_session&.cwd || Dir.pwd
|
|
478
|
-
|
|
533
|
+
dirs = SessionRequest.directories(@store.sessions, @trust.call)
|
|
534
|
+
@modal = NewSessionForm.new(cwd: SessionRequest.strip_worktree(cwd), color: @color, dirs: dirs)
|
|
479
535
|
end
|
|
480
536
|
|
|
481
537
|
# `attach:` hands the terminal over as soon as the session starts. Without
|
|
@@ -532,6 +588,7 @@ module ClaudeInbox
|
|
|
532
588
|
when :stop then stop_session(id)
|
|
533
589
|
when :delete then delete_session(id)
|
|
534
590
|
when :adopt then adopt_session(id)
|
|
591
|
+
when :install then install_pane
|
|
535
592
|
end
|
|
536
593
|
when :save then save_prompt
|
|
537
594
|
when :copy then @listener.copy_pairing_url
|
|
@@ -570,6 +627,13 @@ module ClaudeInbox
|
|
|
570
627
|
end
|
|
571
628
|
end
|
|
572
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
|
+
|
|
573
637
|
def delete_session(id)
|
|
574
638
|
notice("deleting #{id}…")
|
|
575
639
|
in_background do
|
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
|
|
@@ -5,6 +5,7 @@ require_relative "poller"
|
|
|
5
5
|
require_relative "pull_requests"
|
|
6
6
|
require_relative "reaper"
|
|
7
7
|
require_relative "snapshot"
|
|
8
|
+
require_relative "updater"
|
|
8
9
|
require_relative "writer_lock"
|
|
9
10
|
|
|
10
11
|
module ClaudeInbox
|
|
@@ -13,19 +14,34 @@ module ClaudeInbox
|
|
|
13
14
|
# reaps, since deleting sessions stays with the inbox you can see, and it
|
|
14
15
|
# exits at once while an inbox holds the writer lock.
|
|
15
16
|
class Headless
|
|
16
|
-
def initialize(client:, store:, pull_requests: PullRequests.new,
|
|
17
|
-
lock: WriterLock.new,
|
|
17
|
+
def initialize(client:, store:, pull_requests: PullRequests.new, actions: Actions.new, snapshot: Snapshot.new,
|
|
18
|
+
lock: WriterLock.new, updater: nil)
|
|
18
19
|
@lock = lock
|
|
19
|
-
@
|
|
20
|
+
@snapshot = snapshot
|
|
21
|
+
@updater = updater
|
|
22
|
+
@queue = Queue.new
|
|
20
23
|
@poller = Poller.new(client: client, store: store, pull_requests: pull_requests, reaper: Reaper.disabled,
|
|
21
|
-
queue: queue, snapshot: snapshot, actions: actions)
|
|
24
|
+
queue: @queue, snapshot: snapshot, actions: actions)
|
|
22
25
|
end
|
|
23
26
|
|
|
24
27
|
# False when the lock is held, by an inbox or another headless process.
|
|
25
28
|
def run
|
|
26
29
|
return false unless @lock.take(role: "headless")
|
|
27
30
|
@poller.start
|
|
31
|
+
check_for_update if @updater
|
|
28
32
|
loop { @queue.pop }
|
|
29
33
|
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
# After the poller is off, since the check may wait on the network. The
|
|
38
|
+
# pane cannot swap the process under Claude Code, so the snapshot only
|
|
39
|
+
# says a newer one is out, and a check that cannot tell says nothing.
|
|
40
|
+
def check_for_update
|
|
41
|
+
@snapshot.update = @updater.check&.notice
|
|
42
|
+
@poller.soon
|
|
43
|
+
rescue Updater::Error
|
|
44
|
+
nil
|
|
45
|
+
end
|
|
30
46
|
end
|
|
31
47
|
end
|
data/lib/claude_inbox/images.rb
CHANGED
|
@@ -41,7 +41,7 @@ module ClaudeInbox
|
|
|
41
41
|
# `index` orders the images one request brings; the random part keeps
|
|
42
42
|
# two requests saving in the same millisecond apart. Readable by the
|
|
43
43
|
# owner only: a photo can be anything.
|
|
44
|
-
def self.save(bytes, dir
|
|
44
|
+
def self.save(bytes, dir:, index:, now: Time.now)
|
|
45
45
|
data = bytes.b
|
|
46
46
|
ext = extension(data)
|
|
47
47
|
raise Unsupported, "not a PNG, JPEG, GIF or WebP image" unless ext
|