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
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
|
+

|
|
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
|