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
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: c424c9f33451df3570e8784a053b26686eae4bd9af70d8794548be17f534dab1
4
+ data.tar.gz: 7644408160f6f33a79e18f4ebc62aec2220884a95ac8985dac21a62ee54363cb
5
+ SHA512:
6
+ metadata.gz: 6c4e7d3dcd27bcc509f5d8bb70d5ffc88ceaf697dcb6a6a2423f86893feea151f56a0ae55c4c2182a1cb4c5df6715dc3ab603fbade98f1f764f076f3beaf7d82
7
+ data.tar.gz: 59c64367da3aa81786b6344c790c3ba67adc9881823c99fd619626e4ae8f9594a1cd2c0140ce54d88523d820780c81fa287f6c716b4562e710f587f69278afd7
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jordan Byron
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,442 @@
1
+ # claude-inbox
2
+
3
+ Inbox-style triage on top of Claude Code's background sessions. A companion to
4
+ `claude agents`, not a replacement: it reads the same daemon state through
5
+ `claude agents --json` and adds **snooze**, **auto-settle** and the session's
6
+ **pull request**.
7
+
8
+ ![claude-inbox with one working session, one snoozed and the settled section folded](docs/screenshot.png)
9
+
10
+ ## Install
11
+
12
+ ```
13
+ gem install claude-inbox
14
+ claude-inbox
15
+ ```
16
+
17
+ Requires Ruby 3.2+ and a `claude` on PATH with the agents feature. Versions
18
+ are published to rubygems and listed under GitHub Releases.
19
+ `claude-inbox --version` prints the installed version.
20
+
21
+ ## Sections
22
+
23
+ 1. **Pinned.** Parked at the top by hand, regardless of state. `P` toggles it.
24
+ 2. **Needs you.** `blocked` or `failed`, unless you've attached to it since (see
25
+ Acknowledge below).
26
+ 3. **Active.** `working`, plus recently finished sessions that have not settled
27
+ yet, plus interactive sessions, plus a needs-you session you've attached to
28
+ but that hasn't resolved. Interactive sessions report only `status`, so it
29
+ gets mapped: busy is working, idle is done, waiting needs you. The JSON calls
30
+ a terminal you opened yourself, a Remote Control worker, a sub-agent and a
31
+ headless `claude -p` run all "interactive", each named after its directory;
32
+ the client tells them apart from the process tree (a remote worker runs with
33
+ `--sdk-url` under a `claude rc` parent, a sub-agent has a `claude` for a
34
+ parent, and a headless run gives itself away with `-p` or the SDK's stream
35
+ flags, since the shell that spawned it hides the session behind it).
36
+ Sub-agents and headless runs are dropped: nobody is sitting in them and
37
+ attach lands on whatever asked for them. Remote sessions settle and snooze
38
+ like any other row. A terminal you are sitting in never settles. Neither
39
+ can be attached, peeked or stopped from outside; you can land on them, and
40
+ Enter tells you why nothing happens.
41
+ 4. **Snoozed.** Sorted by wake time, parked ("until I wake it") entries last.
42
+ Collapsed; Enter expands.
43
+ 5. **Settled.** Parked with `x`, or whose pull request is merged or closed.
44
+ Collapsed; Enter expands.
45
+
46
+ A row with a pull request shows it after the state: `#885 open`, `#885 draft`,
47
+ `#885 merged`, `#885 closed`. `o` opens it in the browser.
48
+
49
+ `working` from the daemon covers two situations, and the row says which. A
50
+ spinner and `working` mean the agent is thinking. A steady `◌` and `idle · 1
51
+ shell` mean the agent has stopped and is only waiting on work it started, such as
52
+ a `--watch` shell or a sub-agent. That is why a session with nothing left to do
53
+ can sit there for an hour. Whatever the agent is up to, the open work is named
54
+ next to the state: `working · 2 agents`, `idle · 1 shell`. The count comes from
55
+ the session's own job file, which `claude agents --json` does not expose; the
56
+ inbox still takes the state itself from the daemon, the only thing that knows
57
+ whether a session is alive.
58
+
59
+ Under each row in Pinned, Needs You and Active is the session's own line,
60
+ the one `claude agents` prints too: what it is waiting on while blocked
61
+ (`↳ confirm: drop the top line?`), what it produced once done, otherwise its
62
+ status line. It comes from the job file, like the open work above, so a
63
+ terminal you opened yourself has none and shows its directory instead.
64
+
65
+ A session you gave a color to with `/color` wears it on the label — see
66
+ [Colors](#colors).
67
+
68
+ ## Keys
69
+
70
+ Keyboard-first, vim flavoured, but the mouse works too. Arrows work as well
71
+ as `j`/`k`. Clicking a row does what landing on it and pressing `Enter`
72
+ would: selects it and attaches (or expands a fold, or refuses on a terminal
73
+ row you can't be attached to from here). The wheel moves the selection
74
+ rather than uncovering the scrollback behind us, in terminals that report
75
+ mouse events (`\e[?1000h` + `\e[?1006h`, the same mode clicks use) or that at
76
+ least support alternate scroll mode (`\e[?1007h`, wheel-as-arrows only, no
77
+ clicks). Apple Terminal supports neither escape, so there the wheel scrolls
78
+ its own history and clicking a row does nothing.
79
+
80
+ | Key | Action |
81
+ |---|---|
82
+ | `j` `k` | move down / up |
83
+ | `gg` `G` | first / last row |
84
+ | `Ctrl-d` `Ctrl-u` | half page down / up (`Ctrl-f` `Ctrl-b` full page) |
85
+ | `Enter` `l` | attach (full-screen handoff; `←` or `Ctrl+Z` return here), or expand Snoozed / Settled |
86
+ | `Tab` `Shift+Tab` | jump to the next / previous section |
87
+ | `h` | close the peek pane, else collapse the current Snoozed / Settled fold |
88
+ | `za` `zo` `zc` | toggle / open / close the Snoozed or Settled fold under the cursor |
89
+ | `p` | toggle the read-only peek pane |
90
+ | `J` `K` (`Ctrl-e` `Ctrl-y`) | scroll the peek pane |
91
+ | `n` | new session (full screen): multi-line prompt (`Enter` breaks a line, `Cmd-V` or `Ctrl-V` pastes an image, `Ctrl-S` starts it, `Ctrl-O` starts and opens it), name, directory (`Tab` completes), model, effort, permissions, worktree; "default" choices show what your settings resolve to |
92
+ | `t` | pin / unpin (parks it in Pinned at the top, regardless of state) |
93
+ | `s` | snooze: `1` 15m · `2` 1h · `3` tomorrow 9am · `4` until woken |
94
+ | `u` | wake a snoozed session now, or bring back one you settled |
95
+ | `x` | settle a working or needs-you session by hand (returns when it changes state, unless it just finished) |
96
+ | `a` | set a local alias (never touches the real session name) |
97
+ | `o` | open the session's pull request in the browser |
98
+ | `P` | link a pull request by hand (empty clears; the scanned links return) |
99
+ | `X` | stop the session (`y` to confirm) |
100
+ | `Ctrl-x` | delete the session for good, conversation and worktree with it (`y` to confirm) |
101
+ | `/` | filter by name or cwd; `Enter` keeps it, `Esc` clears |
102
+ | `R` | poll now |
103
+ | `q` | quit |
104
+
105
+ Bindings live in `ClaudeInbox::Keymap`, a pure resolver with chord support
106
+ that is unit tested on its own. A fast `Esc` followed by a chord key is split
107
+ back into two keys, since tty-reader would otherwise glue them together.
108
+
109
+ `X` and `Ctrl-x` both ask before they act, and both take `y`, but they are not
110
+ the same thing. `X` runs `claude stop`: the process ends, the conversation is
111
+ kept, and `Enter` resumes it later. `Ctrl-x` runs `claude rm`. The session,
112
+ its transcript and its worktree all go, and so does our own state entry for
113
+ it, rather than sitting out the seven-day prune. Nothing resumes afterwards,
114
+ so read the box before answering.
115
+
116
+ ### New session
117
+
118
+ `n` opens a form. `Tab` / `Shift+Tab` move between fields, `Enter` moves on
119
+ (inside the prompt it breaks a line), `Esc` cancels. Choice fields cycle with
120
+ `h` `l` or the arrows. Text fields are a real editor: `←` `→` move the cursor,
121
+ `Ctrl-A` / `Ctrl-E` jump to the ends, `Backspace` and `Delete` cut either side
122
+ of it, `Ctrl-W` takes the word before it and `Ctrl-U` / `Ctrl-K` everything
123
+ before / after it. The `/` filter, `:` command and alias / PR prompt lines
124
+ are the same editor.
125
+
126
+ `Ctrl-S` starts the session and drops you back in the inbox; `Ctrl-O` starts it
127
+ and hands the terminal straight over. Either way the new row is selected once
128
+ it shows up. Terminals send the same byte for `Ctrl-S` and `Ctrl-Shift-S`, so
129
+ the second start needs a letter of its own.
130
+
131
+ The directory defaults to the selected row's. It runs `claude --bg "<prompt>"`
132
+ with only the flags you changed from default, in that directory.
133
+
134
+ Images work as they do at Claude Code's own prompt. Paste one with `Cmd-V`
135
+ (or `Ctrl-V` in a terminal that doesn't do bracketed paste), or drop a file
136
+ onto the window, and it lands at the cursor as an `[Image #1]` token that
137
+ moves and deletes as one character, so the prompt can point at it: "make the
138
+ button look like [Image #1]". On macOS, `Cmd-Ctrl-Shift-4` copies a region of
139
+ the screen; `n` and `Cmd-V` put it in the prompt. A pasted image is saved
140
+ under `~/.config/claude-inbox/images/` (cleared after 14 days, as sessions
141
+ are); a dropped file is referenced where it is. When the session starts, each
142
+ token becomes an `@path` mention, which `claude` reads as an image the way it
143
+ does at its own prompt. A dropped file that isn't an image, and pasted text,
144
+ stay text, newlines included.
145
+
146
+ Why the terminal can paste an image at all: with bracketed paste on, a paste
147
+ arrives fenced between `\e[200~` and `\e[201~`, and an image, having no text
148
+ form, arrives as an empty fence. That empty fence is the signal to read the
149
+ clipboard, which is how Claude Code does it too. A dropped file arrives the
150
+ same way, as its shell-escaped path. Reading the clipboard goes through
151
+ `osascript`, so it is macOS only; elsewhere the paste is reported empty.
152
+
153
+ Slash commands work as they do at Claude Code's own prompt: a prompt that
154
+ starts with one, `/unslop README.md`, is expanded by `claude` into the skill
155
+ with its arguments before the session starts; one further into the text is
156
+ left for the agent to read and act on. Typing `/` at the start of any word
157
+ opens a menu of what the CLI would offer — project and personal skills and
158
+ commands, plugin skills as `plugin:name`, claude.ai's synced skills as
159
+ `anthropic-skills:name` — narrowed as you type, each with the description
160
+ from its front matter. `↑` `↓` choose, `Tab` or `Enter` drop the command in
161
+ with a space after it, `Esc` closes the menu (a second `Esc` cancels the
162
+ form). A `/` inside a word, as in `a/b`, opens nothing, and the menu only
163
+ appears while something matches, so a path like `/Users/…` is left alone
164
+ after its first letters. Built-ins such as `/init` live inside the CLI and
165
+ are not listed; typing one still works. Project commands follow the
166
+ Directory field.
167
+
168
+ ### Why `←` comes back here and not to native agent view
169
+
170
+ Inside an attached session, `←` on an empty prompt detaches. `claude attach`
171
+ then execs itself in place as `claude agents`, so you would land in the native
172
+ view and only get back to the inbox after quitting that. No flag or setting
173
+ suppresses just that relaunch: the one switch that exists disables `attach`
174
+ too. So the inbox watches its child's command line and, the moment it turns
175
+ into the agents view, terminates it. Measured round trip is about 0.4s and the
176
+ agents view never draws a frame. `Ctrl+Z` is handled locally by the attach
177
+ client and returns directly with no trick needed.
178
+
179
+ ## Rules
180
+
181
+ All four rules are pure methods on `ClaudeInbox::Store::Row`, a session paired
182
+ with its entry, and are the only place triage logic lives.
183
+
184
+ **Wake.** A snoozed session returns when its timer elapses, when you press `u`,
185
+ or when it *becomes* blocked or failed after being snoozed. A session that was
186
+ already blocked when you snoozed it stays snoozed; that is the point of snoozing.
187
+ `u` also brings back a settled row, whether it settled by hand or because its
188
+ pull request resolved, and puts it wherever its raw state belongs — Active or
189
+ Needs You. It stays there until the state actually changes again.
190
+
191
+ **Acknowledge.** Attaching to a needs-you session (`Enter`) marks its current
192
+ state seen, moving it to Active instead of leaving it in Needs You. It comes
193
+ back to Needs You the moment its state changes again. Still blocked with a
194
+ new prompt doesn't count, only an actual state change does, same as hand-settle.
195
+
196
+ **Settle.** Settled by hand with `x`, or because every pull request it has is
197
+ merged or closed. A hand-settled session stays put when it finishes, and
198
+ returns as soon as it changes state in any other way: working again, blocked,
199
+ or failed. `failed` never settles. A session with no pull request at all
200
+ never settles on its own, however long it has been quiet — `x` is the only
201
+ way in.
202
+
203
+ A session with a pull request follows the PR instead: it stays Active while
204
+ any of its PRs is open or a draft, however long it has been quiet, and
205
+ settles the moment every one is merged or closed. A PR whose state is not
206
+ known yet (no `gh`, offline) is ignored, so the session stays Active too.
207
+
208
+ **Reap.** A background session quiet for `REAP_AFTER` (14 days) is deleted
209
+ outright on the next poll: `claude rm`, so the transcript and the worktree go
210
+ with it, same as `Ctrl-x`. This is the one thing here that destroys anything
211
+ without asking first, so read the rest of this before you leave it running.
212
+
213
+ Reaping does *not* key on Settled, deliberately. Settling answers "should I
214
+ still be looking at this?", and the PR rule keeps a row in Active for as long
215
+ as a pull request stays open, so an abandoned draft parks a session there for
216
+ ever and the deadest rows in the list are precisely the ones Settled never
217
+ reaches. Idle time, measured from `state_since`, is the only clock.
218
+
219
+ Four things are never reaped, whatever the clock says: a `working` session, one
220
+ that still holds a process, a pin, and a snooze. `failed` *is* reaped, even
221
+ though it never settles. It earns a permanent row because you ought to see it,
222
+ and after a fortnight of not seeing it you never will. A pin or a parked
223
+ snooze ("until I wake it") is the way to keep a session indefinitely; both are
224
+ deliberate gestures, so both outrank the reaper.
225
+
226
+ Unpushed work is safe without us doing anything. `claude rm` refuses a worktree
227
+ holding commits that aren't pushed and offers a `--discard-unpushed` token to
228
+ override it; nothing here ever passes that token, so a refusal is the end of
229
+ it. Refusals are logged and retried at most once a day.
230
+
231
+ A session that `claude rm` has taken, by the reaper or by `Ctrl-x`, stays
232
+ hidden for as long as the daemon still lists it; the store drops it from each list in between.
233
+
234
+ Every reap appends a line to `~/.config/claude-inbox/reaped.log`, which is the
235
+ last record a session existed once its transcript is gone. If that file can't
236
+ be opened the sweep raises and nothing is deleted. `CLAUDE_INBOX_NO_REAP=1`
237
+ turns the whole thing off, and `--fixture` runs never reap.
238
+
239
+ ## Pull requests
240
+
241
+ Claude Code already links sessions to PRs. The daemon scans each background
242
+ session's transcript for links and writes them to
243
+ `~/.claude/jobs/<id>/state.json` as `children` (`kind: "pr"`); `claude agents
244
+ --json` does not expose that, so `JobState` reads the file. It is a link
245
+ scan, so a session that only *reviews* a PR gets it too. Interactive sessions
246
+ have no job file, so for them (or to correct a bad scan) `P` sets the link by
247
+ hand; that lives in our own state file as `pr` and replaces the scanned list.
248
+
249
+ State is seeded from `~/.claude/gh-pr-status-cache.json`, whatever Claude Code
250
+ last saw, then refreshed with `gh pr view` on the poller thread, at most once a
251
+ minute per PR and never for one already merged or closed. Without `gh` the
252
+ cached state is all you get.
253
+
254
+ `gh pr view` is a network round trip, so each poll publishes the list first,
255
+ with whatever states are already known, and asks gh afterwards; the rows come
256
+ round again only if an answer moved one. That is why the inbox is up in well
257
+ under a second rather than after a dozen serial `gh` calls. Once gh has said a
258
+ PR is merged or closed the answer is kept in `~/.config/claude-inbox/prs.json`,
259
+ in the same shape as Claude Code's cache, because that cache only covers PRs
260
+ its own sessions opened and a link scan picks up plenty of others — without
261
+ the file every launch would ask about every merged PR again.
262
+
263
+ Until the first poll lands the body is blank rather than claiming "nothing
264
+ running"; past a second and a half it gets a spinner and a rotating excuse
265
+ with the elapsed time, and past ten seconds a hint to check the daemon.
266
+
267
+ ## Colors
268
+
269
+ `/color` inside a session is the only way to set one; there is no key for it
270
+ here, and nothing is stored on our side. The daemon writes it to the job file
271
+ as `color`, `claude agents --json` leaves it out, so `JobState` reads it on
272
+ every poll and a color you change shows up on the next one.
273
+
274
+ It lands on the label and nowhere else. The glyph, the state badge and the PR
275
+ badge keep the colors their own state gives them, so no color you pick can
276
+ stop a blocked session looking blocked. Settled rows stay dim — that section is
277
+ meant to be quiet, and a color shouting out of a collapsed fold would undo it.
278
+
279
+ The eight colors `/color` offers are mapped the way Claude Code's own tmux
280
+ code maps them when it tints a teammate pane: `red`, `blue`, `green`, `yellow`,
281
+ `purple` and `cyan` go to the terminal's own ansi colors, so they follow your
282
+ theme; `orange` and `pink` have no ansi name and go through 256-color indexes
283
+ 208 and 205. Anything else is left unpainted. Colors close with SGR 39
284
+ (default foreground) rather than 0, so a label that is already bold or italic
285
+ stays that way.
286
+
287
+ ## Usage
288
+
289
+ The right end of the header shows how much of your Claude subscription's
290
+ 5-hour and 7-day rate limit windows you have used, as `usage 5h 24% · 7d 41%`.
291
+ It needs a Pro or Max account and a one-time setup.
292
+
293
+ Claude Code passes the numbers to your [status line](https://code.claude.com/docs/en/statusline)
294
+ script every turn, and the inbox reads them from a file that script writes.
295
+ Put these two lines in the script, just after it reads stdin:
296
+
297
+ ```bash
298
+ input=$(cat)
299
+ limits=$(echo "$input" | jq -c '.rate_limits // empty')
300
+ [ -n "$limits" ] && echo "$limits" > ~/.claude/rate_limits.json.tmp && mv ~/.claude/rate_limits.json.tmp ~/.claude/rate_limits.json
301
+ ```
302
+
303
+ If you have no status line script, run `/statusline` in any session and it
304
+ writes one for you, then add the lines to that. Keep the `-n` check: a
305
+ session's first status line run has no numbers yet, and writing anyway would
306
+ blank the file.
307
+
308
+ The label stays off until the file exists, and goes off again once the file
309
+ is more than fifteen minutes old.
310
+
311
+ ## State
312
+
313
+ `~/.config/claude-inbox/state.json`, keyed by session id, atomic writes.
314
+ Holds `wake_at`, `snoozed_at`, `alias`, `pr`, `pinned`, `pinned_at`, `settled_at`, `acknowledged_at`, `revived_at`, `last_state`, `state_since`, `last_seen`,
315
+ and `reap_failed_at` / `reap_error` for a session `claude rm` has refused.
316
+ `Store::Entry` is where those names live; everything else reads an entry
317
+ through it. Entries not seen in a poll for 7 days are pruned. Pruning only reaches entries
318
+ the daemon has *forgotten*, which is a different thing from the reaper: the
319
+ daemon still lists sessions a month old, so those keep their entry and it is
320
+ the 14-day reap that clears them.
321
+
322
+ ## Layout
323
+
324
+ ```
325
+ Sessions.load: AgentsClient → JobState → PullRequests → Poller → Store → Renderer → App
326
+ (shells out) (jobs dir) (known states) (thread, then gh) (pure) (strings) (terminal + key loop)
327
+ ```
328
+
329
+ - `AgentsClient` is the only thing that runs `claude`. `FixtureClient` swaps in a JSON file.
330
+ - `Sessions.load` is the one place the list is put together: `AgentsClient`,
331
+ then `JobState`, then `PullRequests#enrich`, in that order because the links
332
+ `PullRequests` wants are the ones `JobState` read. `Session` is immutable; a
333
+ step that adds something hands back a copy.
334
+ - `JobState` reads `~/.claude/jobs/<id>/state.json`, the daemon's own file: the scanned
335
+ links `PullRequests` wants, the open work behind a `working` state, the session's
336
+ own line (`detail`, `needs`, `output.result`) and the `/color` each session carries. Never cached. `enrich` returns each background session
337
+ with its file attached, for `Sessions.load`.
338
+ - `PullRequests` pairs each session with the PRs its `job_state` links and keeps
339
+ their state fresh through `gh`. `enrich` never asks gh and runs inside
340
+ `Sessions.load`; `refresh` is the slow half and runs after the list has gone
341
+ up. `--fixture` points it at `test/fixtures/jobs` with `gh` off.
342
+ - `Palette` maps a session color to an escape sequence and knows nothing else.
343
+ - `RateLimits` reads `~/.claude/rate_limits.json`, the file a status line script writes, and gives the header its usage label. Nil without the file.
344
+ - `Store` holds the last poll and the entry table behind a mutex, and folds each poll in.
345
+ A key it was told to `hide` or `forget` stays out of every poll until it is released or the daemon stops listing it.
346
+ `Store::Entry` is what is remembered about one session, and the only place the state file's key names appear.
347
+ `Store::Row` is one session with its entry, and the rules are its methods.
348
+ `Store::Sections` is one poll sorted into sections and knows where the cursor can land: the `/` filter, the fold-or-rows rule and which section a key is in live there.
349
+ - `Renderer` turns sections into an array of fixed-width strings. `Painter` diffs frames
350
+ and repaints only changed rows.
351
+ - `Reaper` runs `claude rm` over whatever `Row#reapable?` picks and appends a
352
+ line to the log for each one. `due` names them without touching anything;
353
+ `sweep` does the deleting. Both run on the poller, and the list goes up
354
+ between them with the rows `due` named hidden in the store, so a slow `rm`
355
+ stalls neither a frame nor the first one and a row never paints while it is
356
+ being deleted.
357
+ - `Poller` is the thread that asks `Sessions.load` for the list, every four
358
+ seconds and on demand, runs it past the `Reaper` and then the gh refresh,
359
+ and hands each result to `App` over a queue. An on-demand poll queues onto
360
+ that same thread rather than racing it, so a burst of them while it is idle
361
+ is one `claude agents` call, one that lands mid-poll is a single follow-up,
362
+ and no two polls overlap. `App` pauses it while `claude
363
+ attach` has the terminal.
364
+ - `Terminal` is the screen: the alt screen with its mouse and wheel modes, raw
365
+ mode, the cached size and the `Painter` that diffs frames onto it. `release`
366
+ lends it to `claude attach` and takes it back.
367
+ - `App` owns the key loop, the poller thread and the logs thread, and is the
368
+ only place that spawns a child.
369
+ - `Peek` is the peek pane: whether it is open, how far back it is scrolled and
370
+ what to paint for the selected row. `Logs` is where the lines come from: the
371
+ `claude logs` replay of each session, fetched off the main thread, debounced
372
+ and cached.
373
+ - `VtScreen` is a small cursor-addressed grid used to turn the `claude logs` replay
374
+ into readable lines for the peek pane.
375
+ - `SlashCommands` reads the skills and commands `claude` would offer from the
376
+ same directories it reads them, front matter included, for the new-session
377
+ prompt's menu. Pure filesystem; it never runs `claude`.
378
+ - `Dialog` is a box over the list that claims every key until it answers:
379
+ the snooze menu, the stop and delete confirms, the alias and pull request
380
+ prompts. Pure, like `NewSessionForm`; `App` acts on the answer.
381
+ - `Mouse` turns the SGR escape sequences the terminal sends for clicks and
382
+ wheel ticks into `Event`s; `App` maps a click's row back to whatever
383
+ `Renderer` painted there.
384
+
385
+ ## Things learned from the real CLI (2.1.273)
386
+
387
+ - Terminal.app puts the tty's active process in the tab title, so a poller that
388
+ forks `claude` every few seconds makes the title flicker. Every helper
389
+ subprocess here is started with `setsid` so it has no controlling tty.
390
+
391
+ - `claude agents --json --all` matches the documented shape exactly. A `done`
392
+ session can still carry a `pid` and `status: idle`.
393
+ - `claude logs <id>` is **not** plain text. It is a replay of the session's terminal
394
+ output: cursor positioning, erase-line, color. Words are frequently separated by
395
+ cursor motion rather than spaces, so stripping escapes yields run-together garbage.
396
+ Feeding it through a screen grid produces readable text.
397
+ - `claude logs` fails with "job not found" for a finished session whose process the
398
+ supervisor has reaped. The peek pane shows a notice instead.
399
+ - `claude attach --help` says `←` returns to agent view and `Ctrl+Z` drops to the
400
+ shell. Under the hood `←` makes the attach process exec `claude agents` in place,
401
+ same pid, using its own executable path, so a PATH shim never sees it.
402
+ - `CLAUDE_CODE_DISABLE_AGENT_VIEW=1` disables `agents`, `attach` and `logs` alike.
403
+ - A prompt beginning with a slash command is expanded, `$ARGUMENTS` and all,
404
+ in `-p` and `--bg` alike (checked on 2.1.274 with a project command).
405
+ Installed plugins are listed in `~/.claude/plugins/installed_plugins.json`,
406
+ keyed `name@marketplace`, each pointing at its `installPath`; claude.ai's
407
+ synced skills sit in `~/.claude/skills/synced/<bucket>/<name>/SKILL.md`
408
+ and show up as `anthropic-skills:<name>`.
409
+ - Interactive sessions (a `claude` you started in a terminal yourself) appear in
410
+ the JSON with no `id` and no `state`, only `status`, and cannot be attached,
411
+ peeked or stopped from outside.
412
+ - `~/.claude/jobs/<id>/state.json` is where the daemon keeps what the JSON
413
+ leaves out: `children` (scanned PR and issue links), `intent`, `worktreePath`,
414
+ `worktreeBranch`, token count and the transcript path, plus the line the
415
+ agents view prints under a row: `detail` is the session's status line,
416
+ `needs` what it is waiting on while blocked, `output.result` its closing
417
+ summary once done. `~/.claude/gh-pr-status-cache.json`
418
+ is keyed by PR url and calls an open draft `DRAFT`; `gh pr view` reports
419
+ `OPEN` plus `isDraft`.
420
+
421
+ - The job file is also where `/color` ends up, as `color`. `claude agents --json`
422
+ does not carry it, so a color is only ever visible by reading the file. The CLI
423
+ offers eight, and its own tmux code is the authority on what they mean to a
424
+ terminal: six are ansi names, `orange` and `pink` are 256-color indexes 208 and
425
+ 205. Interactive sessions have no job file and so never have a color.
426
+
427
+ ## Development
428
+
429
+ ```
430
+ bundle install
431
+ bin/ci # full signoff: lint, gem audit, tests (see CONTRIBUTING.md)
432
+ bin/release --dry-run # the version the commits since the last tag call for
433
+ bundle exec rake test # minitest/spec, test/**/*_spec.rb
434
+ bundle exec standardrb
435
+ CLAUDE_INBOX_STDERR=/tmp/err.log bin/claude-inbox # crash traces off the alt screen
436
+ DEBUG=1 bin/claude-inbox # slow-frame notes in /tmp/inbox-debug.log
437
+ CLAUDE_INBOX_NO_REAP=1 bin/claude-inbox # never delete an idle session
438
+ bin/screens # drive the fixture in a pty, print every screen
439
+ ```
440
+
441
+ `bin/screens` is how a refactor is checked against the real screen: run it on
442
+ `main` and on the branch and diff the two.
data/exe/claude-inbox ADDED
@@ -0,0 +1,33 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "claude_inbox"
5
+
6
+ if ARGV.include?("--version")
7
+ puts ClaudeInbox::VERSION
8
+ exit
9
+ end
10
+
11
+ require "claude_inbox/app"
12
+
13
+ # Crash traces go here instead of the alt screen when set.
14
+ $stderr.reopen(ENV["CLAUDE_INBOX_STDERR"], "a") if ENV["CLAUDE_INBOX_STDERR"]
15
+
16
+ opts = {}
17
+ if (i = ARGV.index("--fixture"))
18
+ agents = ARGV[i + 1]
19
+ fixtures = File.dirname(agents)
20
+ opts[:client] = ClaudeInbox::FixtureClient.new(agents, logs: File.binread(File.join(fixtures, "logs_raw.txt")))
21
+ opts[:store] = ClaudeInbox::Store.new(path: nil)
22
+ opts[:jobs_dir] = File.join(fixtures, "jobs")
23
+ opts[:pull_requests] = ClaudeInbox::PullRequests.new(cache_path: File.join(fixtures, "gh-pr-status-cache.json"), resolved_path: nil, gh: nil)
24
+ else
25
+ # The only place the reaper is armed. Everything else — tests, --fixture,
26
+ # anything embedding App — gets one that deletes nothing.
27
+ opts[:client] = ClaudeInbox::AgentsClient.new
28
+ opts[:store] = ClaudeInbox::Store.new
29
+ opts[:reaper] = ClaudeInbox::Reaper.new(opts[:client], opts[:store])
30
+ end
31
+ opts[:color] = false if ARGV.include?("--no-color")
32
+
33
+ ClaudeInbox::App.new(**opts).run