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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6c29f6bef2ba7ecc7f4d63161cfc064517e21f95a3a187313a1c5b882ae34786
|
|
4
|
+
data.tar.gz: 5195d163ef619f7f82a7224ca0a9b4dfe508fd23d8c1b3d87ab0a7b3b419cb28
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 9b051d9eeba2b19da5c9ced63f9dcd00030619cc4c7ac667f873da8db411ecf3c777352d2b373dfb7def00c9737c6d1ed0bc8edb0663b0a73d850c01e429d20d
|
|
7
|
+
data.tar.gz: cfe120a05f387965ba538ea48dbf726ad1bf379684b1515d213bead19d02803be8030b14abd938457671c6d80fe0d65ce1de1715632db8c304b768a5e6d46ae8
|
data/README.md
CHANGED
|
@@ -1,218 +1,156 @@
|
|
|
1
1
|
# claude-inbox
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
`claude agents --json` and adds snooze, auto-settle and the session's pull
|
|
6
|
-
request.
|
|
3
|
+
An inbox for Claude Code's background sessions. It lists what `claude agents`
|
|
4
|
+
lists and adds snooze, settle, pinning and each session's pull request.
|
|
7
5
|
|
|
8
6
|

|
|
9
7
|
|
|
10
8
|
## Install
|
|
11
9
|
|
|
10
|
+
Requires Ruby 3.2+ and `claude` on PATH.
|
|
11
|
+
|
|
12
12
|
```
|
|
13
13
|
gem install claude-inbox
|
|
14
14
|
claude-inbox
|
|
15
15
|
```
|
|
16
16
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
17
|
+
Put arguments you want on every launch in `~/.config/claude-inbox/config`.
|
|
18
|
+
|
|
19
|
+
To add the pane to every Claude Code session:
|
|
20
|
+
|
|
21
|
+
```
|
|
22
|
+
claude-inbox install
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
`claude-inbox install --force` also replaces any other copy of the pane.
|
|
26
|
+
|
|
27
|
+
## Claude Code pane
|
|
28
|
+
|
|
29
|
+
The same inbox as a pane inside Claude Code, docked beside the transcript in
|
|
30
|
+
a fullscreen terminal.
|
|
31
|
+
|
|
32
|
+

|
|
33
|
+
|
|
34
|
+
`/inbox` opens it, `ctrl+x tab` focuses it and `esc` leaves. The keys are the
|
|
35
|
+
ones below, with `ge` for `G`, `n` `p` for `Tab` `Shift+Tab`, and no `a`,
|
|
36
|
+
`P`, `X`, `Ctrl-x`, `/` or `R`. `Enter` switches to the session when you
|
|
37
|
+
attached through `claude-inbox`.
|
|
38
|
+
|
|
39
|
+
It opens when a session starts. To stop that, set
|
|
40
|
+
`pluginConfigs.inbox-pane.options.openOnStart` to `false` in
|
|
41
|
+
`~/.claude/settings.json`.
|
|
20
42
|
|
|
21
43
|
## Sections
|
|
22
44
|
|
|
23
|
-
1. **Pinned.**
|
|
24
|
-
2. **Needs you.**
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
terminal you are sitting in never settles and can't be attached, peeked
|
|
36
|
-
or stopped from outside; Enter tells you so.
|
|
37
|
-
4. **Snoozed.** Sorted by wake time, parked ("until I wake it") entries last.
|
|
38
|
-
Collapsed; Enter expands.
|
|
39
|
-
5. **Settled.** Parked with `x`, or whose pull request is merged or closed.
|
|
40
|
-
Collapsed; Enter expands.
|
|
41
|
-
|
|
42
|
-
A row with a pull request shows it after the state: `#885 open`, `#885 draft`,
|
|
43
|
-
`#885 merged`, `#885 closed`. `o` opens it in the browser.
|
|
44
|
-
|
|
45
|
-
`working` from the daemon covers two situations, and the row says which. A
|
|
46
|
-
spinner and `working` mean the agent is thinking. A steady `◌` and `idle · 1
|
|
47
|
-
shell` mean the agent has stopped and is waiting on work it started, such as a
|
|
48
|
-
`--watch` shell or a sub-agent. That is why a session with nothing left to do
|
|
49
|
-
can sit there for an hour. The open work is named next to the state either
|
|
50
|
-
way: `working · 2 agents`, `idle · 1 shell`.
|
|
51
|
-
|
|
52
|
-
Under each row in Pinned, Needs You and Active is the line `claude agents`
|
|
53
|
-
prints too: what it is waiting on while blocked (`↳ confirm: drop the top
|
|
54
|
-
line?`), what it produced once done, otherwise its status line. A terminal you
|
|
55
|
-
opened yourself has none and shows its directory instead.
|
|
56
|
-
|
|
57
|
-
A session you gave a color to with `/color` wears it on the label. Nothing
|
|
58
|
-
else is tinted, so a blocked session still looks blocked.
|
|
45
|
+
1. **Pinned.** Rows you pinned with `t`.
|
|
46
|
+
2. **Needs you.** Blocked or failed sessions you haven't attached to yet.
|
|
47
|
+
3. **Active.** Everything else that's running or not yet settled, including
|
|
48
|
+
interactive terminals.
|
|
49
|
+
4. **Snoozed.** Sorted by wake time. Collapsed.
|
|
50
|
+
5. **Settled.** Settled with `x`, or every pull request is merged or closed.
|
|
51
|
+
Collapsed.
|
|
52
|
+
|
|
53
|
+
Under each row is what the session is waiting on, what it produced, or its
|
|
54
|
+
status line. A row with a pull request shows it as `#885 open`, `#885 merged`
|
|
55
|
+
and so on. A steady `◌` with `idle · 1 shell` means the agent stopped and is
|
|
56
|
+
waiting on a shell or sub-agent it started.
|
|
59
57
|
|
|
60
58
|
## Keys
|
|
61
59
|
|
|
62
|
-
Vim bindings
|
|
63
|
-
terminals that report it: clicking a row selects and attaches, and the wheel
|
|
64
|
-
moves the selection. Apple Terminal reports neither, so there the wheel
|
|
65
|
-
scrolls its own history and clicks do nothing.
|
|
60
|
+
Vim bindings. Arrows and the mouse work too.
|
|
66
61
|
|
|
67
62
|
| Key | Action |
|
|
68
63
|
|---|---|
|
|
69
64
|
| `j` `k` | move down / up |
|
|
70
65
|
| `gg` `G` | first / last row |
|
|
71
66
|
| `Ctrl-d` `Ctrl-u` | half page down / up (`Ctrl-f` `Ctrl-b` full page) |
|
|
72
|
-
| `Enter` `l` | attach (
|
|
73
|
-
| `Tab` `Shift+Tab` |
|
|
74
|
-
| `h` | close the peek pane, else collapse the current
|
|
75
|
-
| `za` `zo` `zc` | toggle / open / close the
|
|
67
|
+
| `Enter` `l` | attach (`←` or `Ctrl+Z` returns here), adopt a remote session, or expand Snoozed / Settled |
|
|
68
|
+
| `Tab` `Shift+Tab` | next / previous section |
|
|
69
|
+
| `h` | close the peek pane, else collapse the current fold |
|
|
70
|
+
| `za` `zo` `zc` | toggle / open / close the fold under the cursor |
|
|
76
71
|
| `p` | toggle the read-only peek pane |
|
|
77
72
|
| `J` `K` (`Ctrl-e` `Ctrl-y`) | scroll the peek pane |
|
|
78
|
-
| `n` | new session
|
|
73
|
+
| `n` | new session |
|
|
74
|
+
| `N` | pair a phone |
|
|
79
75
|
| `t` | pin / unpin |
|
|
80
76
|
| `s` | snooze: `1` 15m · `2` 1h · `3` tomorrow 9am · `4` until woken |
|
|
81
|
-
| `u` | wake a snoozed session
|
|
82
|
-
| `x` | settle
|
|
83
|
-
| `a` | set a local alias
|
|
84
|
-
| `o` | open the
|
|
77
|
+
| `u` | wake a snoozed session, or bring back a settled one |
|
|
78
|
+
| `x` | settle by hand |
|
|
79
|
+
| `a` | set a local alias |
|
|
80
|
+
| `o` | open the pull request in the browser |
|
|
85
81
|
| `w` | open the session at claude.ai/code |
|
|
86
|
-
| `P` | link a pull request by hand (empty clears
|
|
87
|
-
| `X` | stop the session
|
|
88
|
-
| `Ctrl-x` | delete the session
|
|
89
|
-
| `/` | filter
|
|
82
|
+
| `P` | link a pull request by hand (empty clears) |
|
|
83
|
+
| `X` | stop the session; `Enter` resumes it later |
|
|
84
|
+
| `Ctrl-x` | delete the session, transcript and worktree for good |
|
|
85
|
+
| `/` | filter; `Enter` keeps it, `Esc` clears |
|
|
90
86
|
| `R` | poll now |
|
|
91
87
|
| `q` | quit |
|
|
92
88
|
|
|
93
|
-
`X` and `Ctrl-x` both ask
|
|
94
|
-
thing. `X` runs `claude stop`: the process ends, the conversation is kept, and
|
|
95
|
-
`Enter` resumes it later. `Ctrl-x` runs `claude rm`: the session, its
|
|
96
|
-
transcript and its worktree all go. Nothing resumes afterwards, so read the
|
|
97
|
-
box before answering.
|
|
89
|
+
`X` and `Ctrl-x` both ask for `y` first.
|
|
98
90
|
|
|
99
91
|
### New session
|
|
100
92
|
|
|
101
|
-
`n` opens a form
|
|
102
|
-
worktree
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
it
|
|
106
|
-
|
|
107
|
-
`Ctrl-S` starts the session and drops you back in the inbox; `Ctrl-O` starts
|
|
108
|
-
it and attaches. Either way the new row is selected once it shows up. It runs
|
|
109
|
-
`claude --bg "<prompt>"` in that directory, with only the flags you changed.
|
|
110
|
-
|
|
111
|
-
The prompt is multi-line; `Enter` breaks a line. Paste an image with `Cmd-V`
|
|
112
|
-
(or `Ctrl-V`), or drop a file onto the window, and it lands at the cursor as
|
|
113
|
-
an `[Image #1]` token the prompt can point at: "make the button look like
|
|
114
|
-
[Image #1]". Pasted images are saved under `~/.config/claude-inbox/images/`
|
|
115
|
-
for 14 days; a dropped file is referenced where it is. Reading the clipboard
|
|
116
|
-
is macOS only.
|
|
117
|
-
|
|
118
|
-
Remote Control set to yes starts the session with `--remote-control`, so it
|
|
119
|
-
runs under the daemon like any other row and is also listed at claude.ai/code
|
|
120
|
-
and in the Claude mobile app. See Remote Control below.
|
|
121
|
-
|
|
122
|
-
Slash commands work as at Claude Code's own prompt. Type `/` at the start of a
|
|
123
|
-
word for a menu of your skills and commands, plugin skills as `plugin:name`,
|
|
124
|
-
claude.ai's synced skills as `anthropic-skills:name`. `↑` `↓` choose, `Tab` or
|
|
125
|
-
`Enter` insert, `Esc` closes the menu. Built-ins such as `/init` aren't listed
|
|
126
|
-
but still work.
|
|
93
|
+
`n` opens a form for the prompt, name, directory, model, effort, permissions,
|
|
94
|
+
worktree and Remote Control. `Tab` moves between fields, `Ctrl-S` starts the
|
|
95
|
+
session and `Ctrl-O` starts it and attaches.
|
|
96
|
+
|
|
97
|
+
Paste an image on macOS, or drop a file into the prompt, and refer to it as
|
|
98
|
+
`[Image #1]`. Type `/` for a menu of your skills.
|
|
127
99
|
|
|
128
100
|
## How sessions move
|
|
129
101
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
been quiet. A session with one stays Active while any of its PRs is open or a
|
|
145
|
-
draft.
|
|
146
|
-
|
|
147
|
-
**Reap.** A background session quiet for 14 days is deleted on the next poll
|
|
148
|
-
with `claude rm`, so the transcript and the worktree go with it, same as
|
|
149
|
-
`Ctrl-x`. This is the one thing here that destroys anything without asking
|
|
150
|
-
first. Reaping does not key on Settled: an open draft keeps a row in Active
|
|
151
|
-
for ever, and those are precisely the rows that need reaping. Idle time is the
|
|
152
|
-
only clock.
|
|
153
|
-
|
|
154
|
-
Never reaped, whatever the clock says: a `working` session, one that still
|
|
155
|
-
holds a process, a pin, and a snooze. `failed` is reaped, even though it never
|
|
156
|
-
settles; after a fortnight of not looking at it you never will. A pin or a
|
|
157
|
-
parked snooze is the way to keep a session indefinitely.
|
|
158
|
-
|
|
159
|
-
Unpushed work is safe. `claude rm` refuses a worktree holding commits that
|
|
160
|
-
aren't pushed, and nothing here ever overrides that refusal. Every reap
|
|
161
|
-
appends a line to `~/.config/claude-inbox/reaped.log`, the last record a
|
|
162
|
-
session existed once its transcript is gone. `CLAUDE_INBOX_NO_REAP=1` turns
|
|
163
|
-
reaping off.
|
|
102
|
+
A snoozed session wakes when its timer runs out, when you press `u`, or when
|
|
103
|
+
it becomes blocked or failed.
|
|
104
|
+
|
|
105
|
+
Attaching to a needs-you session moves it to Active until its state changes
|
|
106
|
+
again.
|
|
107
|
+
|
|
108
|
+
A session settles when you press `x` or when all its pull requests are merged
|
|
109
|
+
or closed. A session without a pull request only settles by hand.
|
|
110
|
+
|
|
111
|
+
The inbox deletes background sessions that have been idle for 14 days, with
|
|
112
|
+
`claude rm`. Working, pinned and snoozed sessions are kept, and `claude rm`
|
|
113
|
+
refuses a worktree with unpushed commits. Each deletion is logged to
|
|
114
|
+
`~/.config/claude-inbox/reaped.log`. Set `CLAUDE_INBOX_NO_REAP=1` to turn it
|
|
115
|
+
off.
|
|
164
116
|
|
|
165
117
|
## Remote Control
|
|
166
118
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
poller thread, at most once a minute per PR. Without `gh` the cached state is
|
|
195
|
-
all you get. The list goes up before gh is asked, so the inbox is up in under
|
|
196
|
-
a second.
|
|
197
|
-
|
|
198
|
-
## Setup
|
|
199
|
-
|
|
200
|
-
The right end of the header shows how much of your Claude subscription's
|
|
201
|
-
5-hour and 7-day rate limit windows you have used, as a ten-cell bar per
|
|
202
|
-
window that turns yellow at 70% and red at 90%, like the context bar many
|
|
203
|
-
status lines draw:
|
|
119
|
+
Sessions with Remote Control show `⇅`, and `w` opens them at claude.ai/code.
|
|
120
|
+
A session started from your phone through `claude remote-control` can't be
|
|
121
|
+
attached directly, so `Enter` offers to adopt it as a background session.
|
|
122
|
+
|
|
123
|
+
## Starting sessions from your phone
|
|
124
|
+
|
|
125
|
+
With `--listen-lan`, a phone on your network can start sessions.
|
|
126
|
+
|
|
127
|
+

|
|
128
|
+
|
|
129
|
+
1. Run `claude-inbox --listen-lan`.
|
|
130
|
+
2. Press `N`, then `c` to copy the pairing URL.
|
|
131
|
+
3. Open it on the phone in Safari and tap Share, then Add to Home Screen.
|
|
132
|
+
4. Open the Inbox icon, paste the URL again and tap Pair.
|
|
133
|
+
|
|
134
|
+
LAN mode is plain HTTP. Use it only on a network you control, and press `r`
|
|
135
|
+
in the `N` dialog if the URL leaks. [docs/remote-start.md](docs/remote-start.md)
|
|
136
|
+
covers ssh, the JSON API and an iOS Shortcut.
|
|
137
|
+
|
|
138
|
+
## Pull requests
|
|
139
|
+
|
|
140
|
+
Pull requests show up when a session's transcript links to them. Install
|
|
141
|
+
`gh` to keep their state current. Use `P` to link one by hand.
|
|
142
|
+
|
|
143
|
+
## Usage bars
|
|
144
|
+
|
|
145
|
+
The header can show your 5-hour and 7-day rate limit usage:
|
|
204
146
|
|
|
205
147
|
```
|
|
206
148
|
session ██░░░░░░░░ 24% · 3h left week ████░░░░░░ 41% · 2d left
|
|
207
149
|
```
|
|
208
150
|
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
Claude Code passes the numbers to your [status line](https://code.claude.com/docs/en/statusline)
|
|
214
|
-
script every turn, and the inbox reads them from a file that script writes.
|
|
215
|
-
Put these two lines in the script, just after it reads stdin:
|
|
151
|
+
This needs a Pro or Max account. Add these lines to your
|
|
152
|
+
[status line](https://code.claude.com/docs/en/statusline) script, just after
|
|
153
|
+
it reads stdin. Run `/statusline` first if you don't have one.
|
|
216
154
|
|
|
217
155
|
```bash
|
|
218
156
|
input=$(cat)
|
|
@@ -220,14 +158,6 @@ limits=$(echo "$input" | jq -c '.rate_limits // empty')
|
|
|
220
158
|
[ -n "$limits" ] && echo "$limits" > ~/.claude/rate_limits.json.tmp && mv ~/.claude/rate_limits.json.tmp ~/.claude/rate_limits.json
|
|
221
159
|
```
|
|
222
160
|
|
|
223
|
-
If you have no status line script, run `/statusline` in any session and it
|
|
224
|
-
writes one for you, then add the lines to that. Keep the `-n` check: a
|
|
225
|
-
session's first status line run has no numbers yet, and writing anyway would
|
|
226
|
-
blank the file.
|
|
227
|
-
|
|
228
|
-
The bars stay off until the file exists, and goes off again once the file
|
|
229
|
-
is more than fifteen minutes old.
|
|
230
|
-
|
|
231
161
|
## Contributing
|
|
232
162
|
|
|
233
163
|
See [CONTRIBUTING.md](CONTRIBUTING.md).
|
data/exe/claude-inbox
CHANGED
|
@@ -8,25 +8,88 @@ if ARGV.include?("--version")
|
|
|
8
8
|
exit
|
|
9
9
|
end
|
|
10
10
|
|
|
11
|
+
require "claude_inbox/mod"
|
|
12
|
+
|
|
13
|
+
# The stable path to the Claude Code pane, for CLAUDE_CODE_PLUGIN_DIRS.
|
|
14
|
+
if ARGV.include?("--mod-dir")
|
|
15
|
+
puts ClaudeInbox::Mod.link(force: true)
|
|
16
|
+
exit
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
if ARGV.first == "install"
|
|
20
|
+
begin
|
|
21
|
+
installed = ClaudeInbox::Mod.install(force: ARGV.include?("--force"))
|
|
22
|
+
rescue JSON::ParserError
|
|
23
|
+
abort "claude-inbox: ~/.claude/settings.json is not valid JSON, so it was left alone"
|
|
24
|
+
end
|
|
25
|
+
installed.removed.each { |dir| puts "Removed #{dir} from ~/.claude/settings.json." }
|
|
26
|
+
puts "The pane now runs #{ClaudeInbox::Mod::COMMAND || "claude-inbox from your PATH"}." if installed.command_set
|
|
27
|
+
puts installed.added ? "Added the pane to ~/.claude/settings.json. New Claude Code sessions load it; /inbox opens it." :
|
|
28
|
+
"The pane is already in ~/.claude/settings.json."
|
|
29
|
+
exit
|
|
30
|
+
end
|
|
31
|
+
|
|
11
32
|
require "claude_inbox/app"
|
|
33
|
+
require "claude_inbox/config"
|
|
34
|
+
require "claude_inbox/writer_lock"
|
|
35
|
+
require "tmpdir"
|
|
36
|
+
|
|
37
|
+
fixture = ARGV.include?("--fixture")
|
|
38
|
+
# A fixture run takes the listen flags, for trying the API with curl, but
|
|
39
|
+
# not the environment or the config file, which are for the real inbox.
|
|
40
|
+
begin
|
|
41
|
+
argv = fixture ? ARGV : ClaudeInbox::Config.argv(ARGV)
|
|
42
|
+
listen = ClaudeInbox::Remote::Listener.options(argv, fixture ? {} : ENV)
|
|
43
|
+
rescue ArgumentError => e
|
|
44
|
+
abort "claude-inbox: #{e.message}"
|
|
45
|
+
end
|
|
12
46
|
|
|
13
47
|
# Crash traces go here instead of the alt screen when set.
|
|
14
48
|
$stderr.reopen(ENV["CLAUDE_INBOX_STDERR"], "a") if ENV["CLAUDE_INBOX_STDERR"]
|
|
15
49
|
|
|
16
50
|
opts = {}
|
|
17
|
-
if
|
|
18
|
-
agents =
|
|
51
|
+
if fixture
|
|
52
|
+
agents = argv[argv.index("--fixture") + 1]
|
|
19
53
|
fixtures = File.dirname(agents)
|
|
20
54
|
opts[:client] = ClaudeInbox::FixtureClient.new(agents, logs: File.binread(File.join(fixtures, "logs_raw.txt")))
|
|
21
55
|
opts[:store] = ClaudeInbox::Store.new(path: nil)
|
|
22
56
|
opts[:pull_requests] = ClaudeInbox::PullRequests.new(cache_path: File.join(fixtures, "gh-pr-status-cache.json"), resolved_path: nil, gh: nil)
|
|
57
|
+
# Its own token, lock and images, so a test inbox can neither rotate the
|
|
58
|
+
# token phones are paired with nor hold the real inbox's lock.
|
|
59
|
+
dir = File.join(Dir.tmpdir, "claude-inbox-fixture")
|
|
60
|
+
opts[:listen] = listen&.merge(fixture: true, pairing: ClaudeInbox::Remote::Pairing.new(path: File.join(dir, "listen.json")),
|
|
61
|
+
lock_path: File.join(dir, "listen.lock"), images_dir: File.join(dir, "images"))
|
|
23
62
|
else
|
|
24
63
|
# The only place the reaper is armed. Everything else — tests, --fixture,
|
|
25
|
-
# anything embedding App — gets one that deletes nothing.
|
|
64
|
+
# anything embedding App — gets one that deletes nothing. The listener,
|
|
65
|
+
# likewise, comes only from this file, and only when asked for.
|
|
26
66
|
opts[:client] = ClaudeInbox::AgentsClient.new
|
|
27
67
|
opts[:store] = ClaudeInbox::Store.new
|
|
28
68
|
opts[:reaper] = ClaudeInbox::Reaper.new(opts[:client], opts[:store])
|
|
69
|
+
opts[:snapshot] = ClaudeInbox::Snapshot.new
|
|
70
|
+
opts[:switch] = ClaudeInbox::Switch.new
|
|
71
|
+
opts[:actions] = ClaudeInbox::Actions.new
|
|
72
|
+
opts[:listen] = listen
|
|
73
|
+
end
|
|
74
|
+
opts[:color] = false if argv.include?("--no-color")
|
|
75
|
+
|
|
76
|
+
if argv.include?("--headless")
|
|
77
|
+
abort "claude-inbox: --headless takes no --fixture" if fixture
|
|
78
|
+
require "claude_inbox/headless"
|
|
79
|
+
exit(ClaudeInbox::Headless.new(client: opts[:client], store: opts[:store]).run ? 0 : 1)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# The first inbox you can see is the snapshot's writer: a headless one gives
|
|
83
|
+
# way to it, and a second inbox reads along, writing and deleting nothing.
|
|
84
|
+
# Held for the life of this process by this reference.
|
|
85
|
+
writer_lock = fixture ? ClaudeInbox::WriterLock.disabled : ClaudeInbox::WriterLock.new
|
|
86
|
+
unless writer_lock.take(role: "inbox")
|
|
87
|
+
opts[:store] = ClaudeInbox::Store.new(writable: false, relay: ClaudeInbox::Actions.new)
|
|
88
|
+
opts[:reaper] = ClaudeInbox::Reaper.disabled
|
|
89
|
+
opts[:snapshot] = ClaudeInbox::Snapshot.disabled
|
|
90
|
+
opts[:actions] = ClaudeInbox::Actions.disabled
|
|
29
91
|
end
|
|
30
|
-
|
|
92
|
+
# So the pane settings.json names is the installed version's, after a gem update too.
|
|
93
|
+
ClaudeInbox::Mod.link unless fixture
|
|
31
94
|
|
|
32
95
|
ClaudeInbox::App.new(**opts).run
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module ClaudeInbox
|
|
7
|
+
# What another front end asks the inbox to do to a session: one file per
|
|
8
|
+
# request under a directory, taken and applied here, so the triage rules
|
|
9
|
+
# and the state file have one owner. A request for a session the last
|
|
10
|
+
# poll did not list waits a little, since it may have just started; one
|
|
11
|
+
# the store cannot honour after that, or a verb it lacks, is dropped.
|
|
12
|
+
class Actions
|
|
13
|
+
DEFAULT_DIR = File.join(Dir.home, ".config", "claude-inbox", "actions")
|
|
14
|
+
VERBS = %w[settle wake pin snooze acknowledge].freeze
|
|
15
|
+
SNOOZES = %w[m15 h1 tomorrow_9am until_woken].freeze
|
|
16
|
+
# A file still being written parses as nothing for milliseconds; a
|
|
17
|
+
# session not yet polled appears within one poll.
|
|
18
|
+
GRACE = 10
|
|
19
|
+
|
|
20
|
+
def self.disabled = new(dir: nil)
|
|
21
|
+
|
|
22
|
+
def initialize(dir: DEFAULT_DIR, clock: -> { Time.now })
|
|
23
|
+
@dir = dir
|
|
24
|
+
@clock = clock
|
|
25
|
+
@sent = 0
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Leaves one request for the writer, as the pane does; `from` names the
|
|
29
|
+
# asker, and a count keeps two requests in one millisecond apart.
|
|
30
|
+
def request(action, id, choice: nil, from: "inbox-#{Process.pid}")
|
|
31
|
+
return unless @dir
|
|
32
|
+
FileUtils.mkdir_p(@dir)
|
|
33
|
+
at = (@clock.call.to_f * 1000).to_i
|
|
34
|
+
request = {"action" => action, "id" => id, "at" => at}
|
|
35
|
+
request["choice"] = choice if choice
|
|
36
|
+
@sent += 1
|
|
37
|
+
File.write(File.join(@dir, "#{from}-#{at}-#{@sent}.json"), JSON.generate(request))
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Applies every request on disk to `store` and returns how many it took.
|
|
41
|
+
def drain(store)
|
|
42
|
+
return 0 unless @dir && File.directory?(@dir)
|
|
43
|
+
keys = store.sessions.select(&:actionable?).map(&:key)
|
|
44
|
+
Dir.glob(File.join(@dir, "*.json")).sort.count { |path| take(path, store, keys) }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
# One file: false when it is left for a later pass or dropped, true once
|
|
50
|
+
# applied. Another drainer may take it first, which is nothing to report.
|
|
51
|
+
def take(path, store, keys)
|
|
52
|
+
request = parse(path)
|
|
53
|
+
return false if request.nil?
|
|
54
|
+
return false if !keys.include?(request["id"]) && young?(request)
|
|
55
|
+
File.delete(path)
|
|
56
|
+
apply(store, request, keys)
|
|
57
|
+
rescue Errno::ENOENT
|
|
58
|
+
false
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Nil leaves the file for the next pass: it is torn or early, and young.
|
|
62
|
+
def parse(path)
|
|
63
|
+
request = JSON.parse(File.read(path))
|
|
64
|
+
request = {} unless request.is_a?(Hash)
|
|
65
|
+
return request if File.mtime(path) < @clock.call - GRACE
|
|
66
|
+
(request.key?("id") && request.key?("action")) ? request : nil
|
|
67
|
+
rescue JSON::ParserError
|
|
68
|
+
(File.mtime(path) < @clock.call - GRACE) ? {} : nil
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def young?(request)
|
|
72
|
+
at = request["at"].to_i / 1000
|
|
73
|
+
at.positive? && Time.at(at) >= @clock.call - GRACE
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def apply(store, request, keys)
|
|
77
|
+
id = request["id"]
|
|
78
|
+
return false unless keys.include?(id) && VERBS.include?(request["action"])
|
|
79
|
+
case request["action"]
|
|
80
|
+
when "settle" then store.settle(id)
|
|
81
|
+
when "wake" then store.wake(id)
|
|
82
|
+
when "pin" then store.toggle_pin(id)
|
|
83
|
+
when "acknowledge" then store.acknowledge(id)
|
|
84
|
+
when "snooze"
|
|
85
|
+
return false unless SNOOZES.include?(request["choice"])
|
|
86
|
+
store.snooze(id, request["choice"].to_sym)
|
|
87
|
+
end
|
|
88
|
+
true
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|