claude-inbox 0.3.0 → 0.5.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 +93 -165
- data/exe/claude-inbox +49 -4
- data/lib/claude_inbox/actions.rb +91 -0
- data/lib/claude_inbox/agents_client.rb +15 -10
- data/lib/claude_inbox/app.rb +58 -18
- data/lib/claude_inbox/headless.rb +30 -0
- data/lib/claude_inbox/job_state.rb +4 -4
- data/lib/claude_inbox/keymap.rb +0 -2
- data/lib/claude_inbox/mod.rb +71 -0
- data/lib/claude_inbox/new_session_form.rb +67 -31
- data/lib/claude_inbox/painter.rb +3 -6
- data/lib/claude_inbox/paste.rb +2 -0
- data/lib/claude_inbox/poller.rb +46 -25
- data/lib/claude_inbox/pull_requests.rb +32 -18
- data/lib/claude_inbox/rate_limits.rb +2 -4
- data/lib/claude_inbox/reaper.rb +1 -3
- data/lib/claude_inbox/remote/http.rb +11 -8
- data/lib/claude_inbox/remote/listener.rb +4 -9
- data/lib/claude_inbox/remote/start.rb +4 -11
- data/lib/claude_inbox/renderer.rb +8 -16
- data/lib/claude_inbox/session_request.rb +10 -0
- data/lib/claude_inbox/settings.rb +1 -0
- data/lib/claude_inbox/slash_commands.rb +13 -12
- data/lib/claude_inbox/snapshot.rb +47 -0
- data/lib/claude_inbox/store/entry.rb +0 -2
- data/lib/claude_inbox/store/row.rb +7 -5
- data/lib/claude_inbox/store.rb +24 -9
- data/lib/claude_inbox/switch.rb +38 -0
- data/lib/claude_inbox/terminal.rb +8 -0
- data/lib/claude_inbox/text_buffer.rb +84 -33
- data/lib/claude_inbox/writer_lock.rb +58 -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 +491 -0
- data/mod/tests/contract.test.tsx +21 -0
- data/mod/tests/pane.test.tsx +217 -0
- data/mod/tests/snapshot.fixture.ts +44 -0
- data/mod/tests/snooze.test.tsx +31 -0
- data/mod/tsconfig.json +3 -0
- data/mod/types/index.d.ts +28 -0
- metadata +16 -2
- data/lib/claude_inbox/remote.rb +0 -10
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 150bc4c5ae639139680ad48d476e0628faa82e89e77e783fb1ac9b2ac34ab25f
|
|
4
|
+
data.tar.gz: c40bbe7c0a955ba01edc66014b3b2ff9d8b80eb70d3b6ea4a9345495a69af011
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 1168c17b14d49071580d1c8565eff838a8c5229e339de5cbbd3f90ed78274973cbe1b1b0f3d21b89c4734142029630a1cb9ea8b03932655d22d151a3447faaf8
|
|
7
|
+
data.tar.gz: 7a6fb8224d67ae2ff94136d9615bb1a5caf12b7430031c5d221fde12a46357aef6f7ffd223f2ab725fb15d3797458ddb7f6cd824d890a01b3b525c0dbc8e3f1b
|
data/README.md
CHANGED
|
@@ -1,227 +1,162 @@
|
|
|
1
1
|
# claude-inbox
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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.
|
|
5
|
+
|
|
6
|
+
Inspired by [T3 Code](https://github.com/pingdotgg/t3code)'s functionality and
|
|
7
|
+
inbox-style UX, I wanted a better way to manage my Claude agents, so I built
|
|
8
|
+
this.
|
|
7
9
|
|
|
8
10
|

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

|
|
37
37
|
|
|
38
|
-
|
|
39
|
-
|
|
38
|
+
`/inbox` opens it, `ctrl+x tab` focuses it and `esc` leaves. The keys are the
|
|
39
|
+
ones below, with `ge` for `G`, `n` `p` for `Tab` `Shift+Tab`, and no `a`,
|
|
40
|
+
`P`, `X`, `Ctrl-x`, `/` or `R`. `Enter`, or a click on a row, switches to the
|
|
41
|
+
session when you attached through `claude-inbox`.
|
|
40
42
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
as a `--watch` shell or a sub-agent. That's why a session with nothing left to
|
|
45
|
-
do can sit there for an hour.
|
|
43
|
+
It opens when a session starts. To stop that, set
|
|
44
|
+
`pluginConfigs.inbox-pane.options.openOnStart` to `false` in
|
|
45
|
+
`~/.claude/settings.json`.
|
|
46
46
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
47
|
+
## Sections
|
|
48
|
+
|
|
49
|
+
1. **Pinned.** Rows you pinned with `t`.
|
|
50
|
+
2. **Needs you.** Blocked or failed sessions you haven't attached to yet.
|
|
51
|
+
3. **Active.** Everything else that's running or not yet settled, including
|
|
52
|
+
interactive terminals.
|
|
53
|
+
4. **Snoozed.** Sorted by wake time. Collapsed.
|
|
54
|
+
5. **Settled.** Settled with `x`, or every pull request is merged or closed.
|
|
55
|
+
Collapsed.
|
|
52
56
|
|
|
53
|
-
|
|
54
|
-
|
|
57
|
+
Under each row is what the session is waiting on, what it produced, or its
|
|
58
|
+
status line. A row with a pull request shows it as `#885 open`, `#885 merged`
|
|
59
|
+
and so on. A steady `◌` with `idle · 1 shell` means the agent stopped and is
|
|
60
|
+
waiting on a shell or sub-agent it started.
|
|
55
61
|
|
|
56
62
|
## Keys
|
|
57
63
|
|
|
58
|
-
Vim bindings. Arrows
|
|
59
|
-
mouse, a click selects and attaches and the wheel moves the selection. Apple
|
|
60
|
-
Terminal reports neither.
|
|
64
|
+
Vim bindings. Arrows and the mouse work too.
|
|
61
65
|
|
|
62
66
|
| Key | Action |
|
|
63
67
|
|---|---|
|
|
64
68
|
| `j` `k` | move down / up |
|
|
65
69
|
| `gg` `G` | first / last row |
|
|
66
70
|
| `Ctrl-d` `Ctrl-u` | half page down / up (`Ctrl-f` `Ctrl-b` full page) |
|
|
67
|
-
| `Enter` `l` | attach (
|
|
68
|
-
| `Tab` `Shift+Tab` |
|
|
69
|
-
| `h` | close the peek pane, else collapse the current
|
|
70
|
-
| `za` `zo` `zc` | toggle / open / close the
|
|
71
|
+
| `Enter` `l` | attach (`←` or `Ctrl+Z` returns here), adopt a remote session, or expand Snoozed / Settled |
|
|
72
|
+
| `Tab` `Shift+Tab` | next / previous section |
|
|
73
|
+
| `h` | close the peek pane, else collapse the current fold |
|
|
74
|
+
| `za` `zo` `zc` | toggle / open / close the fold under the cursor |
|
|
71
75
|
| `p` | toggle the read-only peek pane |
|
|
72
76
|
| `J` `K` (`Ctrl-e` `Ctrl-y`) | scroll the peek pane |
|
|
73
77
|
| `n` | new session |
|
|
74
|
-
| `N` | pair a phone
|
|
78
|
+
| `N` | pair a phone |
|
|
75
79
|
| `t` | pin / unpin |
|
|
76
80
|
| `s` | snooze: `1` 15m · `2` 1h · `3` tomorrow 9am · `4` until woken |
|
|
77
|
-
| `u` | wake a snoozed session
|
|
78
|
-
| `x` | settle
|
|
79
|
-
| `a` | set a local alias
|
|
80
|
-
| `o` | open the
|
|
81
|
+
| `u` | wake a snoozed session, or bring back a settled one |
|
|
82
|
+
| `x` | settle by hand |
|
|
83
|
+
| `a` | set a local alias |
|
|
84
|
+
| `o` | open the pull request in the browser |
|
|
81
85
|
| `w` | open the session at claude.ai/code |
|
|
82
|
-
| `P` | link a pull request by hand (empty clears
|
|
83
|
-
| `X` | stop the session
|
|
84
|
-
| `Ctrl-x` | delete the session
|
|
85
|
-
| `/` | filter
|
|
86
|
+
| `P` | link a pull request by hand (empty clears) |
|
|
87
|
+
| `X` | stop the session; `Enter` resumes it later |
|
|
88
|
+
| `Ctrl-x` | delete the session, transcript and worktree for good |
|
|
89
|
+
| `/` | filter; `Enter` keeps it, `Esc` clears |
|
|
86
90
|
| `R` | poll now |
|
|
87
91
|
| `q` | quit |
|
|
88
92
|
|
|
89
|
-
`X` and `Ctrl-x` both ask
|
|
90
|
-
things. `X` runs `claude stop`, which ends the process and keeps the
|
|
91
|
-
conversation, and `Enter` resumes it later. `Ctrl-x` runs `claude rm`, which
|
|
92
|
-
deletes the session, its transcript and its worktree. Nothing resumes after
|
|
93
|
-
that, so read the box before answering.
|
|
93
|
+
`X` and `Ctrl-x` both ask for `y` first.
|
|
94
94
|
|
|
95
95
|
### New session
|
|
96
96
|
|
|
97
|
-
`n` opens a form
|
|
98
|
-
worktree and Remote Control. `Tab`
|
|
99
|
-
|
|
100
|
-
shows what your settings resolve to. The directory starts as the selected
|
|
101
|
-
row's, and `Tab` completes it.
|
|
97
|
+
`n` opens a form for the prompt, name, directory, model, effort, permissions,
|
|
98
|
+
worktree and Remote Control. `Tab` moves between fields, `Ctrl-S` starts the
|
|
99
|
+
session and `Ctrl-O` starts it and attaches.
|
|
102
100
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
the flags you changed, and the new row is selected once it shows up.
|
|
101
|
+
Paste an image on macOS, or drop a file into the prompt, and refer to it as
|
|
102
|
+
`[Image #1]`. Type `/` for a menu of your skills.
|
|
106
103
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
lands at the cursor for the prompt to point at: "make the button look like
|
|
110
|
-
[Image #1]". Pasted images are kept in `~/.config/claude-inbox/images/` for
|
|
111
|
-
14 days. A dropped file is referenced where it is. Pasting images works on
|
|
112
|
-
macOS only.
|
|
104
|
+
Type in Directory to pick from recent and trusted directories; `Ctrl-U`
|
|
105
|
+
lists them all.
|
|
113
106
|
|
|
114
|
-
|
|
115
|
-
up at claude.ai/code and in the Claude mobile app. It defaults to yes when
|
|
116
|
-
"Enable Remote Control for all sessions" is on in `/config`.
|
|
107
|
+
## How sessions move
|
|
117
108
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
`anthropic-skills:name`. `↑` `↓` choose, `Tab` or `Enter` insert, `Esc`
|
|
121
|
-
closes the menu. Built-ins such as `/init` aren't listed but still work.
|
|
109
|
+
A snoozed session wakes when its timer runs out, when you press `u`, or when
|
|
110
|
+
it becomes blocked or failed.
|
|
122
111
|
|
|
123
|
-
|
|
112
|
+
Attaching to a needs-you session moves it to Active until its state changes
|
|
113
|
+
again.
|
|
114
|
+
|
|
115
|
+
A session settles when you press `x` or when all its pull requests are merged
|
|
116
|
+
or closed. A session without a pull request only settles by hand.
|
|
124
117
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
**Acknowledge.** Attaching to a needs-you session marks its state seen and
|
|
131
|
-
moves it to Active. It returns to Needs you when its state changes again. A
|
|
132
|
-
new prompt while still blocked doesn't count.
|
|
133
|
-
|
|
134
|
-
**Settle.** A session settles when you press `x` or when every pull request
|
|
135
|
-
it has is merged or closed. A hand-settled session stays put when it
|
|
136
|
-
finishes, and comes back on any other state change. `failed` never settles.
|
|
137
|
-
A session with no pull request never settles on its own, and one with an
|
|
138
|
-
open or draft PR stays Active.
|
|
139
|
-
|
|
140
|
-
**Reap.** On the next poll, the inbox deletes a background session that has
|
|
141
|
-
been quiet for 14 days with `claude rm`, transcript and worktree included.
|
|
142
|
-
It's the one thing here that deletes without asking. Reaping ignores Settled,
|
|
143
|
-
because an open draft keeps a row in Active for ever and those are exactly
|
|
144
|
-
the rows that need reaping. Idle time is the only clock.
|
|
145
|
-
|
|
146
|
-
The inbox never reaps a `working` session, one that still holds a process, a
|
|
147
|
-
pinned one or a snoozed one. It does reap `failed`, even though failed never
|
|
148
|
-
settles. After a fortnight of not looking at it, you won't. Pin or park a
|
|
149
|
-
session to keep it.
|
|
150
|
-
|
|
151
|
-
Unpushed work is safe, because `claude rm` refuses a worktree with unpushed
|
|
152
|
-
commits and the inbox never overrides that. Every reap adds a line to
|
|
153
|
-
`~/.config/claude-inbox/reaped.log`. `CLAUDE_INBOX_NO_REAP=1` turns reaping
|
|
154
|
-
off.
|
|
118
|
+
With `--reap`, the inbox deletes background sessions that have been idle for
|
|
119
|
+
14 days, with `claude rm`. Working, pinned and snoozed sessions are kept, and
|
|
120
|
+
`claude rm` refuses a worktree with unpushed commits. Each deletion is logged
|
|
121
|
+
to `~/.config/claude-inbox/reaped.log`.
|
|
155
122
|
|
|
156
123
|
## Remote Control
|
|
157
124
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
- A background session started with Remote Control on, from the `n` form or
|
|
163
|
-
as `claude --bg "…" --remote-control`. It behaves like any other row, and
|
|
164
|
-
keeps Remote Control across a stop and a resume.
|
|
165
|
-
- A worker a `claude remote-control` server spawned when you started a
|
|
166
|
-
session from your phone. The daemon can't attach to it, so `Enter` offers
|
|
167
|
-
to adopt it. `y` ends the worker, resumes the conversation as a background
|
|
168
|
-
session under the same id with Remote Control on, and attaches. The phone
|
|
169
|
-
session you were in ends and a new one with the whole conversation takes
|
|
170
|
-
its place. The server keeps running. A worker nobody has messaged yet has
|
|
171
|
-
nothing to adopt.
|
|
125
|
+
Sessions with Remote Control show `⇅`, and `w` opens them at claude.ai/code.
|
|
126
|
+
A session started from your phone through `claude remote-control` can't be
|
|
127
|
+
attached directly, so `Enter` offers to adopt it as a background session.
|
|
172
128
|
|
|
173
129
|
## Starting sessions from your phone
|
|
174
130
|
|
|
175
|
-
With `--listen-lan`, a phone on your
|
|
176
|
-
`n` does. It's off unless you ask for it.
|
|
131
|
+
With `--listen-lan`, a phone on your network can start sessions.
|
|
177
132
|
|
|
178
133
|

|
|
179
134
|
|
|
180
|
-
1.
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
to the phone.
|
|
185
|
-
3. Open it in Safari. To keep it, tap Share, then Add to Home Screen.
|
|
186
|
-
4. Open the new Inbox icon. It has its own storage, apart from Safari's, so
|
|
187
|
-
paste the pairing URL once more and tap Pair.
|
|
135
|
+
1. Run `claude-inbox --listen-lan`.
|
|
136
|
+
2. Press `N`, then `c` to copy the pairing URL.
|
|
137
|
+
3. Open it on the phone in Safari and tap Share, then Add to Home Screen.
|
|
138
|
+
4. Open the Inbox icon, paste the URL again and tap Pair.
|
|
188
139
|
|
|
189
|
-
LAN mode is plain HTTP
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
`r` in the `N` dialog to replace it.
|
|
140
|
+
LAN mode is plain HTTP. Use it only on a network you control, and press `r`
|
|
141
|
+
in the `N` dialog if the URL leaks. [docs/remote-start.md](docs/remote-start.md)
|
|
142
|
+
covers ssh, the JSON API and an iOS Shortcut.
|
|
193
143
|
|
|
194
|
-
|
|
195
|
-
[docs/remote-start.md](docs/remote-start.md) covers the rest, including ssh,
|
|
196
|
-
the firewall prompt, the JSON API and an iOS Shortcut.
|
|
144
|
+
## Pull requests
|
|
197
145
|
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
The daemon scans each background session's transcript for PR links, so a
|
|
201
|
-
session that only reviews a PR gets it too. It doesn't scan interactive
|
|
202
|
-
sessions. Use `P` to link one by hand, or to fix a bad scan.
|
|
203
|
-
|
|
204
|
-
The state comes from Claude Code's own cache, then from `gh pr view` at most
|
|
205
|
-
once a minute per PR. Without `gh`, you only get the cached state.
|
|
146
|
+
Pull requests show up when a session's transcript links to them. Install
|
|
147
|
+
`gh` to keep their state current. Use `P` to link one by hand.
|
|
206
148
|
|
|
207
149
|
## Usage bars
|
|
208
150
|
|
|
209
|
-
The
|
|
210
|
-
limit windows you've used. Each is a ten-cell bar that turns yellow at 70%
|
|
211
|
-
and red at 90%.
|
|
151
|
+
The header can show your 5-hour and 7-day rate limit usage:
|
|
212
152
|
|
|
213
153
|
```
|
|
214
154
|
session ██░░░░░░░░ 24% · 3h left week ████░░░░░░ 41% · 2d left
|
|
215
155
|
```
|
|
216
156
|
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
Claude Code passes the numbers to your
|
|
222
|
-
[status line](https://code.claude.com/docs/en/statusline) script every turn,
|
|
223
|
-
and the inbox reads them from a file that script writes. Put these lines in
|
|
224
|
-
the script, just after it reads stdin:
|
|
157
|
+
This needs a Pro or Max account. Add these lines to your
|
|
158
|
+
[status line](https://code.claude.com/docs/en/statusline) script, just after
|
|
159
|
+
it reads stdin. Run `/statusline` first if you don't have one.
|
|
225
160
|
|
|
226
161
|
```bash
|
|
227
162
|
input=$(cat)
|
|
@@ -229,13 +164,6 @@ limits=$(echo "$input" | jq -c '.rate_limits // empty')
|
|
|
229
164
|
[ -n "$limits" ] && echo "$limits" > ~/.claude/rate_limits.json.tmp && mv ~/.claude/rate_limits.json.tmp ~/.claude/rate_limits.json
|
|
230
165
|
```
|
|
231
166
|
|
|
232
|
-
With no status line script, run `/statusline` in any session to get one,
|
|
233
|
-
then add the lines. Keep the `-n` check. A session's first status line run
|
|
234
|
-
has no numbers yet, and writing anyway would blank the file.
|
|
235
|
-
|
|
236
|
-
The bars stay hidden until the file exists, and hide again once it's more
|
|
237
|
-
than fifteen minutes old.
|
|
238
|
-
|
|
239
167
|
## Contributing
|
|
240
168
|
|
|
241
169
|
See [CONTRIBUTING.md](CONTRIBUTING.md).
|
data/exe/claude-inbox
CHANGED
|
@@ -8,8 +8,30 @@ 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"
|
|
12
33
|
require "claude_inbox/config"
|
|
34
|
+
require "claude_inbox/writer_lock"
|
|
13
35
|
require "tmpdir"
|
|
14
36
|
|
|
15
37
|
fixture = ARGV.include?("--fixture")
|
|
@@ -38,14 +60,37 @@ if fixture
|
|
|
38
60
|
opts[:listen] = listen&.merge(fixture: true, pairing: ClaudeInbox::Remote::Pairing.new(path: File.join(dir, "listen.json")),
|
|
39
61
|
lock_path: File.join(dir, "listen.lock"), images_dir: File.join(dir, "images"))
|
|
40
62
|
else
|
|
41
|
-
# The only place the reaper is armed
|
|
42
|
-
#
|
|
43
|
-
#
|
|
63
|
+
# The only place the reaper is armed, and only with --reap: deleting
|
|
64
|
+
# sessions is opt-in. Everything else — tests, --fixture, anything
|
|
65
|
+
# embedding App — gets one that deletes nothing. The listener, likewise,
|
|
66
|
+
# comes only from this file, and only when asked for.
|
|
44
67
|
opts[:client] = ClaudeInbox::AgentsClient.new
|
|
45
68
|
opts[:store] = ClaudeInbox::Store.new
|
|
46
|
-
opts[:reaper] = ClaudeInbox::Reaper.new(opts[:client], opts[:store])
|
|
69
|
+
opts[:reaper] = argv.include?("--reap") ? ClaudeInbox::Reaper.new(opts[:client], opts[:store]) : ClaudeInbox::Reaper.disabled
|
|
70
|
+
opts[:snapshot] = ClaudeInbox::Snapshot.new
|
|
71
|
+
opts[:switch] = ClaudeInbox::Switch.new
|
|
72
|
+
opts[:actions] = ClaudeInbox::Actions.new
|
|
47
73
|
opts[:listen] = listen
|
|
48
74
|
end
|
|
49
75
|
opts[:color] = false if argv.include?("--no-color")
|
|
50
76
|
|
|
77
|
+
if argv.include?("--headless")
|
|
78
|
+
abort "claude-inbox: --headless takes no --fixture" if fixture
|
|
79
|
+
require "claude_inbox/headless"
|
|
80
|
+
exit(ClaudeInbox::Headless.new(client: opts[:client], store: opts[:store]).run ? 0 : 1)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# The first inbox you can see is the snapshot's writer: a headless one gives
|
|
84
|
+
# way to it, and a second inbox reads along, writing and deleting nothing.
|
|
85
|
+
# Held for the life of this process by this reference.
|
|
86
|
+
writer_lock = fixture ? ClaudeInbox::WriterLock.disabled : ClaudeInbox::WriterLock.new
|
|
87
|
+
unless writer_lock.take(role: "inbox")
|
|
88
|
+
opts[:store] = ClaudeInbox::Store.new(writable: false, relay: ClaudeInbox::Actions.new)
|
|
89
|
+
opts[:reaper] = ClaudeInbox::Reaper.disabled
|
|
90
|
+
opts[:snapshot] = ClaudeInbox::Snapshot.disabled
|
|
91
|
+
opts[:actions] = ClaudeInbox::Actions.disabled
|
|
92
|
+
end
|
|
93
|
+
# So the pane settings.json names is the installed version's, after a gem update too.
|
|
94
|
+
ClaudeInbox::Mod.link unless fixture
|
|
95
|
+
|
|
51
96
|
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, named for this
|
|
29
|
+
# inbox; a count keeps two requests in one millisecond apart.
|
|
30
|
+
def request(action, id, choice: nil)
|
|
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, "inbox-#{Process.pid}-#{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
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require "json"
|
|
4
4
|
require_relative "debug"
|
|
5
5
|
require_relative "job_state"
|
|
6
|
+
require_relative "session"
|
|
6
7
|
require_relative "subprocess"
|
|
7
8
|
|
|
8
9
|
module ClaudeInbox
|
|
@@ -38,6 +39,8 @@ module ClaudeInbox
|
|
|
38
39
|
|
|
39
40
|
# Hands the terminal to the child; caller must have restored cooked mode.
|
|
40
41
|
# Returns when the user detaches. Detaching never stops the session.
|
|
42
|
+
# False when the attach failed on its own, as a session that cannot be
|
|
43
|
+
# woken does: the CLI prints why and exits 1.
|
|
41
44
|
#
|
|
42
45
|
# Pressing ← inside an attached session detaches it and then `claude
|
|
43
46
|
# attach` execs itself in place as `claude agents` (same pid). There is
|
|
@@ -45,11 +48,13 @@ module ClaudeInbox
|
|
|
45
48
|
# that exists disables attach too. So we watch the child's command line
|
|
46
49
|
# and, the moment it becomes the agents view, terminate it. The user then
|
|
47
50
|
# lands back in the inbox instead of native agent view.
|
|
48
|
-
|
|
51
|
+
# The block, polled with the command line, ends the attach early when it
|
|
52
|
+
# answers true: how a switch request from another front end is honoured.
|
|
53
|
+
def attach(id, &stop)
|
|
49
54
|
pid = Process.spawn(*Subprocess.command(@bin, "attach", id))
|
|
50
|
-
watchdog = Thread.new { kill_when_agents_view(pid) }
|
|
55
|
+
watchdog = Thread.new { kill_when_agents_view(pid, &stop) }
|
|
51
56
|
_, status = Process.wait2(pid)
|
|
52
|
-
status
|
|
57
|
+
status.success? || !status.exited?
|
|
53
58
|
ensure
|
|
54
59
|
watchdog&.kill
|
|
55
60
|
end
|
|
@@ -185,14 +190,13 @@ module ClaudeInbox
|
|
|
185
190
|
# origin, terminal when unlisted) and the bridge a remote one is served
|
|
186
191
|
# over, and drops the unattended ones, whose parent is the row worth
|
|
187
192
|
# showing.
|
|
188
|
-
def assign_origins(sessions, origins, bridges
|
|
193
|
+
def assign_origins(sessions, origins, bridges)
|
|
189
194
|
sessions
|
|
190
195
|
.map { |s| s.interactive? ? s.with(origin: origins.fetch(s.pid, :terminal), bridge_id: bridges[s.pid]) : s }
|
|
191
196
|
.reject(&:unattended?)
|
|
192
197
|
end
|
|
193
198
|
|
|
194
199
|
def origins_from(rows)
|
|
195
|
-
return {} if rows.empty?
|
|
196
200
|
self.class.origins(rows, ps_commands(rows.map { |_, ppid, _| ppid }.uniq))
|
|
197
201
|
end
|
|
198
202
|
|
|
@@ -203,8 +207,6 @@ module ClaudeInbox
|
|
|
203
207
|
pid, ppid, *cmd = l.split
|
|
204
208
|
[pid.to_i, ppid.to_i, cmd.join(" ")]
|
|
205
209
|
}
|
|
206
|
-
rescue Errno::ENOENT
|
|
207
|
-
[]
|
|
208
210
|
end
|
|
209
211
|
|
|
210
212
|
# pid => command, for the given parent pids.
|
|
@@ -218,15 +220,18 @@ module ClaudeInbox
|
|
|
218
220
|
}
|
|
219
221
|
end
|
|
220
222
|
|
|
221
|
-
def kill_when_agents_view(pid)
|
|
223
|
+
def kill_when_agents_view(pid, &stop)
|
|
222
224
|
loop do
|
|
223
225
|
sleep WATCH_INTERVAL
|
|
224
226
|
cmd = Subprocess.capture("ps", "-o", "command=", "-p", pid.to_s).out
|
|
225
227
|
break if cmd.empty?
|
|
226
|
-
next unless cmd.split[1] == "agents"
|
|
228
|
+
next unless cmd.split[1] == "agents" || stop&.call
|
|
227
229
|
Debug.log("watchdog saw #{cmd.inspect}")
|
|
228
230
|
Process.kill("TERM", pid)
|
|
229
|
-
|
|
231
|
+
10.times do
|
|
232
|
+
sleep 0.1
|
|
233
|
+
break if Subprocess.capture("ps", "-o", "pid=", "-p", pid.to_s).out.empty?
|
|
234
|
+
end
|
|
230
235
|
Process.kill("KILL", pid)
|
|
231
236
|
break
|
|
232
237
|
end
|