claude-inbox 0.1.0 → 0.3.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 +185 -386
- data/exe/claude-inbox +23 -5
- data/lib/claude_inbox/agents_client.rb +104 -44
- data/lib/claude_inbox/app.rb +114 -62
- data/lib/claude_inbox/config.rb +24 -0
- data/lib/claude_inbox/dialog.rb +10 -4
- data/lib/claude_inbox/images.rb +37 -6
- data/lib/claude_inbox/job_state.rb +32 -9
- data/lib/claude_inbox/keymap.rb +2 -2
- data/lib/claude_inbox/logs.rb +5 -2
- data/lib/claude_inbox/new_session_form.rb +64 -52
- data/lib/claude_inbox/painter.rb +32 -0
- data/lib/claude_inbox/palette.rb +4 -9
- data/lib/claude_inbox/peek.rb +4 -4
- data/lib/claude_inbox/poller.rb +9 -14
- data/lib/claude_inbox/pull_requests.rb +5 -5
- data/lib/claude_inbox/rate_limits.rb +19 -7
- data/lib/claude_inbox/reaper.rb +13 -16
- data/lib/claude_inbox/records.rb +7 -2
- data/lib/claude_inbox/remote/http.rb +137 -0
- data/lib/claude_inbox/remote/icon.png +0 -0
- data/lib/claude_inbox/remote/icon.svg +8 -0
- data/lib/claude_inbox/remote/listener.rb +436 -0
- data/lib/claude_inbox/remote/page.html +477 -0
- data/lib/claude_inbox/remote/pairing.rb +111 -0
- data/lib/claude_inbox/remote/pairing_dialog.rb +93 -0
- data/lib/claude_inbox/remote/start.rb +202 -0
- data/lib/claude_inbox/remote.rb +10 -0
- data/lib/claude_inbox/renderer.rb +135 -113
- data/lib/claude_inbox/session.rb +19 -19
- data/lib/claude_inbox/session_request.rb +151 -0
- data/lib/claude_inbox/sessions.rb +2 -3
- data/lib/claude_inbox/settings.rb +19 -7
- data/lib/claude_inbox/slash_commands.rb +6 -6
- data/lib/claude_inbox/store/row.rb +3 -3
- data/lib/claude_inbox/store.rb +11 -4
- data/lib/claude_inbox/subprocess.rb +12 -3
- data/lib/claude_inbox/terminal.rb +9 -3
- data/lib/claude_inbox/text.rb +13 -11
- data/lib/claude_inbox/text_buffer.rb +3 -2
- data/lib/claude_inbox/theme.rb +5 -4
- data/lib/claude_inbox/trust.rb +23 -0
- data/lib/claude_inbox/vt_screen.rb +1 -3
- data/lib/claude_inbox.rb +1 -1
- metadata +15 -2
data/exe/claude-inbox
CHANGED
|
@@ -9,25 +9,43 @@ if ARGV.include?("--version")
|
|
|
9
9
|
end
|
|
10
10
|
|
|
11
11
|
require "claude_inbox/app"
|
|
12
|
+
require "claude_inbox/config"
|
|
13
|
+
require "tmpdir"
|
|
14
|
+
|
|
15
|
+
fixture = ARGV.include?("--fixture")
|
|
16
|
+
# A fixture run takes the listen flags, for trying the API with curl, but
|
|
17
|
+
# not the environment or the config file, which are for the real inbox.
|
|
18
|
+
begin
|
|
19
|
+
argv = fixture ? ARGV : ClaudeInbox::Config.argv(ARGV)
|
|
20
|
+
listen = ClaudeInbox::Remote::Listener.options(argv, fixture ? {} : ENV)
|
|
21
|
+
rescue ArgumentError => e
|
|
22
|
+
abort "claude-inbox: #{e.message}"
|
|
23
|
+
end
|
|
12
24
|
|
|
13
25
|
# Crash traces go here instead of the alt screen when set.
|
|
14
26
|
$stderr.reopen(ENV["CLAUDE_INBOX_STDERR"], "a") if ENV["CLAUDE_INBOX_STDERR"]
|
|
15
27
|
|
|
16
28
|
opts = {}
|
|
17
|
-
if
|
|
18
|
-
agents =
|
|
29
|
+
if fixture
|
|
30
|
+
agents = argv[argv.index("--fixture") + 1]
|
|
19
31
|
fixtures = File.dirname(agents)
|
|
20
32
|
opts[:client] = ClaudeInbox::FixtureClient.new(agents, logs: File.binread(File.join(fixtures, "logs_raw.txt")))
|
|
21
33
|
opts[:store] = ClaudeInbox::Store.new(path: nil)
|
|
22
|
-
opts[:jobs_dir] = File.join(fixtures, "jobs")
|
|
23
34
|
opts[:pull_requests] = ClaudeInbox::PullRequests.new(cache_path: File.join(fixtures, "gh-pr-status-cache.json"), resolved_path: nil, gh: nil)
|
|
35
|
+
# Its own token, lock and images, so a test inbox can neither rotate the
|
|
36
|
+
# token phones are paired with nor hold the real inbox's lock.
|
|
37
|
+
dir = File.join(Dir.tmpdir, "claude-inbox-fixture")
|
|
38
|
+
opts[:listen] = listen&.merge(fixture: true, pairing: ClaudeInbox::Remote::Pairing.new(path: File.join(dir, "listen.json")),
|
|
39
|
+
lock_path: File.join(dir, "listen.lock"), images_dir: File.join(dir, "images"))
|
|
24
40
|
else
|
|
25
41
|
# The only place the reaper is armed. Everything else — tests, --fixture,
|
|
26
|
-
# anything embedding App — gets one that deletes nothing.
|
|
42
|
+
# anything embedding App — gets one that deletes nothing. The listener,
|
|
43
|
+
# likewise, comes only from this file, and only when asked for.
|
|
27
44
|
opts[:client] = ClaudeInbox::AgentsClient.new
|
|
28
45
|
opts[:store] = ClaudeInbox::Store.new
|
|
29
46
|
opts[:reaper] = ClaudeInbox::Reaper.new(opts[:client], opts[:store])
|
|
47
|
+
opts[:listen] = listen
|
|
30
48
|
end
|
|
31
|
-
opts[:color] = false if
|
|
49
|
+
opts[:color] = false if argv.include?("--no-color")
|
|
32
50
|
|
|
33
51
|
ClaudeInbox::App.new(**opts).run
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
4
|
require_relative "debug"
|
|
5
|
+
require_relative "job_state"
|
|
5
6
|
require_relative "subprocess"
|
|
6
7
|
|
|
7
8
|
module ClaudeInbox
|
|
@@ -10,8 +11,11 @@ module ClaudeInbox
|
|
|
10
11
|
class AgentsClient
|
|
11
12
|
class Error < StandardError; end
|
|
12
13
|
|
|
13
|
-
|
|
14
|
+
attr_reader :jobs_dir
|
|
15
|
+
|
|
16
|
+
def initialize(bin: "claude", jobs_dir: JobState::DEFAULT_DIR)
|
|
14
17
|
@bin = bin
|
|
18
|
+
@jobs_dir = jobs_dir
|
|
15
19
|
end
|
|
16
20
|
|
|
17
21
|
# => Array<Session>
|
|
@@ -42,7 +46,7 @@ module ClaudeInbox
|
|
|
42
46
|
# and, the moment it becomes the agents view, terminate it. The user then
|
|
43
47
|
# lands back in the inbox instead of native agent view.
|
|
44
48
|
def attach(id)
|
|
45
|
-
pid = Process.spawn(@bin, "attach", id)
|
|
49
|
+
pid = Process.spawn(*Subprocess.command(@bin, "attach", id))
|
|
46
50
|
watchdog = Thread.new { kill_when_agents_view(pid) }
|
|
47
51
|
_, status = Process.wait2(pid)
|
|
48
52
|
status
|
|
@@ -60,10 +64,7 @@ module ClaudeInbox
|
|
|
60
64
|
|
|
61
65
|
# Start a background session. Returns its short id.
|
|
62
66
|
def spawn(prompt:, cwd:, **opts)
|
|
63
|
-
|
|
64
|
-
r = Subprocess.capture(*argv, chdir: cwd)
|
|
65
|
-
raise Error, "claude --bg failed: #{(r.err + r.out).strip}" unless r.success?
|
|
66
|
-
r.out[/\b[0-9a-f]{8}\b/] || r.out.strip
|
|
67
|
+
start_bg(self.class.spawn_args(@bin, prompt: prompt, **opts), cwd, "claude --bg")
|
|
67
68
|
end
|
|
68
69
|
|
|
69
70
|
# How a prompt attaches a file: the same @ mention the CLI's own prompt
|
|
@@ -71,45 +72,44 @@ module ClaudeInbox
|
|
|
71
72
|
# its path completion escapes them; a quoted path is not recognized.
|
|
72
73
|
def self.mention(path) = "@" + path.gsub(" ", "\\ ")
|
|
73
74
|
|
|
74
|
-
# Pure so it can be tested:
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
argv
|
|
75
|
+
# Pure so it can be tested: a setting left nil leaves its flag off. The
|
|
76
|
+
# prompt goes after "--", where a leading dash is text rather than an
|
|
77
|
+
# unknown option, and --remote-control sits just before it: its optional
|
|
78
|
+
# name would otherwise eat the prompt (both checked on 2.1.282).
|
|
79
|
+
def self.spawn_args(bin, prompt:, model: nil, effort: nil, permission_mode: nil, worktree: false, name: nil, remote: false)
|
|
80
|
+
argv = [bin, "--bg"]
|
|
81
|
+
{"--model" => model, "--effort" => effort, "--permission-mode" => permission_mode}.each do |flag, value|
|
|
82
|
+
argv += [flag, value] if value
|
|
83
|
+
end
|
|
80
84
|
argv += ["--name", name] if name && !name.strip.empty?
|
|
81
85
|
argv << "--worktree" if worktree
|
|
82
|
-
argv
|
|
86
|
+
argv << "--remote-control" if remote
|
|
87
|
+
argv + ["--", prompt]
|
|
83
88
|
end
|
|
84
89
|
|
|
85
|
-
|
|
86
|
-
|
|
90
|
+
# Pull a conversation a `claude remote-control` server is serving into
|
|
91
|
+
# the daemon, as a background session under the same id. Returns the
|
|
92
|
+
# short id. The worker goes first: while it is open, a resume only makes
|
|
93
|
+
# a copy. The server marks the session failed and leaves it be; the
|
|
94
|
+
# resumed session gets a bridge of its own.
|
|
95
|
+
def adopt(session_id:, cwd:, pid:)
|
|
96
|
+
raise Error, "nothing to adopt yet — send it a message from your phone first" if transcript_empty?(cwd, session_id)
|
|
97
|
+
Process.kill("TERM", pid)
|
|
98
|
+
wait_gone(pid)
|
|
99
|
+
start_bg(self.class.adopt_args(@bin, session_id), cwd, "claude --bg --resume")
|
|
87
100
|
end
|
|
88
101
|
|
|
89
|
-
|
|
90
|
-
# you type in: the headless print mode and the SDK's stream protocol.
|
|
91
|
-
HEADLESS_FLAGS = %w[-p --print --input-format --output-format].freeze
|
|
102
|
+
def self.adopt_args(bin, session_id) = [bin, "--bg", "--resume", session_id, "--remote-control"]
|
|
92
103
|
|
|
93
|
-
#
|
|
94
|
-
#
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
# merely flagged: attach lands on the session that asked for it, so there
|
|
98
|
-
# is nothing useful to show or act on directly.
|
|
99
|
-
def classify_origins(sessions)
|
|
100
|
-
pids = sessions.select { |s| s.interactive? && s.pid }.map(&:pid)
|
|
101
|
-
return sessions if pids.empty?
|
|
102
|
-
assign_origins(sessions, origins_by_pid(pids))
|
|
104
|
+
# Where the CLI keeps a directory's transcripts: every slash and dot in
|
|
105
|
+
# the path becomes a dash.
|
|
106
|
+
def self.transcript_path(cwd, session_id, home: Dir.home)
|
|
107
|
+
File.join(home, ".claude", "projects", cwd.gsub(%r{[/.]}, "-"), "#{session_id}.jsonl")
|
|
103
108
|
end
|
|
104
109
|
|
|
105
|
-
#
|
|
106
|
-
#
|
|
107
|
-
|
|
108
|
-
def assign_origins(sessions, origins)
|
|
109
|
-
sessions
|
|
110
|
-
.map { |s| s.interactive? ? s.with(origin: origins.fetch(s.pid, :terminal)) : s }
|
|
111
|
-
.reject(&:unattended?)
|
|
112
|
-
end
|
|
110
|
+
# Flags that mark a claude process as one a program drives rather than one
|
|
111
|
+
# you type in: the headless print mode and the SDK's stream protocol.
|
|
112
|
+
HEADLESS_FLAGS = %w[-p --print --input-format --output-format].freeze
|
|
113
113
|
|
|
114
114
|
# pid => origin, given `ps` for the sessions and for their parents. Pure.
|
|
115
115
|
#
|
|
@@ -137,12 +137,63 @@ module ClaudeInbox
|
|
|
137
137
|
# terminal often enough to matter; the false positive is accepted.
|
|
138
138
|
def self.headless?(cmd) = cmd.split.drop(1).any? { |arg| HEADLESS_FLAGS.include?(arg) }
|
|
139
139
|
|
|
140
|
-
|
|
140
|
+
# pid => bridge session id, for the workers a `claude remote-control`
|
|
141
|
+
# server spawned: each is started with `--session-id cse_…`, the same id
|
|
142
|
+
# a background session records as bridgeSessionId. Pure.
|
|
143
|
+
def self.bridge_ids(rows)
|
|
144
|
+
rows.filter_map { |pid, _, cmd| (m = cmd.match(/--session-id[= ](cse_\w+)/)) && [pid, m[1]] }.to_h
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
private
|
|
148
|
+
|
|
149
|
+
# A worker nobody has messaged yet has an empty file, and the daemon
|
|
150
|
+
# reports "source session not found" on resuming it.
|
|
151
|
+
def transcript_empty?(cwd, session_id)
|
|
152
|
+
path = self.class.transcript_path(cwd, session_id)
|
|
153
|
+
File.exist?(path) && File.zero?(path)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def wait_gone(pid, timeout: 5)
|
|
157
|
+
deadline = Time.now + timeout
|
|
158
|
+
while Time.now < deadline
|
|
159
|
+
Process.kill(0, pid)
|
|
160
|
+
sleep 0.1
|
|
161
|
+
end
|
|
162
|
+
raise Error, "the worker #{pid} would not exit"
|
|
163
|
+
rescue Errno::ESRCH
|
|
164
|
+
nil
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def parse(json)
|
|
168
|
+
JSON.parse(json).map { |h| Session.from_hash(h) }
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# The JSON reports Remote Control workers, local sub-agents and headless
|
|
172
|
+
# runs as `interactive`, same as a terminal you opened yourself, each named
|
|
173
|
+
# after its directory. The process tree tells them apart; see `origins`.
|
|
174
|
+
# Everything but a terminal and a remote worker is dropped here rather than
|
|
175
|
+
# merely flagged: attach lands on the session that asked for it, so there
|
|
176
|
+
# is nothing useful to show or act on directly.
|
|
177
|
+
def classify_origins(sessions)
|
|
178
|
+
pids = sessions.select { |s| s.interactive? && s.pid }.map(&:pid)
|
|
179
|
+
return sessions if pids.empty?
|
|
141
180
|
rows = ps_rows(pids)
|
|
181
|
+
assign_origins(sessions, origins_from(rows), self.class.bridge_ids(rows))
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# Tags each interactive session with where it is driven from (pid =>
|
|
185
|
+
# origin, terminal when unlisted) and the bridge a remote one is served
|
|
186
|
+
# over, and drops the unattended ones, whose parent is the row worth
|
|
187
|
+
# showing.
|
|
188
|
+
def assign_origins(sessions, origins, bridges = {})
|
|
189
|
+
sessions
|
|
190
|
+
.map { |s| s.interactive? ? s.with(origin: origins.fetch(s.pid, :terminal), bridge_id: bridges[s.pid]) : s }
|
|
191
|
+
.reject(&:unattended?)
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def origins_from(rows)
|
|
142
195
|
return {} if rows.empty?
|
|
143
196
|
self.class.origins(rows, ps_commands(rows.map { |_, ppid, _| ppid }.uniq))
|
|
144
|
-
rescue Errno::ENOENT
|
|
145
|
-
{}
|
|
146
197
|
end
|
|
147
198
|
|
|
148
199
|
def ps_rows(pids)
|
|
@@ -152,6 +203,8 @@ module ClaudeInbox
|
|
|
152
203
|
pid, ppid, *cmd = l.split
|
|
153
204
|
[pid.to_i, ppid.to_i, cmd.join(" ")]
|
|
154
205
|
}
|
|
206
|
+
rescue Errno::ENOENT
|
|
207
|
+
[]
|
|
155
208
|
end
|
|
156
209
|
|
|
157
210
|
# pid => command, for the given parent pids.
|
|
@@ -165,8 +218,6 @@ module ClaudeInbox
|
|
|
165
218
|
}
|
|
166
219
|
end
|
|
167
220
|
|
|
168
|
-
private
|
|
169
|
-
|
|
170
221
|
def kill_when_agents_view(pid)
|
|
171
222
|
loop do
|
|
172
223
|
sleep WATCH_INTERVAL
|
|
@@ -188,22 +239,29 @@ module ClaudeInbox
|
|
|
188
239
|
raise Error, "#{argv[1]} failed: #{r.err.strip}" unless r.success?
|
|
189
240
|
true
|
|
190
241
|
end
|
|
242
|
+
|
|
243
|
+
def start_bg(argv, cwd, what)
|
|
244
|
+
r = Subprocess.capture(*argv, chdir: cwd)
|
|
245
|
+
raise Error, "#{what} failed: #{(r.err + r.out).strip}" unless r.success?
|
|
246
|
+
r.out[/\b[0-9a-f]{8}\b/] || r.out.strip
|
|
247
|
+
end
|
|
191
248
|
end
|
|
192
249
|
|
|
193
250
|
# Reads a committed JSON fixture instead of the daemon.
|
|
194
251
|
class FixtureClient < AgentsClient
|
|
195
|
-
def initialize(path, logs: nil, origins: {})
|
|
196
|
-
super()
|
|
252
|
+
def initialize(path, logs: nil, origins: {}, bridges: {})
|
|
253
|
+
super(jobs_dir: File.join(File.dirname(path), "jobs"))
|
|
197
254
|
@path = path
|
|
198
255
|
@logs = logs
|
|
199
256
|
@origins = origins
|
|
257
|
+
@bridges = bridges
|
|
200
258
|
end
|
|
201
259
|
|
|
202
260
|
# Fixture rows carry no process tree; tag each interactive row from the
|
|
203
261
|
# pid => origin map the test hands in, otherwise as a terminal, then drop
|
|
204
262
|
# the unattended ones same as the real client does.
|
|
205
263
|
def list
|
|
206
|
-
assign_origins(parse(File.read(@path)), @origins)
|
|
264
|
+
assign_origins(parse(File.read(@path)), @origins, @bridges)
|
|
207
265
|
end
|
|
208
266
|
|
|
209
267
|
def logs(_id) = @logs
|
|
@@ -219,6 +277,8 @@ module ClaudeInbox
|
|
|
219
277
|
"deadbeef"
|
|
220
278
|
end
|
|
221
279
|
|
|
280
|
+
def adopt(session_id:, cwd:, pid:) = "adop7ed0"
|
|
281
|
+
|
|
222
282
|
def rm(_id) = true
|
|
223
283
|
end
|
|
224
284
|
end
|
data/lib/claude_inbox/app.rb
CHANGED
|
@@ -11,32 +11,38 @@ require_relative "terminal"
|
|
|
11
11
|
require_relative "logs"
|
|
12
12
|
require_relative "peek"
|
|
13
13
|
require_relative "keymap"
|
|
14
|
+
require_relative "remote"
|
|
14
15
|
require_relative "mouse"
|
|
15
16
|
require_relative "new_session_form"
|
|
16
17
|
require_relative "paste"
|
|
17
18
|
require_relative "pull_requests"
|
|
19
|
+
require_relative "reaper"
|
|
18
20
|
require_relative "poller"
|
|
19
21
|
require_relative "rate_limits"
|
|
22
|
+
require_relative "session_request"
|
|
20
23
|
|
|
21
24
|
module ClaudeInbox
|
|
22
25
|
# Owns the terminal and the key loop. The only class allowed to spawn a
|
|
23
26
|
# child process that takes over the terminal.
|
|
24
27
|
class App
|
|
25
|
-
# The reaper
|
|
26
|
-
#
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
28
|
+
# The reaper deletes sessions and the listener lets other machines in,
|
|
29
|
+
# so both are off unless `bin/claude-inbox` switches them on.
|
|
30
|
+
def initialize(client: AgentsClient.new, store: Store.new, pull_requests: PullRequests.new,
|
|
31
|
+
rate_limits: RateLimits.new, reaper: Reaper.disabled, listen: nil, out: $stdout, input: $stdin, color: true,
|
|
32
|
+
terminal: Terminal.new(out, input), queue: Queue.new)
|
|
30
33
|
@client = client
|
|
31
34
|
@store = store
|
|
32
35
|
@rate_limits = rate_limits
|
|
33
|
-
@terminal =
|
|
36
|
+
@terminal = terminal
|
|
34
37
|
@color = color
|
|
35
38
|
@renderer = Renderer.new(color: color)
|
|
36
39
|
@reader = TTY::Reader.new(input: input, output: out, interrupt: :noop)
|
|
37
|
-
@queue =
|
|
38
|
-
@poller = Poller.new(client: client, store: store, pull_requests: pull_requests,
|
|
40
|
+
@queue = queue
|
|
41
|
+
@poller = Poller.new(client: client, store: store, pull_requests: pull_requests,
|
|
39
42
|
reaper: reaper, queue: @queue)
|
|
43
|
+
@listener = listen ? Remote::Listener.new(client: client, store: store, queue: @queue, jobs_dir: client.jobs_dir, **listen) : Remote::Listener.disabled
|
|
44
|
+
@logs = Logs.new(client)
|
|
45
|
+
@peek = Peek.new(@logs)
|
|
40
46
|
@selected = nil
|
|
41
47
|
@row_items = []
|
|
42
48
|
@list_width = nil
|
|
@@ -47,7 +53,6 @@ module ClaudeInbox
|
|
|
47
53
|
@tick = 0
|
|
48
54
|
@modal = nil
|
|
49
55
|
@filter = nil
|
|
50
|
-
@status = "starting…"
|
|
51
56
|
@last_poll = nil
|
|
52
57
|
@quit = false
|
|
53
58
|
@resize = false
|
|
@@ -58,13 +63,25 @@ module ClaudeInbox
|
|
|
58
63
|
install_traps
|
|
59
64
|
@terminal.enter
|
|
60
65
|
@poller.start
|
|
61
|
-
@
|
|
62
|
-
@
|
|
66
|
+
@listener.start
|
|
67
|
+
@logs.start
|
|
63
68
|
main_loop
|
|
64
69
|
ensure
|
|
65
70
|
@poller.stop
|
|
66
|
-
@logs
|
|
71
|
+
@logs.stop
|
|
67
72
|
@terminal.restore
|
|
73
|
+
@listener.stop
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def step(input = nil)
|
|
77
|
+
drain_queue
|
|
78
|
+
@logs.tick
|
|
79
|
+
if @resize
|
|
80
|
+
@resize = false
|
|
81
|
+
@terminal.resized
|
|
82
|
+
end
|
|
83
|
+
render
|
|
84
|
+
handle_input(input) if input
|
|
68
85
|
end
|
|
69
86
|
|
|
70
87
|
private
|
|
@@ -99,6 +116,9 @@ module ClaudeInbox
|
|
|
99
116
|
when :notice then notice(rest[0])
|
|
100
117
|
when :select then @pending_select = rest[0]
|
|
101
118
|
when :attach then attach(rest[0])
|
|
119
|
+
when :form_started then @modal = nil if @modal.equal?(rest[0])
|
|
120
|
+
when :form_failed then form_failed(*rest)
|
|
121
|
+
when :remote_started then remote_started(*rest)
|
|
102
122
|
end
|
|
103
123
|
end
|
|
104
124
|
rescue ThreadError
|
|
@@ -108,17 +128,7 @@ module ClaudeInbox
|
|
|
108
128
|
# ----- main loop --------------------------------------------------------
|
|
109
129
|
|
|
110
130
|
def main_loop
|
|
111
|
-
until @quit
|
|
112
|
-
drain_queue
|
|
113
|
-
@logs.tick
|
|
114
|
-
if @resize
|
|
115
|
-
@resize = false
|
|
116
|
-
@terminal.resized
|
|
117
|
-
end
|
|
118
|
-
render
|
|
119
|
-
key = @reader.read_keypress(echo: false, raw: false, nonblock: true)
|
|
120
|
-
handle_input(key) if key
|
|
121
|
-
end
|
|
131
|
+
step(@reader.read_keypress(echo: false, raw: false, nonblock: true)) until @quit
|
|
122
132
|
end
|
|
123
133
|
|
|
124
134
|
def handle_input(raw)
|
|
@@ -131,10 +141,13 @@ module ClaudeInbox
|
|
|
131
141
|
end
|
|
132
142
|
|
|
133
143
|
# The form takes a paste whole, images included; the one-line editors
|
|
134
|
-
# take it as typing, so a pasted PR URL lands where it should.
|
|
144
|
+
# take it as typing, so a pasted PR URL lands where it should. Anywhere
|
|
145
|
+
# else a paste is dropped: typed out, its letters would be keys, and a
|
|
146
|
+
# "y" answers a confirm.
|
|
135
147
|
def handle_paste(text)
|
|
136
148
|
return @modal.paste(text) if @modal.is_a?(NewSessionForm)
|
|
137
|
-
|
|
149
|
+
typing = @modal ? @modal.is_a?(Dialog::Prompt) : @filter_editing
|
|
150
|
+
text.each_char { |c| handle_key(c) } if typing
|
|
138
151
|
end
|
|
139
152
|
|
|
140
153
|
def render
|
|
@@ -142,18 +155,16 @@ module ClaudeInbox
|
|
|
142
155
|
now = Time.now
|
|
143
156
|
sections = filtered(@store.sections(now))
|
|
144
157
|
width, height = @terminal.size
|
|
158
|
+
body_h = Renderer.body_height(height)
|
|
145
159
|
ensure_selection(sections)
|
|
146
|
-
peek = @peek.view(sections.row(@selected), height)
|
|
147
160
|
@tick += 1
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
filter: @filter, filter_editing: @filter_editing, tick: @tick / 2,
|
|
154
|
-
loading: loading_for
|
|
161
|
+
view = Renderer::View.new(
|
|
162
|
+
width: width, height: height, now: now, selected: @selected&.key, top: @top, expanded: @expanded,
|
|
163
|
+
peek: @peek.view(sections.row(@selected), body_h), modal: modal_lines(width), screen: screen_lines(width, body_h),
|
|
164
|
+
status: status_text(now), usage: @rate_limits.windows(now), filter: @filter, filter_editing: @filter_editing,
|
|
165
|
+
tick: @tick / 2, loading: loading_for, listening: @listener.snapshot
|
|
155
166
|
)
|
|
156
|
-
|
|
167
|
+
frame = @renderer.frame(sections, view)
|
|
157
168
|
@row_items = frame.items
|
|
158
169
|
@list_width = frame.list_width
|
|
159
170
|
@top = frame.top
|
|
@@ -201,9 +212,10 @@ module ClaudeInbox
|
|
|
201
212
|
|
|
202
213
|
# Guard for attach/stop: refuse politely on a terminal or remote row.
|
|
203
214
|
def require_actionable
|
|
204
|
-
|
|
205
|
-
if
|
|
206
|
-
|
|
215
|
+
s = selected_session
|
|
216
|
+
return true if s&.actionable?
|
|
217
|
+
if s&.interactive?
|
|
218
|
+
notice(s.remote? ? "that's a remote session — Enter adopts it, w opens it at claude.ai/code" : "that's your own terminal — switch to that window")
|
|
207
219
|
end
|
|
208
220
|
false
|
|
209
221
|
end
|
|
@@ -211,8 +223,9 @@ module ClaudeInbox
|
|
|
211
223
|
# Guard for snooze/wake/alias: anything with a key, since those live in
|
|
212
224
|
# our own store. A terminal you are sitting in is the one exception.
|
|
213
225
|
def require_storable
|
|
214
|
-
|
|
215
|
-
|
|
226
|
+
s = selected_session
|
|
227
|
+
return true if @selected&.row? && !s&.terminal?
|
|
228
|
+
notice("you're in that terminal right now — nothing to snooze") if s&.terminal?
|
|
216
229
|
false
|
|
217
230
|
end
|
|
218
231
|
|
|
@@ -301,21 +314,23 @@ module ClaudeInbox
|
|
|
301
314
|
when :alias then open_alias_editor
|
|
302
315
|
when :link_pr then open_pr_editor
|
|
303
316
|
when :open_pr then open_pr
|
|
317
|
+
when :open_remote then open_remote
|
|
304
318
|
when :stop then open_confirm(:stop)
|
|
305
319
|
when :delete then open_confirm(:delete)
|
|
306
320
|
when :refresh then @poller.soon
|
|
307
321
|
when :toggle_peek then toggle_peek
|
|
308
322
|
when :new_session then open_new_session
|
|
323
|
+
when :remote_pairing then @modal = @listener.pairing_dialog
|
|
309
324
|
when :filter then start_filter
|
|
310
325
|
when :escape then clear_filter
|
|
311
326
|
end
|
|
312
327
|
end
|
|
313
328
|
|
|
314
|
-
def page = [@terminal.size[1]
|
|
329
|
+
def page = [Renderer.body_height(@terminal.size[1]), 1].max
|
|
315
330
|
|
|
316
331
|
def move(delta)
|
|
317
|
-
return if @items.nil? || @items.empty?
|
|
318
332
|
stops = filtered.selections(@expanded)
|
|
333
|
+
return if stops.empty?
|
|
319
334
|
idx = stops.index(@selected) || 0
|
|
320
335
|
select(stops[(idx + delta).clamp(0, stops.size - 1)])
|
|
321
336
|
end
|
|
@@ -336,7 +351,8 @@ module ClaudeInbox
|
|
|
336
351
|
name if Store::FOLDABLE_SECTIONS.include?(name)
|
|
337
352
|
end
|
|
338
353
|
|
|
339
|
-
def set_expanded(value
|
|
354
|
+
def set_expanded(value)
|
|
355
|
+
name = current_fold_section
|
|
340
356
|
@expanded[name] = value if name
|
|
341
357
|
end
|
|
342
358
|
|
|
@@ -350,6 +366,7 @@ module ClaudeInbox
|
|
|
350
366
|
|
|
351
367
|
def activate
|
|
352
368
|
return @expanded[@selected.key] = true if @selected&.fold?
|
|
369
|
+
return @modal = Dialog::Confirm.new(:adopt, @selected.key) if selected_session&.remote?
|
|
353
370
|
attach(@selected.key) if require_actionable
|
|
354
371
|
end
|
|
355
372
|
|
|
@@ -404,25 +421,27 @@ module ClaudeInbox
|
|
|
404
421
|
@modal = Dialog::Prompt.new(:pr, @selected.key, current)
|
|
405
422
|
end
|
|
406
423
|
|
|
407
|
-
# Hands the first PR to the OS browser opener.
|
|
408
424
|
def open_pr
|
|
409
425
|
pr = selected_session&.pr
|
|
410
426
|
return notice("no pull request linked — P sets one") unless pr
|
|
427
|
+
open_in_browser(pr.url, pr.short)
|
|
428
|
+
end
|
|
429
|
+
|
|
430
|
+
def open_remote
|
|
431
|
+
url = selected_session&.remote_url
|
|
432
|
+
return notice("no claude.ai/code page for this session") unless url
|
|
433
|
+
open_in_browser(url, "claude.ai/code")
|
|
434
|
+
end
|
|
435
|
+
|
|
436
|
+
def open_in_browser(url, what)
|
|
411
437
|
opener = RUBY_PLATFORM.include?("darwin") ? "open" : "xdg-open"
|
|
412
|
-
notice("opening #{
|
|
413
|
-
in_background { Subprocess.capture(opener,
|
|
438
|
+
notice("opening #{what}")
|
|
439
|
+
in_background { Subprocess.capture(opener, url) }
|
|
414
440
|
end
|
|
415
441
|
|
|
416
442
|
def open_new_session
|
|
417
443
|
cwd = selected_session&.cwd || Dir.pwd
|
|
418
|
-
@modal = NewSessionForm.new(cwd: strip_worktree(cwd), pastel: Pastel.new(enabled: @color))
|
|
419
|
-
end
|
|
420
|
-
|
|
421
|
-
# A session's cwd may sit inside a worktree another agent is using; carrying
|
|
422
|
-
# that into a new prompt would spawn the new agent there too, writing over
|
|
423
|
-
# the same files. Fall back to the repo the worktree was cut from.
|
|
424
|
-
def strip_worktree(cwd)
|
|
425
|
-
cwd.to_s.sub(%r{/\.claude/worktrees/[^/]+(?:/.*)?\z}, "")
|
|
444
|
+
@modal = NewSessionForm.new(cwd: SessionRequest.strip_worktree(cwd), pastel: Pastel.new(enabled: @color))
|
|
426
445
|
end
|
|
427
446
|
|
|
428
447
|
# `attach:` hands the terminal over as soon as the session starts. Without
|
|
@@ -434,15 +453,31 @@ module ClaudeInbox
|
|
|
434
453
|
in_background do
|
|
435
454
|
id = @client.spawn(**v)
|
|
436
455
|
@queue << [:notice, "started #{id}"]
|
|
456
|
+
@queue << [:form_started, form]
|
|
437
457
|
@queue << [:select, id]
|
|
438
458
|
attach ? @queue << [:attach, id] : @poller.soon
|
|
459
|
+
rescue AgentsClient::Error => e
|
|
460
|
+
@queue << [:form_failed, form, e.message]
|
|
439
461
|
end
|
|
440
462
|
end
|
|
441
463
|
|
|
464
|
+
# Someone at the desk may be mid-thought, so a start from another
|
|
465
|
+
# device never moves the cursor, attaches or closes what is open.
|
|
466
|
+
def remote_started(id, via)
|
|
467
|
+
notice("started #{id} from #{via}")
|
|
468
|
+
@poller.soon
|
|
469
|
+
end
|
|
470
|
+
|
|
471
|
+
# A spawn failure hands the form back rather than just logging it, so
|
|
472
|
+
# the composed prompt survives (see NewSessionForm#submission_failed).
|
|
473
|
+
def form_failed(form, message)
|
|
474
|
+
@modal.equal?(form) ? form.submission_failed(message) : (@error = message)
|
|
475
|
+
end
|
|
476
|
+
|
|
442
477
|
# The new-session form takes the whole body; a Dialog is a box over it.
|
|
443
|
-
def screen_lines(width,
|
|
478
|
+
def screen_lines(width, body_h)
|
|
444
479
|
return nil unless @modal.is_a?(NewSessionForm)
|
|
445
|
-
{lines: @modal.screen(width,
|
|
480
|
+
{lines: @modal.screen(width, body_h), footer: @modal.footer}
|
|
446
481
|
end
|
|
447
482
|
|
|
448
483
|
def modal_lines(width)
|
|
@@ -459,21 +494,26 @@ module ClaudeInbox
|
|
|
459
494
|
when :confirm
|
|
460
495
|
kind, id = @modal.kind, @modal.id
|
|
461
496
|
@modal = nil
|
|
462
|
-
|
|
497
|
+
case kind
|
|
498
|
+
when :stop then stop_session(id)
|
|
499
|
+
when :delete then delete_session(id)
|
|
500
|
+
when :adopt then adopt_session(id)
|
|
501
|
+
end
|
|
463
502
|
when :save then save_prompt
|
|
503
|
+
when :copy then @listener.copy_pairing_url
|
|
504
|
+
when :rotate then @listener.rotate
|
|
464
505
|
end
|
|
465
506
|
end
|
|
466
507
|
|
|
508
|
+
# `:start`/`:start_and_attach` leave the form as `@modal`, now busy: it
|
|
509
|
+
# closes itself once the spawn is confirmed to have started (`:form_started`)
|
|
510
|
+
# or reopens with the failure and the prompt intact (`:form_failed`).
|
|
467
511
|
def handle_form_key(name, key)
|
|
468
512
|
form = @modal
|
|
469
513
|
case form.press(name, key)
|
|
470
514
|
when :cancel then @modal = nil
|
|
471
|
-
when :start
|
|
472
|
-
|
|
473
|
-
start_session(form, attach: false)
|
|
474
|
-
when :start_and_attach
|
|
475
|
-
@modal = nil
|
|
476
|
-
start_session(form, attach: true)
|
|
515
|
+
when :start then start_session(form, attach: false)
|
|
516
|
+
when :start_and_attach then start_session(form, attach: true)
|
|
477
517
|
end
|
|
478
518
|
end
|
|
479
519
|
|
|
@@ -484,6 +524,18 @@ module ClaudeInbox
|
|
|
484
524
|
end
|
|
485
525
|
end
|
|
486
526
|
|
|
527
|
+
def adopt_session(key)
|
|
528
|
+
s = session_for(key)
|
|
529
|
+
return unless s&.remote?
|
|
530
|
+
notice("adopting #{s.display_name}…")
|
|
531
|
+
in_background do
|
|
532
|
+
id = @client.adopt(session_id: s.session_id, cwd: s.cwd, pid: s.pid)
|
|
533
|
+
@queue << [:notice, "adopted as #{id}"]
|
|
534
|
+
@queue << [:select, id]
|
|
535
|
+
@queue << [:attach, id]
|
|
536
|
+
end
|
|
537
|
+
end
|
|
538
|
+
|
|
487
539
|
def delete_session(id)
|
|
488
540
|
notice("deleting #{id}…")
|
|
489
541
|
in_background do
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "shellwords"
|
|
4
|
+
|
|
5
|
+
module ClaudeInbox
|
|
6
|
+
# Arguments for every launch. They go before the typed ones, so a typed
|
|
7
|
+
# flag wins.
|
|
8
|
+
module Config
|
|
9
|
+
PATH = File.join(Dir.home, ".config", "claude-inbox", "config")
|
|
10
|
+
|
|
11
|
+
def self.argv(typed, path: PATH)
|
|
12
|
+
args(path) + typed
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def self.args(path)
|
|
16
|
+
File.readlines(path).flat_map { |line| Shellwords.split(line.sub(/(\A|\s)#.*/, "")) }
|
|
17
|
+
rescue Errno::ENOENT
|
|
18
|
+
[]
|
|
19
|
+
rescue ArgumentError => e
|
|
20
|
+
raise ArgumentError, "#{path}: #{e.message}"
|
|
21
|
+
end
|
|
22
|
+
private_class_method :args
|
|
23
|
+
end
|
|
24
|
+
end
|