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.
Files changed (44) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +93 -165
  3. data/exe/claude-inbox +49 -4
  4. data/lib/claude_inbox/actions.rb +91 -0
  5. data/lib/claude_inbox/agents_client.rb +15 -10
  6. data/lib/claude_inbox/app.rb +58 -18
  7. data/lib/claude_inbox/headless.rb +30 -0
  8. data/lib/claude_inbox/job_state.rb +4 -4
  9. data/lib/claude_inbox/keymap.rb +0 -2
  10. data/lib/claude_inbox/mod.rb +71 -0
  11. data/lib/claude_inbox/new_session_form.rb +67 -31
  12. data/lib/claude_inbox/painter.rb +3 -6
  13. data/lib/claude_inbox/paste.rb +2 -0
  14. data/lib/claude_inbox/poller.rb +46 -25
  15. data/lib/claude_inbox/pull_requests.rb +32 -18
  16. data/lib/claude_inbox/rate_limits.rb +2 -4
  17. data/lib/claude_inbox/reaper.rb +1 -3
  18. data/lib/claude_inbox/remote/http.rb +11 -8
  19. data/lib/claude_inbox/remote/listener.rb +4 -9
  20. data/lib/claude_inbox/remote/start.rb +4 -11
  21. data/lib/claude_inbox/renderer.rb +8 -16
  22. data/lib/claude_inbox/session_request.rb +10 -0
  23. data/lib/claude_inbox/settings.rb +1 -0
  24. data/lib/claude_inbox/slash_commands.rb +13 -12
  25. data/lib/claude_inbox/snapshot.rb +47 -0
  26. data/lib/claude_inbox/store/entry.rb +0 -2
  27. data/lib/claude_inbox/store/row.rb +7 -5
  28. data/lib/claude_inbox/store.rb +24 -9
  29. data/lib/claude_inbox/switch.rb +38 -0
  30. data/lib/claude_inbox/terminal.rb +8 -0
  31. data/lib/claude_inbox/text_buffer.rb +84 -33
  32. data/lib/claude_inbox/writer_lock.rb +58 -0
  33. data/lib/claude_inbox.rb +1 -10
  34. data/mod/.claude-plugin/plugin.json +20 -0
  35. data/mod/hooks/hooks.json +1 -0
  36. data/mod/hooks/register.tsx +491 -0
  37. data/mod/tests/contract.test.tsx +21 -0
  38. data/mod/tests/pane.test.tsx +217 -0
  39. data/mod/tests/snapshot.fixture.ts +44 -0
  40. data/mod/tests/snooze.test.tsx +31 -0
  41. data/mod/tsconfig.json +3 -0
  42. data/mod/types/index.d.ts +28 -0
  43. metadata +16 -2
  44. data/lib/claude_inbox/remote.rb +0 -10
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7fc36460c912469438541ba91cd22f8c3635bb3fd80b60b695afe148f8230ee0
4
- data.tar.gz: 105f161459cb30bd1d3751e2a3c926225e5087279b9056bafee46474542bca0f
3
+ metadata.gz: 150bc4c5ae639139680ad48d476e0628faa82e89e77e783fb1ac9b2ac34ab25f
4
+ data.tar.gz: c40bbe7c0a955ba01edc66014b3b2ff9d8b80eb70d3b6ea4a9345495a69af011
5
5
  SHA512:
6
- metadata.gz: f31e385cff9fb60a06e7bcce8ba965398491ea65c2792ce9d609654a1d0fc5b417dcd147c0962145badf49da6bb0de734a4bcbae75c7055dbb57ec6090889087
7
- data.tar.gz: 889b409820e77ec9d6b4a51de365f3524eac013b55131ac8de4d2b0eae9e573ab11c9a12c5240aea0ef40c74211893240adc22283a439f187668ce48949766ce
6
+ metadata.gz: 1168c17b14d49071580d1c8565eff838a8c5229e339de5cbbd3f90ed78274973cbe1b1b0f3d21b89c4734142029630a1cb9ea8b03932655d22d151a3447faaf8
7
+ data.tar.gz: 7a6fb8224d67ae2ff94136d9615bb1a5caf12b7430031c5d221fde12a46357aef6f7ffd223f2ab725fb15d3797458ddb7f6cd824d890a01b3b525c0dbc8e3f1b
data/README.md CHANGED
@@ -1,227 +1,162 @@
1
1
  # claude-inbox
2
2
 
3
- Inbox-style triage for Claude Code's background sessions. It reads the same
4
- daemon state as `claude agents`, through `claude agents --json`, and adds
5
- snooze, auto-settle and each session's pull request. Use it next to
6
- `claude agents`, not instead of it.
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
  ![claude-inbox with one working session, one snoozed and the settled section folded](docs/screenshot.png)
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
- ## Sections
23
+ To add the pane to every Claude Code session:
24
24
 
25
- 1. **Pinned.** Parked at the top by hand, whatever its state. `t` toggles it.
26
- 2. **Needs you.** `blocked` or `failed`, until you attach to it.
27
- 3. **Active.** `working`, finished but not yet settled, interactive, or a
28
- needs-you session you attached to that hasn't resolved. An interactive
29
- session only reports busy, idle or waiting, which show as working, done
30
- and needs you. A terminal you're sitting in never settles and can't be
31
- attached, peeked or stopped from here. Sub-agents and headless `claude -p`
32
- runs don't show up at all.
33
- 4. **Snoozed.** Sorted by wake time, with "until I wake it" entries last.
34
- Collapsed. Enter expands it.
35
- 5. **Settled.** Settled with `x`, or every pull request is merged or closed.
36
- Collapsed. Enter expands it.
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
+ ![The inbox pane docked beside a Claude Code transcript](docs/pane.png)
37
37
 
38
- A row with a pull request shows it after the state, as `#885 open`,
39
- `#885 draft`, `#885 merged` or `#885 closed`. `o` opens it in the browser.
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
- The daemon's `working` covers two cases, and the row tells them apart. A
42
- spinner and `working` mean the agent is thinking. A steady `◌` and
43
- `idle · 1 shell` mean it stopped and is waiting on something it started, such
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
- Under each row in Pinned, Needs you and Active is the same line
48
- `claude agents` prints. While blocked it's what the session waits on
49
- (`↳ confirm: drop the top line?`), once done it's what it produced, and
50
- otherwise it's the status line. A terminal you opened yourself has none, so
51
- it shows its directory.
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
- A session you gave a color with `/color` wears it on the label. Nothing else
54
- is tinted, so a blocked session still looks blocked.
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 work as well as `j`/`k`. In terminals that report the
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 (full-screen handoff; `←` or `Ctrl+Z` return here), adopt a remote session, or expand Snoozed / Settled |
68
- | `Tab` `Shift+Tab` | jump to the next / previous section |
69
- | `h` | close the peek pane, else collapse the current Snoozed / Settled fold |
70
- | `za` `zo` `zc` | toggle / open / close the Snoozed or Settled fold under the cursor |
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: whether the inbox is listening, and the URL to pair with |
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 now, or bring back one you settled |
78
- | `x` | settle a working or needs-you session by hand (returns when it changes state, unless it just finished) |
79
- | `a` | set a local alias (never touches the real session name) |
80
- | `o` | open the session's pull request in the browser |
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; the scanned links return) |
83
- | `X` | stop the session (`y` to confirm) |
84
- | `Ctrl-x` | delete the session for good, conversation and worktree with it (`y` to confirm) |
85
- | `/` | filter by name, cwd, the prompt it started from, or what it is doing now; `Enter` keeps it, `Esc` clears |
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 first and both take `y`, but they do different
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 with prompt, name, directory, model, effort, permissions,
98
- worktree and Remote Control. `Tab` and `Shift+Tab` move between fields and
99
- `Esc` cancels. Choice fields cycle with `h` `l` or the arrows, and "default"
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
- `Ctrl-S` starts the session and drops you back in the inbox. `Ctrl-O` starts
104
- it and attaches. It runs `claude --bg "<prompt>"` in that directory with only
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
- The prompt is multi-line, and `Enter` breaks a line. Paste an image with
108
- `Cmd-V` (or `Ctrl-V`) or drop a file on the window, and an `[Image #1]` token
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
- Remote Control set to yes adds `--remote-control`, so the session also shows
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
- Type `/` at the start of a word for a menu of your skills and commands.
119
- Plugin skills appear as `plugin:name` and claude.ai's synced skills as
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
- ## How sessions move
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
- **Wake.** A snoozed session comes back when its timer runs out, when you
126
- press `u`, or when it turns blocked or failed after you snoozed it. One that
127
- was already blocked when you snoozed it stays snoozed, which is the point of
128
- snoozing. `u` also brings back a settled row.
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
- A session with Remote Control on has a page at claude.ai/code, where you can
159
- follow and drive it from a browser or the Claude mobile app. `w` opens it,
160
- and the row shows `⇅` after the state. Two kinds of session have it.
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 Wi-Fi or VPN can start sessions the way
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
  ![The inbox beside the phone form, pairing and then starting a session](docs/remote-start.gif)
179
134
 
180
- 1. Start the inbox with `claude-inbox --listen-lan`, or put `--listen-lan`
181
- in `~/.config/claude-inbox/config` or `export CLAUDE_INBOX_LISTEN=lan` in
182
- your shell rc.
183
- 2. Press `N`, then `c` to copy the pairing URL. Universal Clipboard takes it
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, so the token and every prompt cross the network
190
- readable by anyone on it. Use it only on a network you control. Anyone with
191
- the token can start sessions as you in your projects. If the URL leaks, press
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
- A remote start may use the `default`, `auto` and `plan` permission modes.
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
- ## Pull request state
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 right end of the header shows how much of your 5-hour and 7-day rate
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
- `session` is the 5-hour window and `week` the 7-day one, as `/usage` names
218
- them. `3h left` is the time until that window resets. The bars need a Pro or
219
- Max account and a one-time setup.
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. Everything else — tests, --fixture,
42
- # anything embedding App — gets one that deletes nothing. The listener,
43
- # likewise, comes only from this file, and only when asked for.
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
- def attach(id)
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
- sleep 1
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