claude-inbox 0.1.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 +7 -0
- data/LICENSE +21 -0
- data/README.md +442 -0
- data/exe/claude-inbox +33 -0
- data/lib/claude_inbox/agents_client.rb +224 -0
- data/lib/claude_inbox/app.rb +533 -0
- data/lib/claude_inbox/debug.rb +16 -0
- data/lib/claude_inbox/dialog.rb +105 -0
- data/lib/claude_inbox/images.rb +58 -0
- data/lib/claude_inbox/job_state.rb +89 -0
- data/lib/claude_inbox/keymap.rb +71 -0
- data/lib/claude_inbox/logs.rb +66 -0
- data/lib/claude_inbox/mouse.rb +34 -0
- data/lib/claude_inbox/new_session_form.rb +363 -0
- data/lib/claude_inbox/palette.rb +40 -0
- data/lib/claude_inbox/paste.rb +42 -0
- data/lib/claude_inbox/peek.rb +81 -0
- data/lib/claude_inbox/poller.rb +98 -0
- data/lib/claude_inbox/pull_requests.rb +155 -0
- data/lib/claude_inbox/rate_limits.rb +48 -0
- data/lib/claude_inbox/reaper.rb +103 -0
- data/lib/claude_inbox/records.rb +28 -0
- data/lib/claude_inbox/renderer.rb +447 -0
- data/lib/claude_inbox/session.rb +100 -0
- data/lib/claude_inbox/sessions.rb +18 -0
- data/lib/claude_inbox/settings.rb +39 -0
- data/lib/claude_inbox/slash_commands.rb +116 -0
- data/lib/claude_inbox/store/entry.rb +144 -0
- data/lib/claude_inbox/store/row.rb +101 -0
- data/lib/claude_inbox/store/sections.rb +47 -0
- data/lib/claude_inbox/store/selection.rb +16 -0
- data/lib/claude_inbox/store.rb +156 -0
- data/lib/claude_inbox/subprocess.rb +53 -0
- data/lib/claude_inbox/terminal.rb +101 -0
- data/lib/claude_inbox/text.rb +98 -0
- data/lib/claude_inbox/text_buffer.rb +216 -0
- data/lib/claude_inbox/theme.rb +35 -0
- data/lib/claude_inbox/vt_screen.rb +138 -0
- data/lib/claude_inbox.rb +14 -0
- metadata +163 -0
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require_relative "debug"
|
|
5
|
+
require_relative "subprocess"
|
|
6
|
+
|
|
7
|
+
module ClaudeInbox
|
|
8
|
+
# The only place that shells out to `claude`. Returns plain Ruby values.
|
|
9
|
+
# Swap in FixtureClient for tests.
|
|
10
|
+
class AgentsClient
|
|
11
|
+
class Error < StandardError; end
|
|
12
|
+
|
|
13
|
+
def initialize(bin: "claude")
|
|
14
|
+
@bin = bin
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# => Array<Session>
|
|
18
|
+
def list
|
|
19
|
+
args = [@bin, "agents", "--json", "--all"]
|
|
20
|
+
r = Subprocess.capture(*args)
|
|
21
|
+
raise Error, "claude agents failed: #{r.err.strip}" unless r.success?
|
|
22
|
+
classify_origins(parse(r.out))
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Raw terminal replay for a session, or nil when the daemon can't serve it
|
|
26
|
+
# (finished sessions whose process is gone report "job not found").
|
|
27
|
+
def logs(id)
|
|
28
|
+
r = Subprocess.capture(@bin, "logs", id)
|
|
29
|
+
(r.success? && !r.out.empty?) ? r.out : nil
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Poll interval for the agents-view watchdog below.
|
|
33
|
+
WATCH_INTERVAL = 0.05
|
|
34
|
+
|
|
35
|
+
# Hands the terminal to the child; caller must have restored cooked mode.
|
|
36
|
+
# Returns when the user detaches. Detaching never stops the session.
|
|
37
|
+
#
|
|
38
|
+
# Pressing ← inside an attached session detaches it and then `claude
|
|
39
|
+
# attach` execs itself in place as `claude agents` (same pid). There is
|
|
40
|
+
# no flag or env var that suppresses only that relaunch: the one switch
|
|
41
|
+
# that exists disables attach too. So we watch the child's command line
|
|
42
|
+
# and, the moment it becomes the agents view, terminate it. The user then
|
|
43
|
+
# lands back in the inbox instead of native agent view.
|
|
44
|
+
def attach(id)
|
|
45
|
+
pid = Process.spawn(@bin, "attach", id)
|
|
46
|
+
watchdog = Thread.new { kill_when_agents_view(pid) }
|
|
47
|
+
_, status = Process.wait2(pid)
|
|
48
|
+
status
|
|
49
|
+
ensure
|
|
50
|
+
watchdog&.kill
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def stop(id) = run(@bin, "stop", id)
|
|
54
|
+
|
|
55
|
+
def rm(id) = run(@bin, "rm", id)
|
|
56
|
+
|
|
57
|
+
MODELS = %w[default fable opus sonnet haiku].freeze
|
|
58
|
+
EFFORTS = %w[default low medium high xhigh max].freeze
|
|
59
|
+
PERMISSION_MODES = %w[default acceptEdits auto plan bypassPermissions].freeze
|
|
60
|
+
|
|
61
|
+
# Start a background session. Returns its short id.
|
|
62
|
+
def spawn(prompt:, cwd:, **opts)
|
|
63
|
+
argv = self.class.spawn_args(@bin, prompt: prompt, **opts)
|
|
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
|
+
end
|
|
68
|
+
|
|
69
|
+
# How a prompt attaches a file: the same @ mention the CLI's own prompt
|
|
70
|
+
# takes, which reads an image as an image. Spaces are escaped the way
|
|
71
|
+
# its path completion escapes them; a quoted path is not recognized.
|
|
72
|
+
def self.mention(path) = "@" + path.gsub(" ", "\\ ")
|
|
73
|
+
|
|
74
|
+
# Pure so it can be tested: "default" means leave the flag off.
|
|
75
|
+
def self.spawn_args(bin, prompt:, model: nil, effort: nil, permission_mode: nil, worktree: false, name: nil)
|
|
76
|
+
argv = [bin, "--bg", prompt]
|
|
77
|
+
argv += ["--model", model] if model && model != "default"
|
|
78
|
+
argv += ["--effort", effort] if effort && effort != "default"
|
|
79
|
+
argv += ["--permission-mode", permission_mode] if permission_mode && permission_mode != "default"
|
|
80
|
+
argv += ["--name", name] if name && !name.strip.empty?
|
|
81
|
+
argv << "--worktree" if worktree
|
|
82
|
+
argv
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def parse(json)
|
|
86
|
+
JSON.parse(json).map { |h| Session.from_hash(h) }
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Flags that mark a claude process as one a program drives rather than one
|
|
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
|
|
92
|
+
|
|
93
|
+
# The JSON reports Remote Control workers, local sub-agents and headless
|
|
94
|
+
# runs as `interactive`, same as a terminal you opened yourself, each named
|
|
95
|
+
# after its directory. The process tree tells them apart; see `origins`.
|
|
96
|
+
# Everything but a terminal and a remote worker is dropped here rather than
|
|
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))
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Tags each interactive session with where it is driven from (pid =>
|
|
106
|
+
# origin, terminal when unlisted) and drops the unattended ones, whose
|
|
107
|
+
# parent is the row worth showing.
|
|
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
|
|
113
|
+
|
|
114
|
+
# pid => origin, given `ps` for the sessions and for their parents. Pure.
|
|
115
|
+
#
|
|
116
|
+
# :remote a Remote Control worker: --sdk-url, or a `claude rc` parent
|
|
117
|
+
# :headless `claude -p "..."` or an SDK stream run. Its parent is
|
|
118
|
+
# whatever shell spawned it, not the claude that asked for
|
|
119
|
+
# it, so only its own command line gives it away
|
|
120
|
+
# :subagent parented by another claude process
|
|
121
|
+
# :terminal everything else, which is a claude you are sitting in
|
|
122
|
+
def self.origins(rows, parent_cmd)
|
|
123
|
+
rows.to_h do |pid, ppid, cmd|
|
|
124
|
+
parent = parent_cmd[ppid].to_s
|
|
125
|
+
origin =
|
|
126
|
+
if cmd.include?("--sdk-url") || parent.match?(/\bclaude rc\b/) then :remote
|
|
127
|
+
elsif headless?(cmd) then :headless
|
|
128
|
+
elsif parent.match?(/(^|\/)claude\b/) then :subagent
|
|
129
|
+
else :terminal
|
|
130
|
+
end
|
|
131
|
+
[pid, origin]
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# `ps` flattens quoting, so a prompt that mentions a flag reads the same
|
|
136
|
+
# as the flag itself. Nobody types `claude "what does -p do"` into a
|
|
137
|
+
# terminal often enough to matter; the false positive is accepted.
|
|
138
|
+
def self.headless?(cmd) = cmd.split.drop(1).any? { |arg| HEADLESS_FLAGS.include?(arg) }
|
|
139
|
+
|
|
140
|
+
def origins_by_pid(pids)
|
|
141
|
+
rows = ps_rows(pids)
|
|
142
|
+
return {} if rows.empty?
|
|
143
|
+
self.class.origins(rows, ps_commands(rows.map { |_, ppid, _| ppid }.uniq))
|
|
144
|
+
rescue Errno::ENOENT
|
|
145
|
+
{}
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def ps_rows(pids)
|
|
149
|
+
r = Subprocess.capture("ps", "-o", "pid=,ppid=,command=", "-p", pids.join(","))
|
|
150
|
+
return [] unless r.success?
|
|
151
|
+
r.out.lines.map { |l|
|
|
152
|
+
pid, ppid, *cmd = l.split
|
|
153
|
+
[pid.to_i, ppid.to_i, cmd.join(" ")]
|
|
154
|
+
}
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# pid => command, for the given parent pids.
|
|
158
|
+
def ps_commands(ppids)
|
|
159
|
+
return {} if ppids.empty?
|
|
160
|
+
r = Subprocess.capture("ps", "-o", "pid=,command=", "-p", ppids.join(","))
|
|
161
|
+
return {} unless r.success?
|
|
162
|
+
r.out.lines.to_h { |l|
|
|
163
|
+
pid, *cmd = l.split
|
|
164
|
+
[pid.to_i, cmd.join(" ")]
|
|
165
|
+
}
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
private
|
|
169
|
+
|
|
170
|
+
def kill_when_agents_view(pid)
|
|
171
|
+
loop do
|
|
172
|
+
sleep WATCH_INTERVAL
|
|
173
|
+
cmd = Subprocess.capture("ps", "-o", "command=", "-p", pid.to_s).out
|
|
174
|
+
break if cmd.empty?
|
|
175
|
+
next unless cmd.split[1] == "agents"
|
|
176
|
+
Debug.log("watchdog saw #{cmd.inspect}")
|
|
177
|
+
Process.kill("TERM", pid)
|
|
178
|
+
sleep 1
|
|
179
|
+
Process.kill("KILL", pid)
|
|
180
|
+
break
|
|
181
|
+
end
|
|
182
|
+
rescue Errno::ESRCH, Errno::ECHILD
|
|
183
|
+
nil
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def run(*argv)
|
|
187
|
+
r = Subprocess.capture(*argv)
|
|
188
|
+
raise Error, "#{argv[1]} failed: #{r.err.strip}" unless r.success?
|
|
189
|
+
true
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# Reads a committed JSON fixture instead of the daemon.
|
|
194
|
+
class FixtureClient < AgentsClient
|
|
195
|
+
def initialize(path, logs: nil, origins: {})
|
|
196
|
+
super()
|
|
197
|
+
@path = path
|
|
198
|
+
@logs = logs
|
|
199
|
+
@origins = origins
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# Fixture rows carry no process tree; tag each interactive row from the
|
|
203
|
+
# pid => origin map the test hands in, otherwise as a terminal, then drop
|
|
204
|
+
# the unattended ones same as the real client does.
|
|
205
|
+
def list
|
|
206
|
+
assign_origins(parse(File.read(@path)), @origins)
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def logs(_id) = @logs
|
|
210
|
+
|
|
211
|
+
# Stand-in child: prints, waits for a line, exits — enough to prove the
|
|
212
|
+
# terminal round-trips through cooked mode and back.
|
|
213
|
+
def attach(id) = system("sh", "-c", "printf 'fake attach to %s\\npress enter to detach: ' \"$1\"; read -r _", "attach", id)
|
|
214
|
+
|
|
215
|
+
def stop(_id) = true
|
|
216
|
+
|
|
217
|
+
def spawn(prompt:, cwd:, **)
|
|
218
|
+
sleep 0.5
|
|
219
|
+
"deadbeef"
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def rm(_id) = true
|
|
223
|
+
end
|
|
224
|
+
end
|