claude-inbox 0.2.0 → 0.4.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 +107 -177
- data/exe/claude-inbox +67 -4
- data/lib/claude_inbox/actions.rb +91 -0
- data/lib/claude_inbox/agents_client.rb +68 -62
- data/lib/claude_inbox/app.rb +88 -38
- data/lib/claude_inbox/config.rb +24 -0
- data/lib/claude_inbox/dialog.rb +3 -1
- data/lib/claude_inbox/headless.rb +31 -0
- data/lib/claude_inbox/images.rb +37 -6
- data/lib/claude_inbox/job_state.rb +25 -10
- data/lib/claude_inbox/keymap.rb +1 -3
- data/lib/claude_inbox/mod.rb +71 -0
- data/lib/claude_inbox/new_session_form.rb +43 -52
- data/lib/claude_inbox/painter.rb +31 -0
- data/lib/claude_inbox/palette.rb +4 -7
- data/lib/claude_inbox/paste.rb +2 -0
- data/lib/claude_inbox/peek.rb +2 -2
- data/lib/claude_inbox/poller.rb +48 -24
- data/lib/claude_inbox/pull_requests.rb +36 -22
- data/lib/claude_inbox/rate_limits.rb +1 -3
- data/lib/claude_inbox/records.rb +7 -2
- data/lib/claude_inbox/remote/http.rb +140 -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 +435 -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 +201 -0
- data/lib/claude_inbox/remote.rb +10 -0
- data/lib/claude_inbox/renderer.rb +61 -61
- data/lib/claude_inbox/session.rb +7 -21
- data/lib/claude_inbox/session_request.rb +151 -0
- data/lib/claude_inbox/sessions.rb +0 -1
- data/lib/claude_inbox/settings.rb +20 -7
- data/lib/claude_inbox/slash_commands.rb +1 -0
- data/lib/claude_inbox/snapshot.rb +48 -0
- data/lib/claude_inbox/store/entry.rb +0 -2
- data/lib/claude_inbox/store/row.rb +7 -3
- data/lib/claude_inbox/store.rb +24 -11
- data/lib/claude_inbox/subprocess.rb +9 -1
- data/lib/claude_inbox/switch.rb +40 -0
- data/lib/claude_inbox/terminal.rb +9 -3
- data/lib/claude_inbox/text.rb +7 -1
- data/lib/claude_inbox/text_buffer.rb +84 -33
- data/lib/claude_inbox/trust.rb +23 -0
- data/lib/claude_inbox/vt_screen.rb +1 -1
- data/lib/claude_inbox/writer_lock.rb +65 -0
- data/lib/claude_inbox.rb +1 -10
- data/mod/.claude-plugin/plugin.json +20 -0
- data/mod/hooks/hooks.json +1 -0
- data/mod/hooks/register.tsx +470 -0
- data/mod/tests/contract.test.tsx +23 -0
- data/mod/tests/pane.test.tsx +199 -0
- data/mod/tests/snapshot.fixture.ts +47 -0
- data/mod/tests/snooze.test.tsx +31 -0
- data/mod/tsconfig.json +3 -0
- data/mod/types/index.d.ts +30 -0
- metadata +29 -1
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require "json"
|
|
4
4
|
require_relative "debug"
|
|
5
5
|
require_relative "job_state"
|
|
6
|
+
require_relative "session"
|
|
6
7
|
require_relative "subprocess"
|
|
7
8
|
|
|
8
9
|
module ClaudeInbox
|
|
@@ -45,9 +46,11 @@ module ClaudeInbox
|
|
|
45
46
|
# that exists disables attach too. So we watch the child's command line
|
|
46
47
|
# and, the moment it becomes the agents view, terminate it. The user then
|
|
47
48
|
# lands back in the inbox instead of native agent view.
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
49
|
+
# The block, polled with the command line, ends the attach early when it
|
|
50
|
+
# answers true: how a switch request from another front end is honoured.
|
|
51
|
+
def attach(id, &stop)
|
|
52
|
+
pid = Process.spawn(*Subprocess.command(@bin, "attach", id))
|
|
53
|
+
watchdog = Thread.new { kill_when_agents_view(pid, &stop) }
|
|
51
54
|
_, status = Process.wait2(pid)
|
|
52
55
|
status
|
|
53
56
|
ensure
|
|
@@ -64,10 +67,7 @@ module ClaudeInbox
|
|
|
64
67
|
|
|
65
68
|
# Start a background session. Returns its short id.
|
|
66
69
|
def spawn(prompt:, cwd:, **opts)
|
|
67
|
-
|
|
68
|
-
r = Subprocess.capture(*argv, chdir: cwd)
|
|
69
|
-
raise Error, "claude --bg failed: #{(r.err + r.out).strip}" unless r.success?
|
|
70
|
-
r.out[/\b[0-9a-f]{8}\b/] || r.out.strip
|
|
70
|
+
start_bg(self.class.spawn_args(@bin, prompt: prompt, **opts), cwd, "claude --bg")
|
|
71
71
|
end
|
|
72
72
|
|
|
73
73
|
# How a prompt attaches a file: the same @ mention the CLI's own prompt
|
|
@@ -75,18 +75,19 @@ module ClaudeInbox
|
|
|
75
75
|
# its path completion escapes them; a quoted path is not recognized.
|
|
76
76
|
def self.mention(path) = "@" + path.gsub(" ", "\\ ")
|
|
77
77
|
|
|
78
|
-
# Pure so it can be tested:
|
|
79
|
-
# --
|
|
80
|
-
#
|
|
78
|
+
# Pure so it can be tested: a setting left nil leaves its flag off. The
|
|
79
|
+
# prompt goes after "--", where a leading dash is text rather than an
|
|
80
|
+
# unknown option, and --remote-control sits just before it: its optional
|
|
81
|
+
# name would otherwise eat the prompt (both checked on 2.1.282).
|
|
81
82
|
def self.spawn_args(bin, prompt:, model: nil, effort: nil, permission_mode: nil, worktree: false, name: nil, remote: false)
|
|
82
|
-
argv = [bin, "--bg"
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
83
|
+
argv = [bin, "--bg"]
|
|
84
|
+
{"--model" => model, "--effort" => effort, "--permission-mode" => permission_mode}.each do |flag, value|
|
|
85
|
+
argv += [flag, value] if value
|
|
86
|
+
end
|
|
86
87
|
argv += ["--name", name] if name && !name.strip.empty?
|
|
87
88
|
argv << "--worktree" if worktree
|
|
88
89
|
argv << "--remote-control" if remote
|
|
89
|
-
argv
|
|
90
|
+
argv + ["--", prompt]
|
|
90
91
|
end
|
|
91
92
|
|
|
92
93
|
# Pull a conversation a `claude remote-control` server is serving into
|
|
@@ -98,9 +99,7 @@ module ClaudeInbox
|
|
|
98
99
|
raise Error, "nothing to adopt yet — send it a message from your phone first" if transcript_empty?(cwd, session_id)
|
|
99
100
|
Process.kill("TERM", pid)
|
|
100
101
|
wait_gone(pid)
|
|
101
|
-
|
|
102
|
-
raise Error, "claude --bg --resume failed: #{(r.err + r.out).strip}" unless r.success?
|
|
103
|
-
r.out[/\b[0-9a-f]{8}\b/] || r.out.strip
|
|
102
|
+
start_bg(self.class.adopt_args(@bin, session_id), cwd, "claude --bg --resume")
|
|
104
103
|
end
|
|
105
104
|
|
|
106
105
|
def self.adopt_args(bin, session_id) = [bin, "--bg", "--resume", session_id, "--remote-control"]
|
|
@@ -111,6 +110,45 @@ module ClaudeInbox
|
|
|
111
110
|
File.join(home, ".claude", "projects", cwd.gsub(%r{[/.]}, "-"), "#{session_id}.jsonl")
|
|
112
111
|
end
|
|
113
112
|
|
|
113
|
+
# Flags that mark a claude process as one a program drives rather than one
|
|
114
|
+
# you type in: the headless print mode and the SDK's stream protocol.
|
|
115
|
+
HEADLESS_FLAGS = %w[-p --print --input-format --output-format].freeze
|
|
116
|
+
|
|
117
|
+
# pid => origin, given `ps` for the sessions and for their parents. Pure.
|
|
118
|
+
#
|
|
119
|
+
# :remote a Remote Control worker: --sdk-url, or a `claude rc` parent
|
|
120
|
+
# :headless `claude -p "..."` or an SDK stream run. Its parent is
|
|
121
|
+
# whatever shell spawned it, not the claude that asked for
|
|
122
|
+
# it, so only its own command line gives it away
|
|
123
|
+
# :subagent parented by another claude process
|
|
124
|
+
# :terminal everything else, which is a claude you are sitting in
|
|
125
|
+
def self.origins(rows, parent_cmd)
|
|
126
|
+
rows.to_h do |pid, ppid, cmd|
|
|
127
|
+
parent = parent_cmd[ppid].to_s
|
|
128
|
+
origin =
|
|
129
|
+
if cmd.include?("--sdk-url") || parent.match?(/\bclaude rc\b/) then :remote
|
|
130
|
+
elsif headless?(cmd) then :headless
|
|
131
|
+
elsif parent.match?(/(^|\/)claude\b/) then :subagent
|
|
132
|
+
else :terminal
|
|
133
|
+
end
|
|
134
|
+
[pid, origin]
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# `ps` flattens quoting, so a prompt that mentions a flag reads the same
|
|
139
|
+
# as the flag itself. Nobody types `claude "what does -p do"` into a
|
|
140
|
+
# terminal often enough to matter; the false positive is accepted.
|
|
141
|
+
def self.headless?(cmd) = cmd.split.drop(1).any? { |arg| HEADLESS_FLAGS.include?(arg) }
|
|
142
|
+
|
|
143
|
+
# pid => bridge session id, for the workers a `claude remote-control`
|
|
144
|
+
# server spawned: each is started with `--session-id cse_…`, the same id
|
|
145
|
+
# a background session records as bridgeSessionId. Pure.
|
|
146
|
+
def self.bridge_ids(rows)
|
|
147
|
+
rows.filter_map { |pid, _, cmd| (m = cmd.match(/--session-id[= ](cse_\w+)/)) && [pid, m[1]] }.to_h
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
private
|
|
151
|
+
|
|
114
152
|
# A worker nobody has messaged yet has an empty file, and the daemon
|
|
115
153
|
# reports "source session not found" on resuming it.
|
|
116
154
|
def transcript_empty?(cwd, session_id)
|
|
@@ -133,10 +171,6 @@ module ClaudeInbox
|
|
|
133
171
|
JSON.parse(json).map { |h| Session.from_hash(h) }
|
|
134
172
|
end
|
|
135
173
|
|
|
136
|
-
# Flags that mark a claude process as one a program drives rather than one
|
|
137
|
-
# you type in: the headless print mode and the SDK's stream protocol.
|
|
138
|
-
HEADLESS_FLAGS = %w[-p --print --input-format --output-format].freeze
|
|
139
|
-
|
|
140
174
|
# The JSON reports Remote Control workers, local sub-agents and headless
|
|
141
175
|
# runs as `interactive`, same as a terminal you opened yourself, each named
|
|
142
176
|
# after its directory. The process tree tells them apart; see `origins`.
|
|
@@ -160,39 +194,6 @@ module ClaudeInbox
|
|
|
160
194
|
.reject(&:unattended?)
|
|
161
195
|
end
|
|
162
196
|
|
|
163
|
-
# pid => origin, given `ps` for the sessions and for their parents. Pure.
|
|
164
|
-
#
|
|
165
|
-
# :remote a Remote Control worker: --sdk-url, or a `claude rc` parent
|
|
166
|
-
# :headless `claude -p "..."` or an SDK stream run. Its parent is
|
|
167
|
-
# whatever shell spawned it, not the claude that asked for
|
|
168
|
-
# it, so only its own command line gives it away
|
|
169
|
-
# :subagent parented by another claude process
|
|
170
|
-
# :terminal everything else, which is a claude you are sitting in
|
|
171
|
-
def self.origins(rows, parent_cmd)
|
|
172
|
-
rows.to_h do |pid, ppid, cmd|
|
|
173
|
-
parent = parent_cmd[ppid].to_s
|
|
174
|
-
origin =
|
|
175
|
-
if cmd.include?("--sdk-url") || parent.match?(/\bclaude rc\b/) then :remote
|
|
176
|
-
elsif headless?(cmd) then :headless
|
|
177
|
-
elsif parent.match?(/(^|\/)claude\b/) then :subagent
|
|
178
|
-
else :terminal
|
|
179
|
-
end
|
|
180
|
-
[pid, origin]
|
|
181
|
-
end
|
|
182
|
-
end
|
|
183
|
-
|
|
184
|
-
# `ps` flattens quoting, so a prompt that mentions a flag reads the same
|
|
185
|
-
# as the flag itself. Nobody types `claude "what does -p do"` into a
|
|
186
|
-
# terminal often enough to matter; the false positive is accepted.
|
|
187
|
-
def self.headless?(cmd) = cmd.split.drop(1).any? { |arg| HEADLESS_FLAGS.include?(arg) }
|
|
188
|
-
|
|
189
|
-
# pid => bridge session id, for the workers a `claude remote-control`
|
|
190
|
-
# server spawned: each is started with `--session-id cse_…`, the same id
|
|
191
|
-
# a background session records as bridgeSessionId. Pure.
|
|
192
|
-
def self.bridge_ids(rows)
|
|
193
|
-
rows.filter_map { |pid, _, cmd| (m = cmd.match(/--session-id[= ](cse_\w+)/)) && [pid, m[1]] }.to_h
|
|
194
|
-
end
|
|
195
|
-
|
|
196
197
|
def origins_from(rows)
|
|
197
198
|
return {} if rows.empty?
|
|
198
199
|
self.class.origins(rows, ps_commands(rows.map { |_, ppid, _| ppid }.uniq))
|
|
@@ -205,8 +206,6 @@ module ClaudeInbox
|
|
|
205
206
|
pid, ppid, *cmd = l.split
|
|
206
207
|
[pid.to_i, ppid.to_i, cmd.join(" ")]
|
|
207
208
|
}
|
|
208
|
-
rescue Errno::ENOENT
|
|
209
|
-
[]
|
|
210
209
|
end
|
|
211
210
|
|
|
212
211
|
# pid => command, for the given parent pids.
|
|
@@ -220,17 +219,18 @@ module ClaudeInbox
|
|
|
220
219
|
}
|
|
221
220
|
end
|
|
222
221
|
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
def kill_when_agents_view(pid)
|
|
222
|
+
def kill_when_agents_view(pid, &stop)
|
|
226
223
|
loop do
|
|
227
224
|
sleep WATCH_INTERVAL
|
|
228
225
|
cmd = Subprocess.capture("ps", "-o", "command=", "-p", pid.to_s).out
|
|
229
226
|
break if cmd.empty?
|
|
230
|
-
next unless cmd.split[1] == "agents"
|
|
227
|
+
next unless cmd.split[1] == "agents" || stop&.call
|
|
231
228
|
Debug.log("watchdog saw #{cmd.inspect}")
|
|
232
229
|
Process.kill("TERM", pid)
|
|
233
|
-
|
|
230
|
+
10.times do
|
|
231
|
+
sleep 0.1
|
|
232
|
+
break if Subprocess.capture("ps", "-o", "pid=", "-p", pid.to_s).out.empty?
|
|
233
|
+
end
|
|
234
234
|
Process.kill("KILL", pid)
|
|
235
235
|
break
|
|
236
236
|
end
|
|
@@ -243,6 +243,12 @@ module ClaudeInbox
|
|
|
243
243
|
raise Error, "#{argv[1]} failed: #{r.err.strip}" unless r.success?
|
|
244
244
|
true
|
|
245
245
|
end
|
|
246
|
+
|
|
247
|
+
def start_bg(argv, cwd, what)
|
|
248
|
+
r = Subprocess.capture(*argv, chdir: cwd)
|
|
249
|
+
raise Error, "#{what} failed: #{(r.err + r.out).strip}" unless r.success?
|
|
250
|
+
r.out[/\b[0-9a-f]{8}\b/] || r.out.strip
|
|
251
|
+
end
|
|
246
252
|
end
|
|
247
253
|
|
|
248
254
|
# Reads a committed JSON fixture instead of the daemon.
|
data/lib/claude_inbox/app.rb
CHANGED
|
@@ -11,33 +11,43 @@ 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"
|
|
23
|
+
require_relative "actions"
|
|
24
|
+
require_relative "snapshot"
|
|
25
|
+
require_relative "switch"
|
|
20
26
|
|
|
21
27
|
module ClaudeInbox
|
|
22
28
|
# Owns the terminal and the key loop. The only class allowed to spawn a
|
|
23
29
|
# child process that takes over the terminal.
|
|
24
30
|
class App
|
|
25
|
-
# The reaper
|
|
26
|
-
#
|
|
27
|
-
# reaches it by forgetting an argument.
|
|
31
|
+
# The reaper deletes sessions and the listener lets other machines in,
|
|
32
|
+
# so both are off unless `bin/claude-inbox` switches them on.
|
|
28
33
|
def initialize(client: AgentsClient.new, store: Store.new, pull_requests: PullRequests.new,
|
|
29
|
-
rate_limits: RateLimits.new, reaper: Reaper.disabled,
|
|
30
|
-
|
|
34
|
+
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)
|
|
31
37
|
@client = client
|
|
32
38
|
@store = store
|
|
39
|
+
@snapshot = snapshot
|
|
40
|
+
@switch = switch
|
|
33
41
|
@rate_limits = rate_limits
|
|
34
42
|
@terminal = terminal
|
|
35
43
|
@color = color
|
|
36
44
|
@renderer = Renderer.new(color: color)
|
|
45
|
+
@input = input
|
|
37
46
|
@reader = TTY::Reader.new(input: input, output: out, interrupt: :noop)
|
|
38
|
-
@queue =
|
|
47
|
+
@queue = queue
|
|
39
48
|
@poller = Poller.new(client: client, store: store, pull_requests: pull_requests,
|
|
40
|
-
reaper: reaper, queue: @queue)
|
|
49
|
+
reaper: reaper, queue: @queue, snapshot: snapshot, actions: actions)
|
|
50
|
+
@listener = listen ? Remote::Listener.new(client: client, store: store, queue: @queue, **listen) : Remote::Listener.disabled
|
|
41
51
|
@logs = Logs.new(client)
|
|
42
52
|
@peek = Peek.new(@logs)
|
|
43
53
|
@selected = nil
|
|
@@ -60,12 +70,14 @@ module ClaudeInbox
|
|
|
60
70
|
install_traps
|
|
61
71
|
@terminal.enter
|
|
62
72
|
@poller.start
|
|
73
|
+
@listener.start
|
|
63
74
|
@logs.start
|
|
64
75
|
main_loop
|
|
65
76
|
ensure
|
|
66
77
|
@poller.stop
|
|
67
78
|
@logs.stop
|
|
68
79
|
@terminal.restore
|
|
80
|
+
@listener.stop
|
|
69
81
|
end
|
|
70
82
|
|
|
71
83
|
def step(input = nil)
|
|
@@ -75,8 +87,10 @@ module ClaudeInbox
|
|
|
75
87
|
@resize = false
|
|
76
88
|
@terminal.resized
|
|
77
89
|
end
|
|
78
|
-
render
|
|
79
90
|
handle_input(input) if input
|
|
91
|
+
# A paste arrives a character per read and shows nothing until it
|
|
92
|
+
# closes, so a frame per character would only stall a long one.
|
|
93
|
+
render unless @paste.pasting?
|
|
80
94
|
end
|
|
81
95
|
|
|
82
96
|
private
|
|
@@ -104,7 +118,6 @@ module ClaudeInbox
|
|
|
104
118
|
kind, *rest = @queue.pop(true)
|
|
105
119
|
case kind
|
|
106
120
|
when :sessions
|
|
107
|
-
@store.update(rest[0])
|
|
108
121
|
@last_poll = Time.now
|
|
109
122
|
@error = nil
|
|
110
123
|
when :error then @error = rest[0]
|
|
@@ -113,6 +126,7 @@ module ClaudeInbox
|
|
|
113
126
|
when :attach then attach(rest[0])
|
|
114
127
|
when :form_started then @modal = nil if @modal.equal?(rest[0])
|
|
115
128
|
when :form_failed then form_failed(*rest)
|
|
129
|
+
when :remote_started then remote_started(*rest)
|
|
116
130
|
end
|
|
117
131
|
end
|
|
118
132
|
rescue ThreadError
|
|
@@ -122,7 +136,19 @@ module ClaudeInbox
|
|
|
122
136
|
# ----- main loop --------------------------------------------------------
|
|
123
137
|
|
|
124
138
|
def main_loop
|
|
125
|
-
step(@reader.read_keypress(echo: false, raw: false, nonblock: true)) until @quit
|
|
139
|
+
step(@paste.pasting? ? read_pasted : @reader.read_keypress(echo: false, raw: false, nonblock: true)) until @quit
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# As much of an open paste as has arrived, read straight off the tty:
|
|
143
|
+
# the reader switches echo off and back on around every character, and
|
|
144
|
+
# on a long paste that is most of the wait.
|
|
145
|
+
def read_pasted
|
|
146
|
+
return nil unless @input.wait_readable(0.1)
|
|
147
|
+
out = +""
|
|
148
|
+
while out.size < 65_536 && @input.wait_readable(0) && (c = @input.getc)
|
|
149
|
+
out << c
|
|
150
|
+
end
|
|
151
|
+
out
|
|
126
152
|
end
|
|
127
153
|
|
|
128
154
|
def handle_input(raw)
|
|
@@ -135,24 +161,30 @@ module ClaudeInbox
|
|
|
135
161
|
end
|
|
136
162
|
|
|
137
163
|
# The form takes a paste whole, images included; the one-line editors
|
|
138
|
-
# take it as typing, so a pasted PR URL lands where it should.
|
|
164
|
+
# take it as typing, so a pasted PR URL lands where it should. Anywhere
|
|
165
|
+
# else a paste is dropped: typed out, its letters would be keys, and a
|
|
166
|
+
# "y" answers a confirm.
|
|
139
167
|
def handle_paste(text)
|
|
140
168
|
return @modal.paste(text) if @modal.is_a?(NewSessionForm)
|
|
141
|
-
|
|
169
|
+
typing = @modal ? @modal.is_a?(Dialog::Prompt) : @filter_editing
|
|
170
|
+
text.each_char { |c| handle_key(c) } if typing
|
|
142
171
|
end
|
|
143
172
|
|
|
144
173
|
def render
|
|
145
174
|
t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
146
175
|
now = Time.now
|
|
147
|
-
|
|
176
|
+
all = @store.sections(now)
|
|
177
|
+
@snapshot.write(all, now)
|
|
178
|
+
sections = filtered(all)
|
|
148
179
|
width, height = @terminal.size
|
|
180
|
+
body_h = Renderer.body_height(height)
|
|
149
181
|
ensure_selection(sections)
|
|
150
182
|
@tick += 1
|
|
151
183
|
view = Renderer::View.new(
|
|
152
184
|
width: width, height: height, now: now, selected: @selected&.key, top: @top, expanded: @expanded,
|
|
153
|
-
peek: @peek.view(sections.row(@selected),
|
|
185
|
+
peek: @peek.view(sections.row(@selected), body_h), modal: modal_lines(width), screen: screen_lines(width, body_h),
|
|
154
186
|
status: status_text(now), usage: @rate_limits.windows(now), filter: @filter, filter_editing: @filter_editing,
|
|
155
|
-
tick: @tick / 2, loading: loading_for
|
|
187
|
+
tick: @tick / 2, loading: loading_for, listening: @listener.snapshot
|
|
156
188
|
)
|
|
157
189
|
frame = @renderer.frame(sections, view)
|
|
158
190
|
@row_items = frame.items
|
|
@@ -202,8 +234,9 @@ module ClaudeInbox
|
|
|
202
234
|
|
|
203
235
|
# Guard for attach/stop: refuse politely on a terminal or remote row.
|
|
204
236
|
def require_actionable
|
|
205
|
-
|
|
206
|
-
if
|
|
237
|
+
s = selected_session
|
|
238
|
+
return true if s&.actionable?
|
|
239
|
+
if s&.interactive?
|
|
207
240
|
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")
|
|
208
241
|
end
|
|
209
242
|
false
|
|
@@ -212,8 +245,16 @@ module ClaudeInbox
|
|
|
212
245
|
# Guard for snooze/wake/alias: anything with a key, since those live in
|
|
213
246
|
# our own store. A terminal you are sitting in is the one exception.
|
|
214
247
|
def require_storable
|
|
215
|
-
|
|
216
|
-
|
|
248
|
+
s = selected_session
|
|
249
|
+
return true if @selected&.row? && !s&.terminal?
|
|
250
|
+
notice("you're in that terminal right now — nothing to snooze") if s&.terminal?
|
|
251
|
+
false
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
# The edits with no request form, which a second inbox cannot keep.
|
|
255
|
+
def require_writable(verb)
|
|
256
|
+
return true if @store.writable?
|
|
257
|
+
notice("another claude-inbox owns state.json — #{verb} there")
|
|
217
258
|
false
|
|
218
259
|
end
|
|
219
260
|
|
|
@@ -308,12 +349,13 @@ module ClaudeInbox
|
|
|
308
349
|
when :refresh then @poller.soon
|
|
309
350
|
when :toggle_peek then toggle_peek
|
|
310
351
|
when :new_session then open_new_session
|
|
352
|
+
when :remote_pairing then @modal = @listener.pairing_dialog
|
|
311
353
|
when :filter then start_filter
|
|
312
354
|
when :escape then clear_filter
|
|
313
355
|
end
|
|
314
356
|
end
|
|
315
357
|
|
|
316
|
-
def page = [@terminal.size[1]
|
|
358
|
+
def page = [Renderer.body_height(@terminal.size[1]), 1].max
|
|
317
359
|
|
|
318
360
|
def move(delta)
|
|
319
361
|
stops = filtered.selections(@expanded)
|
|
@@ -338,7 +380,8 @@ module ClaudeInbox
|
|
|
338
380
|
name if Store::FOLDABLE_SECTIONS.include?(name)
|
|
339
381
|
end
|
|
340
382
|
|
|
341
|
-
def set_expanded(value
|
|
383
|
+
def set_expanded(value)
|
|
384
|
+
name = current_fold_section
|
|
342
385
|
@expanded[name] = value if name
|
|
343
386
|
end
|
|
344
387
|
|
|
@@ -375,12 +418,17 @@ module ClaudeInbox
|
|
|
375
418
|
notice("settled — u brings it back")
|
|
376
419
|
end
|
|
377
420
|
|
|
421
|
+
# The pane in the attached session may ask for a different one: the
|
|
422
|
+
# attach ends and the next starts without passing through the list. The
|
|
423
|
+
# poller keeps running meanwhile, so the pane's snapshot stays fresh.
|
|
378
424
|
def attach(id)
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
425
|
+
while id
|
|
426
|
+
@store.acknowledge(id)
|
|
427
|
+
@switch.clear
|
|
428
|
+
from = @store.sessions.find { |s| s.key == id }&.session_id
|
|
429
|
+
@terminal.release { @client.attach(id) { @switch.requested_for?(from) } }
|
|
430
|
+
id = @switch.take(@store.sessions.select(&:actionable?).map(&:key))
|
|
431
|
+
end
|
|
384
432
|
end
|
|
385
433
|
|
|
386
434
|
# ----- modals -----------------------------------------------------------
|
|
@@ -396,13 +444,13 @@ module ClaudeInbox
|
|
|
396
444
|
end
|
|
397
445
|
|
|
398
446
|
def open_alias_editor
|
|
399
|
-
return unless require_storable
|
|
447
|
+
return unless require_storable && require_writable("rename")
|
|
400
448
|
current = @store.alias_for(@selected.key) || ""
|
|
401
449
|
@modal = Dialog::Prompt.new(:alias, @selected.key, current)
|
|
402
450
|
end
|
|
403
451
|
|
|
404
452
|
def open_pr_editor
|
|
405
|
-
return unless require_storable
|
|
453
|
+
return unless require_storable && require_writable("link a pull request")
|
|
406
454
|
current = @store.pr_for(@selected.key) || selected_session&.pr&.url || ""
|
|
407
455
|
@modal = Dialog::Prompt.new(:pr, @selected.key, current)
|
|
408
456
|
end
|
|
@@ -427,14 +475,7 @@ module ClaudeInbox
|
|
|
427
475
|
|
|
428
476
|
def open_new_session
|
|
429
477
|
cwd = selected_session&.cwd || Dir.pwd
|
|
430
|
-
@modal = NewSessionForm.new(cwd: strip_worktree(cwd), pastel: Pastel.new(enabled: @color))
|
|
431
|
-
end
|
|
432
|
-
|
|
433
|
-
# A session's cwd may sit inside a worktree another agent is using; carrying
|
|
434
|
-
# that into a new prompt would spawn the new agent there too, writing over
|
|
435
|
-
# the same files. Fall back to the repo the worktree was cut from.
|
|
436
|
-
def strip_worktree(cwd)
|
|
437
|
-
cwd.to_s.sub(%r{/\.claude/worktrees/[^/]+(?:/.*)?\z}, "")
|
|
478
|
+
@modal = NewSessionForm.new(cwd: SessionRequest.strip_worktree(cwd), pastel: Pastel.new(enabled: @color))
|
|
438
479
|
end
|
|
439
480
|
|
|
440
481
|
# `attach:` hands the terminal over as soon as the session starts. Without
|
|
@@ -454,6 +495,13 @@ module ClaudeInbox
|
|
|
454
495
|
end
|
|
455
496
|
end
|
|
456
497
|
|
|
498
|
+
# Someone at the desk may be mid-thought, so a start from another
|
|
499
|
+
# device never moves the cursor, attaches or closes what is open.
|
|
500
|
+
def remote_started(id, via)
|
|
501
|
+
notice("started #{id} from #{via}")
|
|
502
|
+
@poller.soon
|
|
503
|
+
end
|
|
504
|
+
|
|
457
505
|
# A spawn failure hands the form back rather than just logging it, so
|
|
458
506
|
# the composed prompt survives (see NewSessionForm#submission_failed).
|
|
459
507
|
def form_failed(form, message)
|
|
@@ -461,9 +509,9 @@ module ClaudeInbox
|
|
|
461
509
|
end
|
|
462
510
|
|
|
463
511
|
# The new-session form takes the whole body; a Dialog is a box over it.
|
|
464
|
-
def screen_lines(width,
|
|
512
|
+
def screen_lines(width, body_h)
|
|
465
513
|
return nil unless @modal.is_a?(NewSessionForm)
|
|
466
|
-
{lines: @modal.screen(width,
|
|
514
|
+
{lines: @modal.screen(width, body_h), footer: @modal.footer}
|
|
467
515
|
end
|
|
468
516
|
|
|
469
517
|
def modal_lines(width)
|
|
@@ -486,6 +534,8 @@ module ClaudeInbox
|
|
|
486
534
|
when :adopt then adopt_session(id)
|
|
487
535
|
end
|
|
488
536
|
when :save then save_prompt
|
|
537
|
+
when :copy then @listener.copy_pairing_url
|
|
538
|
+
when :rotate then @listener.rotate
|
|
489
539
|
end
|
|
490
540
|
end
|
|
491
541
|
|
|
@@ -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
|
data/lib/claude_inbox/dialog.rb
CHANGED
|
@@ -10,6 +10,8 @@ module ClaudeInbox
|
|
|
10
10
|
# the details read off the dialog afterwards. `frame(width)` is the box
|
|
11
11
|
# Renderer overlays. Pure: nothing here touches the store or the client.
|
|
12
12
|
class Dialog
|
|
13
|
+
WIDTH = 44
|
|
14
|
+
|
|
13
15
|
attr_reader :kind, :id
|
|
14
16
|
|
|
15
17
|
def initialize(kind, id)
|
|
@@ -18,7 +20,7 @@ module ClaudeInbox
|
|
|
18
20
|
end
|
|
19
21
|
|
|
20
22
|
def frame(width, caret = nil)
|
|
21
|
-
box = [width - 4,
|
|
23
|
+
box = [width - 4, self.class::WIDTH].min
|
|
22
24
|
TTY::Box.frame(lines(box - 4, caret).join("\n"), title: {top_left: title}, padding: [0, 1], width: box)
|
|
23
25
|
.split("\n")
|
|
24
26
|
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "actions"
|
|
4
|
+
require_relative "poller"
|
|
5
|
+
require_relative "pull_requests"
|
|
6
|
+
require_relative "reaper"
|
|
7
|
+
require_relative "snapshot"
|
|
8
|
+
require_relative "writer_lock"
|
|
9
|
+
|
|
10
|
+
module ClaudeInbox
|
|
11
|
+
# The poller with no terminal, for a front end that only reads the
|
|
12
|
+
# snapshot: it polls, writes the file and applies the requests. It never
|
|
13
|
+
# reaps, since deleting sessions stays with the inbox you can see, and it
|
|
14
|
+
# exits at once while an inbox holds the writer lock.
|
|
15
|
+
class Headless
|
|
16
|
+
def initialize(client:, store:, pull_requests: PullRequests.new, snapshot: Snapshot.new, actions: Actions.new,
|
|
17
|
+
lock: WriterLock.new, queue: Queue.new)
|
|
18
|
+
@lock = lock
|
|
19
|
+
@queue = queue
|
|
20
|
+
@poller = Poller.new(client: client, store: store, pull_requests: pull_requests, reaper: Reaper.disabled,
|
|
21
|
+
queue: queue, snapshot: snapshot, actions: actions)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# False when the lock is held, by an inbox or another headless process.
|
|
25
|
+
def run
|
|
26
|
+
return false unless @lock.take(role: "headless")
|
|
27
|
+
@poller.start
|
|
28
|
+
loop { @queue.pop }
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
data/lib/claude_inbox/images.rb
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "fileutils"
|
|
4
|
+
require "securerandom"
|
|
4
5
|
require_relative "subprocess"
|
|
5
6
|
|
|
6
7
|
module ClaudeInbox
|
|
7
|
-
# Images a prompt can carry: a dropped file,
|
|
8
|
-
# clipboard.
|
|
8
|
+
# Images a prompt can carry: a dropped file, whatever is on the
|
|
9
|
+
# clipboard, or the bytes a request sent. Each comes back as a path for
|
|
10
|
+
# the prompt to mention.
|
|
9
11
|
module Images
|
|
10
12
|
DEFAULT_DIR = File.join(Dir.home, ".config", "claude-inbox", "images")
|
|
11
13
|
EXTENSIONS = %w[.png .jpg .jpeg .gif .webp .bmp .svg].freeze
|
|
@@ -14,9 +16,19 @@ module ClaudeInbox
|
|
|
14
16
|
|
|
15
17
|
Clipboard = Struct.new(:image, :text)
|
|
16
18
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
#
|
|
19
|
+
class Unsupported < StandardError; end
|
|
20
|
+
|
|
21
|
+
# Bytes from a request carry no file name worth trusting, so the type
|
|
22
|
+
# comes from the magic number at the front.
|
|
23
|
+
MAGIC = {
|
|
24
|
+
".png" => /\A\x89PNG/n,
|
|
25
|
+
".jpg" => /\A\xFF\xD8\xFF/n,
|
|
26
|
+
".gif" => /\AGIF8/n,
|
|
27
|
+
".webp" => /\ARIFF.{4}WEBP/mn
|
|
28
|
+
}.freeze
|
|
29
|
+
|
|
30
|
+
# macOS only: osascript ships with the OS, and PNG is the flavor a
|
|
31
|
+
# screenshot puts there.
|
|
20
32
|
def self.from_clipboard(dir: DEFAULT_DIR, now: Time.now, run: Subprocess.method(:capture))
|
|
21
33
|
FileUtils.mkdir_p(dir)
|
|
22
34
|
prune(dir, now)
|
|
@@ -26,6 +38,23 @@ module ClaudeInbox
|
|
|
26
38
|
Clipboard.new(nil, (text.success? && !text.out.empty?) ? text.out : nil)
|
|
27
39
|
end
|
|
28
40
|
|
|
41
|
+
# `index` orders the images one request brings; the random part keeps
|
|
42
|
+
# two requests saving in the same millisecond apart. Readable by the
|
|
43
|
+
# owner only: a photo can be anything.
|
|
44
|
+
def self.save(bytes, dir: DEFAULT_DIR, now: Time.now, index: 1)
|
|
45
|
+
data = bytes.b
|
|
46
|
+
ext = extension(data)
|
|
47
|
+
raise Unsupported, "not a PNG, JPEG, GIF or WebP image" unless ext
|
|
48
|
+
FileUtils.mkdir_p(dir)
|
|
49
|
+
prune(dir, now)
|
|
50
|
+
path = File.join(dir, "#{now.strftime("%Y%m%d-%H%M%S-%L")}-#{index}-#{SecureRandom.hex(3)}#{ext}")
|
|
51
|
+
File.open(path, File::WRONLY | File::CREAT | File::EXCL | File::BINARY, 0o600) { |f| f.write(data) }
|
|
52
|
+
path
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# ".png", ".jpg", ".gif" or ".webp" by the magic number, or nil.
|
|
56
|
+
def self.extension(bytes) = MAGIC.find { |_, magic| magic.match?(bytes.b) }&.first
|
|
57
|
+
|
|
29
58
|
CLIPBOARD_PNG = <<~APPLESCRIPT
|
|
30
59
|
on run argv
|
|
31
60
|
set png to the clipboard as «class PNGf»
|
|
@@ -46,8 +75,10 @@ module ClaudeInbox
|
|
|
46
75
|
File.file?(path) ? path : nil
|
|
47
76
|
end
|
|
48
77
|
|
|
78
|
+
# Pruned on the way in, because nothing else knows when a session
|
|
79
|
+
# stopped needing its image.
|
|
49
80
|
def self.prune(dir, now)
|
|
50
|
-
Dir.glob(File.join(dir, "
|
|
81
|
+
Dir.glob(EXTENSIONS.map { |ext| File.join(dir, "*#{ext}") }).each do |f|
|
|
51
82
|
File.delete(f) if now - File.mtime(f) > KEEP_FOR
|
|
52
83
|
rescue SystemCallError
|
|
53
84
|
nil
|