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.
Files changed (40) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +442 -0
  4. data/exe/claude-inbox +33 -0
  5. data/lib/claude_inbox/agents_client.rb +224 -0
  6. data/lib/claude_inbox/app.rb +533 -0
  7. data/lib/claude_inbox/debug.rb +16 -0
  8. data/lib/claude_inbox/dialog.rb +105 -0
  9. data/lib/claude_inbox/images.rb +58 -0
  10. data/lib/claude_inbox/job_state.rb +89 -0
  11. data/lib/claude_inbox/keymap.rb +71 -0
  12. data/lib/claude_inbox/logs.rb +66 -0
  13. data/lib/claude_inbox/mouse.rb +34 -0
  14. data/lib/claude_inbox/new_session_form.rb +363 -0
  15. data/lib/claude_inbox/palette.rb +40 -0
  16. data/lib/claude_inbox/paste.rb +42 -0
  17. data/lib/claude_inbox/peek.rb +81 -0
  18. data/lib/claude_inbox/poller.rb +98 -0
  19. data/lib/claude_inbox/pull_requests.rb +155 -0
  20. data/lib/claude_inbox/rate_limits.rb +48 -0
  21. data/lib/claude_inbox/reaper.rb +103 -0
  22. data/lib/claude_inbox/records.rb +28 -0
  23. data/lib/claude_inbox/renderer.rb +447 -0
  24. data/lib/claude_inbox/session.rb +100 -0
  25. data/lib/claude_inbox/sessions.rb +18 -0
  26. data/lib/claude_inbox/settings.rb +39 -0
  27. data/lib/claude_inbox/slash_commands.rb +116 -0
  28. data/lib/claude_inbox/store/entry.rb +144 -0
  29. data/lib/claude_inbox/store/row.rb +101 -0
  30. data/lib/claude_inbox/store/sections.rb +47 -0
  31. data/lib/claude_inbox/store/selection.rb +16 -0
  32. data/lib/claude_inbox/store.rb +156 -0
  33. data/lib/claude_inbox/subprocess.rb +53 -0
  34. data/lib/claude_inbox/terminal.rb +101 -0
  35. data/lib/claude_inbox/text.rb +98 -0
  36. data/lib/claude_inbox/text_buffer.rb +216 -0
  37. data/lib/claude_inbox/theme.rb +35 -0
  38. data/lib/claude_inbox/vt_screen.rb +138 -0
  39. data/lib/claude_inbox.rb +14 -0
  40. 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