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.
Files changed (60) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +107 -177
  3. data/exe/claude-inbox +67 -4
  4. data/lib/claude_inbox/actions.rb +91 -0
  5. data/lib/claude_inbox/agents_client.rb +68 -62
  6. data/lib/claude_inbox/app.rb +88 -38
  7. data/lib/claude_inbox/config.rb +24 -0
  8. data/lib/claude_inbox/dialog.rb +3 -1
  9. data/lib/claude_inbox/headless.rb +31 -0
  10. data/lib/claude_inbox/images.rb +37 -6
  11. data/lib/claude_inbox/job_state.rb +25 -10
  12. data/lib/claude_inbox/keymap.rb +1 -3
  13. data/lib/claude_inbox/mod.rb +71 -0
  14. data/lib/claude_inbox/new_session_form.rb +43 -52
  15. data/lib/claude_inbox/painter.rb +31 -0
  16. data/lib/claude_inbox/palette.rb +4 -7
  17. data/lib/claude_inbox/paste.rb +2 -0
  18. data/lib/claude_inbox/peek.rb +2 -2
  19. data/lib/claude_inbox/poller.rb +48 -24
  20. data/lib/claude_inbox/pull_requests.rb +36 -22
  21. data/lib/claude_inbox/rate_limits.rb +1 -3
  22. data/lib/claude_inbox/records.rb +7 -2
  23. data/lib/claude_inbox/remote/http.rb +140 -0
  24. data/lib/claude_inbox/remote/icon.png +0 -0
  25. data/lib/claude_inbox/remote/icon.svg +8 -0
  26. data/lib/claude_inbox/remote/listener.rb +435 -0
  27. data/lib/claude_inbox/remote/page.html +477 -0
  28. data/lib/claude_inbox/remote/pairing.rb +111 -0
  29. data/lib/claude_inbox/remote/pairing_dialog.rb +93 -0
  30. data/lib/claude_inbox/remote/start.rb +201 -0
  31. data/lib/claude_inbox/remote.rb +10 -0
  32. data/lib/claude_inbox/renderer.rb +61 -61
  33. data/lib/claude_inbox/session.rb +7 -21
  34. data/lib/claude_inbox/session_request.rb +151 -0
  35. data/lib/claude_inbox/sessions.rb +0 -1
  36. data/lib/claude_inbox/settings.rb +20 -7
  37. data/lib/claude_inbox/slash_commands.rb +1 -0
  38. data/lib/claude_inbox/snapshot.rb +48 -0
  39. data/lib/claude_inbox/store/entry.rb +0 -2
  40. data/lib/claude_inbox/store/row.rb +7 -3
  41. data/lib/claude_inbox/store.rb +24 -11
  42. data/lib/claude_inbox/subprocess.rb +9 -1
  43. data/lib/claude_inbox/switch.rb +40 -0
  44. data/lib/claude_inbox/terminal.rb +9 -3
  45. data/lib/claude_inbox/text.rb +7 -1
  46. data/lib/claude_inbox/text_buffer.rb +84 -33
  47. data/lib/claude_inbox/trust.rb +23 -0
  48. data/lib/claude_inbox/vt_screen.rb +1 -1
  49. data/lib/claude_inbox/writer_lock.rb +65 -0
  50. data/lib/claude_inbox.rb +1 -10
  51. data/mod/.claude-plugin/plugin.json +20 -0
  52. data/mod/hooks/hooks.json +1 -0
  53. data/mod/hooks/register.tsx +470 -0
  54. data/mod/tests/contract.test.tsx +23 -0
  55. data/mod/tests/pane.test.tsx +199 -0
  56. data/mod/tests/snapshot.fixture.ts +47 -0
  57. data/mod/tests/snooze.test.tsx +31 -0
  58. data/mod/tsconfig.json +3 -0
  59. data/mod/types/index.d.ts +30 -0
  60. metadata +29 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 76328d9652ac26483494547c0b26c4099a5c8996816743c9b88b21dfebff0def
4
- data.tar.gz: 15d80ce8e007a533cf70fe35a6215d148fe9f48925fb9b51b50a9913f43ebc9c
3
+ metadata.gz: 6c29f6bef2ba7ecc7f4d63161cfc064517e21f95a3a187313a1c5b882ae34786
4
+ data.tar.gz: 5195d163ef619f7f82a7224ca0a9b4dfe508fd23d8c1b3d87ab0a7b3b419cb28
5
5
  SHA512:
6
- metadata.gz: 7cd8965adf8b34a34b8ee2fb412b8742d3457e2616cc3a0de549f4a9ee7b5fd9385635445e56bf6de37f3eef43da85ce5456df52da0319ac4a2312e223b872ed
7
- data.tar.gz: 39c760a37be19c854d69c98b8e429fa5fcc48ab4bbfe03c00033e78bd217c161a29051074e70dbb98db234d1913f2320187eafcfabb68396a96a95c8eee1f0a3
6
+ metadata.gz: 9b051d9eeba2b19da5c9ced63f9dcd00030619cc4c7ac667f873da8db411ecf3c777352d2b373dfb7def00c9737c6d1ed0bc8edb0663b0a73d850c01e429d20d
7
+ data.tar.gz: cfe120a05f387965ba538ea48dbf726ad1bf379684b1515d213bead19d02803be8030b14abd938457671c6d80fe0d65ce1de1715632db8c304b768a5e6d46ae8
data/README.md CHANGED
@@ -1,218 +1,156 @@
1
1
  # claude-inbox
2
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 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
  ![claude-inbox with one working session, one snoozed and the settled section folded](docs/screenshot.png)
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
- 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.
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
+ ![The inbox pane docked beside a Claude Code transcript](docs/pane.png)
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.** Parked at the top by hand, regardless of state. `t` toggles it.
24
- 2. **Needs you.** `blocked` or `failed`, until you attach to it (see
25
- Acknowledge below).
26
- 3. **Active.** `working`, recently finished sessions that haven't settled yet,
27
- interactive sessions, and a needs-you session you've attached to that
28
- hasn't resolved. Interactive sessions report only `status`, so it gets
29
- mapped: busy is working, idle is done, waiting needs you. The daemon calls
30
- a terminal you opened yourself, a Remote Control worker, a sub-agent and a
31
- headless `claude -p` run all "interactive"; the inbox tells them apart from
32
- the process tree. Sub-agents and headless runs are dropped, since nobody is
33
- sitting in them. Remote sessions settle and snooze like any other row,
34
- and Enter pulls one into the daemon (see Remote Control below). A
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; arrows work as well as `j`/`k`. The mouse works too, in
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 (full-screen handoff; `←` or `Ctrl+Z` return here), adopt a remote session, or expand Snoozed / Settled |
73
- | `Tab` `Shift+Tab` | jump to the next / previous section |
74
- | `h` | close the peek pane, else collapse the current Snoozed / Settled fold |
75
- | `za` `zo` `zc` | toggle / open / close the Snoozed or Settled fold under the cursor |
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 (see below) |
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 now, or bring back one you settled |
82
- | `x` | settle a working or needs-you session by hand (returns when it changes state, unless it just finished) |
83
- | `a` | set a local alias (never touches the real session name) |
84
- | `o` | open the session's pull request in the browser |
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; the scanned links return) |
87
- | `X` | stop the session (`y` to confirm) |
88
- | `Ctrl-x` | delete the session for good, conversation and worktree with it (`y` to confirm) |
89
- | `/` | filter by name, cwd, the prompt it started from, or what it is doing now; `Enter` keeps it, `Esc` clears |
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 first, and both take `y`, but they are not the same
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: prompt, name, directory, model, effort, permissions,
102
- worktree, Remote Control. `Tab` / `Shift+Tab` move between fields, `Esc` cancels. Choice
103
- fields cycle with `h` `l` or the arrows; "default" shows what your settings
104
- resolve to. The directory defaults to the selected row's, and `Tab` completes
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
- **Wake.** A snoozed session returns when its timer elapses, when you press
131
- `u`, or when it becomes blocked or failed after being snoozed. A session that
132
- was already blocked when you snoozed it stays snoozed; that is the point of
133
- snoozing. `u` also brings back a settled row and puts it wherever its raw
134
- state belongs.
135
-
136
- **Acknowledge.** Attaching to a needs-you session marks its current state
137
- seen and moves it to Active. It comes back to Needs You when its state
138
- changes again. Still blocked with a new prompt doesn't count.
139
-
140
- **Settle.** Settled by hand with `x`, or because every pull request it has is
141
- merged or closed. A hand-settled session stays put when it finishes, and
142
- returns as soon as it changes state in any other way. `failed` never settles.
143
- A session with no pull request never settles on its own, however long it has
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
- Every background session has a page at claude.ai/code, where you can follow
168
- it from a browser or the Claude mobile app; `w` opens it. A session with
169
- Remote Control on can also be driven from there, the way `claude
170
- --remote-control` and `/rc` allow in a terminal. Its row wears a `⇅` after
171
- the state. Two kinds of session have it:
172
-
173
- - A background session the `n` form started with Remote Control set to yes,
174
- or one you ran as `claude --bg "…" --remote-control` yourself. It is a
175
- daemon row like any other: `Enter` attaches, `X` stops, and it keeps its
176
- Remote Control across a stop and a wake, since the daemon saves the flag.
177
- - A worker a `claude remote-control` server spawned for a request from your
178
- phone. That is how a session started away from the desk lands on this
179
- machine. The daemon calls it interactive and can't attach to it, so
180
- `Enter` offers to adopt it instead: `y` ends the worker and resumes the
181
- conversation as a background session under the same id, with Remote
182
- Control on, and attaches. The phone session you were in ends there and a
183
- new one takes its place, with the whole conversation; the server keeps
184
- running for the next request. A worker nobody has messaged yet has
185
- nothing to adopt, and Enter says so.
186
-
187
- ## Pull request state
188
-
189
- The daemon scans each background session's transcript for PR links, so a
190
- session that only reviews a PR gets it too. Interactive sessions aren't
191
- scanned; `P` sets the link by hand for those, or to correct a bad scan.
192
-
193
- State comes from whatever Claude Code last saw, then `gh pr view` on the
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
+ ![The inbox beside the phone form, pairing and then starting a session](docs/remote-start.gif)
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
- `session` is the 5-hour window and `week` the 7-day one, as Claude's own
210
- `/usage` names them; `3h left` is how long until that window resets. It needs
211
- a Pro or Max account and a one-time setup.
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 (i = ARGV.index("--fixture"))
18
- agents = ARGV[i + 1]
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
- opts[:color] = false if ARGV.include?("--no-color")
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