claude-inbox 0.2.0 → 0.3.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 (39) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +160 -152
  3. data/exe/claude-inbox +23 -4
  4. data/lib/claude_inbox/agents_client.rb +57 -55
  5. data/lib/claude_inbox/app.rb +42 -26
  6. data/lib/claude_inbox/config.rb +24 -0
  7. data/lib/claude_inbox/dialog.rb +3 -1
  8. data/lib/claude_inbox/images.rb +37 -6
  9. data/lib/claude_inbox/job_state.rb +23 -8
  10. data/lib/claude_inbox/keymap.rb +1 -1
  11. data/lib/claude_inbox/new_session_form.rb +29 -39
  12. data/lib/claude_inbox/painter.rb +32 -0
  13. data/lib/claude_inbox/palette.rb +4 -7
  14. data/lib/claude_inbox/peek.rb +2 -2
  15. data/lib/claude_inbox/poller.rb +4 -3
  16. data/lib/claude_inbox/pull_requests.rb +5 -5
  17. data/lib/claude_inbox/records.rb +7 -2
  18. data/lib/claude_inbox/remote/http.rb +137 -0
  19. data/lib/claude_inbox/remote/icon.png +0 -0
  20. data/lib/claude_inbox/remote/icon.svg +8 -0
  21. data/lib/claude_inbox/remote/listener.rb +436 -0
  22. data/lib/claude_inbox/remote/page.html +477 -0
  23. data/lib/claude_inbox/remote/pairing.rb +111 -0
  24. data/lib/claude_inbox/remote/pairing_dialog.rb +93 -0
  25. data/lib/claude_inbox/remote/start.rb +202 -0
  26. data/lib/claude_inbox/remote.rb +10 -0
  27. data/lib/claude_inbox/renderer.rb +57 -57
  28. data/lib/claude_inbox/session.rb +7 -21
  29. data/lib/claude_inbox/session_request.rb +151 -0
  30. data/lib/claude_inbox/sessions.rb +0 -1
  31. data/lib/claude_inbox/settings.rb +19 -7
  32. data/lib/claude_inbox/store.rb +0 -2
  33. data/lib/claude_inbox/subprocess.rb +9 -1
  34. data/lib/claude_inbox/terminal.rb +9 -3
  35. data/lib/claude_inbox/text.rb +7 -1
  36. data/lib/claude_inbox/trust.rb +23 -0
  37. data/lib/claude_inbox/vt_screen.rb +1 -1
  38. data/lib/claude_inbox.rb +1 -1
  39. metadata +14 -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: 7fc36460c912469438541ba91cd22f8c3635bb3fd80b60b695afe148f8230ee0
4
+ data.tar.gz: 105f161459cb30bd1d3751e2a3c926225e5087279b9056bafee46474542bca0f
5
5
  SHA512:
6
- metadata.gz: 7cd8965adf8b34a34b8ee2fb412b8742d3457e2616cc3a0de549f4a9ee7b5fd9385635445e56bf6de37f3eef43da85ce5456df52da0319ac4a2312e223b872ed
7
- data.tar.gz: 39c760a37be19c854d69c98b8e429fa5fcc48ab4bbfe03c00033e78bd217c161a29051074e70dbb98db234d1913f2320187eafcfabb68396a96a95c8eee1f0a3
6
+ metadata.gz: f31e385cff9fb60a06e7bcce8ba965398491ea65c2792ce9d609654a1d0fc5b417dcd147c0962145badf49da6bb0de734a4bcbae75c7055dbb57ec6090889087
7
+ data.tar.gz: 889b409820e77ec9d6b4a51de365f3524eac013b55131ac8de4d2b0eae9e573ab11c9a12c5240aea0ef40c74211893240adc22283a439f187668ce48949766ce
data/README.md CHANGED
@@ -1,9 +1,9 @@
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
+ 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.
7
7
 
8
8
  ![claude-inbox with one working session, one snoozed and the settled section folded](docs/screenshot.png)
9
9
 
@@ -18,51 +18,46 @@ Requires Ruby 3.2+ and a `claude` on PATH with the agents feature. Versions
18
18
  are published to rubygems and listed under GitHub Releases.
19
19
  `claude-inbox --version` prints the installed version.
20
20
 
21
+ Put arguments you want on every launch in `~/.config/claude-inbox/config`.
22
+
21
23
  ## Sections
22
24
 
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.
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.
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.
40
+
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.
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.
52
+
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.
59
55
 
60
56
  ## Keys
61
57
 
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.
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.
66
61
 
67
62
  | Key | Action |
68
63
  |---|---|
@@ -75,7 +70,8 @@ scrolls its own history and clicks do nothing.
75
70
  | `za` `zo` `zc` | toggle / open / close the Snoozed or Settled 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: whether the inbox is listening, and the URL to pair with |
79
75
  | `t` | pin / unpin |
80
76
  | `s` | snooze: `1` 15m · `2` 1h · `3` tomorrow 9am · `4` until woken |
81
77
  | `u` | wake a snoozed session now, or bring back one you settled |
@@ -90,129 +86,142 @@ scrolls its own history and clicks do nothing.
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 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.
98
94
 
99
95
  ### New session
100
96
 
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.
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.
102
+
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.
106
+
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.
113
+
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`.
117
+
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.
127
122
 
128
123
  ## How sessions move
129
124
 
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.
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.
164
155
 
165
156
  ## Remote Control
166
157
 
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.
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.
172
+
173
+ ## Starting sessions from your phone
174
+
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.
177
+
178
+ ![The inbox beside the phone form, pairing and then starting a session](docs/remote-start.gif)
179
+
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.
188
+
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.
193
+
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.
186
197
 
187
198
  ## Pull request state
188
199
 
189
200
  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.
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.
192
203
 
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.
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.
197
206
 
198
- ## Setup
207
+ ## Usage bars
199
208
 
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:
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%.
204
212
 
205
213
  ```
206
214
  session ██░░░░░░░░ 24% · 3h left week ████░░░░░░ 41% · 2d left
207
215
  ```
208
216
 
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.
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.
212
220
 
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:
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:
216
225
 
217
226
  ```bash
218
227
  input=$(cat)
@@ -220,13 +229,12 @@ limits=$(echo "$input" | jq -c '.rate_limits // empty')
220
229
  [ -n "$limits" ] && echo "$limits" > ~/.claude/rate_limits.json.tmp && mv ~/.claude/rate_limits.json.tmp ~/.claude/rate_limits.json
221
230
  ```
222
231
 
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.
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.
227
235
 
228
- The bars stay off until the file exists, and goes off again once the file
229
- is more than fifteen minutes old.
236
+ The bars stay hidden until the file exists, and hide again once it's more
237
+ than fifteen minutes old.
230
238
 
231
239
  ## Contributing
232
240
 
data/exe/claude-inbox CHANGED
@@ -9,24 +9,43 @@ if ARGV.include?("--version")
9
9
  end
10
10
 
11
11
  require "claude_inbox/app"
12
+ require "claude_inbox/config"
13
+ require "tmpdir"
14
+
15
+ fixture = ARGV.include?("--fixture")
16
+ # A fixture run takes the listen flags, for trying the API with curl, but
17
+ # not the environment or the config file, which are for the real inbox.
18
+ begin
19
+ argv = fixture ? ARGV : ClaudeInbox::Config.argv(ARGV)
20
+ listen = ClaudeInbox::Remote::Listener.options(argv, fixture ? {} : ENV)
21
+ rescue ArgumentError => e
22
+ abort "claude-inbox: #{e.message}"
23
+ end
12
24
 
13
25
  # Crash traces go here instead of the alt screen when set.
14
26
  $stderr.reopen(ENV["CLAUDE_INBOX_STDERR"], "a") if ENV["CLAUDE_INBOX_STDERR"]
15
27
 
16
28
  opts = {}
17
- if (i = ARGV.index("--fixture"))
18
- agents = ARGV[i + 1]
29
+ if fixture
30
+ agents = argv[argv.index("--fixture") + 1]
19
31
  fixtures = File.dirname(agents)
20
32
  opts[:client] = ClaudeInbox::FixtureClient.new(agents, logs: File.binread(File.join(fixtures, "logs_raw.txt")))
21
33
  opts[:store] = ClaudeInbox::Store.new(path: nil)
22
34
  opts[:pull_requests] = ClaudeInbox::PullRequests.new(cache_path: File.join(fixtures, "gh-pr-status-cache.json"), resolved_path: nil, gh: nil)
35
+ # Its own token, lock and images, so a test inbox can neither rotate the
36
+ # token phones are paired with nor hold the real inbox's lock.
37
+ dir = File.join(Dir.tmpdir, "claude-inbox-fixture")
38
+ opts[:listen] = listen&.merge(fixture: true, pairing: ClaudeInbox::Remote::Pairing.new(path: File.join(dir, "listen.json")),
39
+ lock_path: File.join(dir, "listen.lock"), images_dir: File.join(dir, "images"))
23
40
  else
24
41
  # The only place the reaper is armed. Everything else — tests, --fixture,
25
- # anything embedding App — gets one that deletes nothing.
42
+ # anything embedding App — gets one that deletes nothing. The listener,
43
+ # likewise, comes only from this file, and only when asked for.
26
44
  opts[:client] = ClaudeInbox::AgentsClient.new
27
45
  opts[:store] = ClaudeInbox::Store.new
28
46
  opts[:reaper] = ClaudeInbox::Reaper.new(opts[:client], opts[:store])
47
+ opts[:listen] = listen
29
48
  end
30
- opts[:color] = false if ARGV.include?("--no-color")
49
+ opts[:color] = false if argv.include?("--no-color")
31
50
 
32
51
  ClaudeInbox::App.new(**opts).run
@@ -46,7 +46,7 @@ module ClaudeInbox
46
46
  # and, the moment it becomes the agents view, terminate it. The user then
47
47
  # lands back in the inbox instead of native agent view.
48
48
  def attach(id)
49
- pid = Process.spawn(@bin, "attach", id)
49
+ pid = Process.spawn(*Subprocess.command(@bin, "attach", id))
50
50
  watchdog = Thread.new { kill_when_agents_view(pid) }
51
51
  _, status = Process.wait2(pid)
52
52
  status
@@ -64,10 +64,7 @@ module ClaudeInbox
64
64
 
65
65
  # Start a background session. Returns its short id.
66
66
  def spawn(prompt:, cwd:, **opts)
67
- argv = self.class.spawn_args(@bin, prompt: prompt, **opts)
68
- r = Subprocess.capture(*argv, chdir: cwd)
69
- raise Error, "claude --bg failed: #{(r.err + r.out).strip}" unless r.success?
70
- r.out[/\b[0-9a-f]{8}\b/] || r.out.strip
67
+ start_bg(self.class.spawn_args(@bin, prompt: prompt, **opts), cwd, "claude --bg")
71
68
  end
72
69
 
73
70
  # How a prompt attaches a file: the same @ mention the CLI's own prompt
@@ -75,18 +72,19 @@ module ClaudeInbox
75
72
  # its path completion escapes them; a quoted path is not recognized.
76
73
  def self.mention(path) = "@" + path.gsub(" ", "\\ ")
77
74
 
78
- # Pure so it can be tested: "default" means leave the flag off.
79
- # --remote-control takes an optional name and eats whatever follows it,
80
- # prompt included, so it goes last.
75
+ # Pure so it can be tested: a setting left nil leaves its flag off. The
76
+ # prompt goes after "--", where a leading dash is text rather than an
77
+ # unknown option, and --remote-control sits just before it: its optional
78
+ # name would otherwise eat the prompt (both checked on 2.1.282).
81
79
  def self.spawn_args(bin, prompt:, model: nil, effort: nil, permission_mode: nil, worktree: false, name: nil, remote: false)
82
- argv = [bin, "--bg", prompt]
83
- argv += ["--model", model] if model && model != "default"
84
- argv += ["--effort", effort] if effort && effort != "default"
85
- argv += ["--permission-mode", permission_mode] if permission_mode && permission_mode != "default"
80
+ argv = [bin, "--bg"]
81
+ {"--model" => model, "--effort" => effort, "--permission-mode" => permission_mode}.each do |flag, value|
82
+ argv += [flag, value] if value
83
+ end
86
84
  argv += ["--name", name] if name && !name.strip.empty?
87
85
  argv << "--worktree" if worktree
88
86
  argv << "--remote-control" if remote
89
- argv
87
+ argv + ["--", prompt]
90
88
  end
91
89
 
92
90
  # Pull a conversation a `claude remote-control` server is serving into
@@ -98,9 +96,7 @@ module ClaudeInbox
98
96
  raise Error, "nothing to adopt yet — send it a message from your phone first" if transcript_empty?(cwd, session_id)
99
97
  Process.kill("TERM", pid)
100
98
  wait_gone(pid)
101
- r = Subprocess.capture(*self.class.adopt_args(@bin, session_id), chdir: cwd)
102
- raise Error, "claude --bg --resume failed: #{(r.err + r.out).strip}" unless r.success?
103
- r.out[/\b[0-9a-f]{8}\b/] || r.out.strip
99
+ start_bg(self.class.adopt_args(@bin, session_id), cwd, "claude --bg --resume")
104
100
  end
105
101
 
106
102
  def self.adopt_args(bin, session_id) = [bin, "--bg", "--resume", session_id, "--remote-control"]
@@ -111,6 +107,45 @@ module ClaudeInbox
111
107
  File.join(home, ".claude", "projects", cwd.gsub(%r{[/.]}, "-"), "#{session_id}.jsonl")
112
108
  end
113
109
 
110
+ # Flags that mark a claude process as one a program drives rather than one
111
+ # you type in: the headless print mode and the SDK's stream protocol.
112
+ HEADLESS_FLAGS = %w[-p --print --input-format --output-format].freeze
113
+
114
+ # pid => origin, given `ps` for the sessions and for their parents. Pure.
115
+ #
116
+ # :remote a Remote Control worker: --sdk-url, or a `claude rc` parent
117
+ # :headless `claude -p "..."` or an SDK stream run. Its parent is
118
+ # whatever shell spawned it, not the claude that asked for
119
+ # it, so only its own command line gives it away
120
+ # :subagent parented by another claude process
121
+ # :terminal everything else, which is a claude you are sitting in
122
+ def self.origins(rows, parent_cmd)
123
+ rows.to_h do |pid, ppid, cmd|
124
+ parent = parent_cmd[ppid].to_s
125
+ origin =
126
+ if cmd.include?("--sdk-url") || parent.match?(/\bclaude rc\b/) then :remote
127
+ elsif headless?(cmd) then :headless
128
+ elsif parent.match?(/(^|\/)claude\b/) then :subagent
129
+ else :terminal
130
+ end
131
+ [pid, origin]
132
+ end
133
+ end
134
+
135
+ # `ps` flattens quoting, so a prompt that mentions a flag reads the same
136
+ # as the flag itself. Nobody types `claude "what does -p do"` into a
137
+ # terminal often enough to matter; the false positive is accepted.
138
+ def self.headless?(cmd) = cmd.split.drop(1).any? { |arg| HEADLESS_FLAGS.include?(arg) }
139
+
140
+ # pid => bridge session id, for the workers a `claude remote-control`
141
+ # server spawned: each is started with `--session-id cse_…`, the same id
142
+ # a background session records as bridgeSessionId. Pure.
143
+ def self.bridge_ids(rows)
144
+ rows.filter_map { |pid, _, cmd| (m = cmd.match(/--session-id[= ](cse_\w+)/)) && [pid, m[1]] }.to_h
145
+ end
146
+
147
+ private
148
+
114
149
  # A worker nobody has messaged yet has an empty file, and the daemon
115
150
  # reports "source session not found" on resuming it.
116
151
  def transcript_empty?(cwd, session_id)
@@ -133,10 +168,6 @@ module ClaudeInbox
133
168
  JSON.parse(json).map { |h| Session.from_hash(h) }
134
169
  end
135
170
 
136
- # Flags that mark a claude process as one a program drives rather than one
137
- # you type in: the headless print mode and the SDK's stream protocol.
138
- HEADLESS_FLAGS = %w[-p --print --input-format --output-format].freeze
139
-
140
171
  # The JSON reports Remote Control workers, local sub-agents and headless
141
172
  # runs as `interactive`, same as a terminal you opened yourself, each named
142
173
  # after its directory. The process tree tells them apart; see `origins`.
@@ -160,39 +191,6 @@ module ClaudeInbox
160
191
  .reject(&:unattended?)
161
192
  end
162
193
 
163
- # pid => origin, given `ps` for the sessions and for their parents. Pure.
164
- #
165
- # :remote a Remote Control worker: --sdk-url, or a `claude rc` parent
166
- # :headless `claude -p "..."` or an SDK stream run. Its parent is
167
- # whatever shell spawned it, not the claude that asked for
168
- # it, so only its own command line gives it away
169
- # :subagent parented by another claude process
170
- # :terminal everything else, which is a claude you are sitting in
171
- def self.origins(rows, parent_cmd)
172
- rows.to_h do |pid, ppid, cmd|
173
- parent = parent_cmd[ppid].to_s
174
- origin =
175
- if cmd.include?("--sdk-url") || parent.match?(/\bclaude rc\b/) then :remote
176
- elsif headless?(cmd) then :headless
177
- elsif parent.match?(/(^|\/)claude\b/) then :subagent
178
- else :terminal
179
- end
180
- [pid, origin]
181
- end
182
- end
183
-
184
- # `ps` flattens quoting, so a prompt that mentions a flag reads the same
185
- # as the flag itself. Nobody types `claude "what does -p do"` into a
186
- # terminal often enough to matter; the false positive is accepted.
187
- def self.headless?(cmd) = cmd.split.drop(1).any? { |arg| HEADLESS_FLAGS.include?(arg) }
188
-
189
- # pid => bridge session id, for the workers a `claude remote-control`
190
- # server spawned: each is started with `--session-id cse_…`, the same id
191
- # a background session records as bridgeSessionId. Pure.
192
- def self.bridge_ids(rows)
193
- rows.filter_map { |pid, _, cmd| (m = cmd.match(/--session-id[= ](cse_\w+)/)) && [pid, m[1]] }.to_h
194
- end
195
-
196
194
  def origins_from(rows)
197
195
  return {} if rows.empty?
198
196
  self.class.origins(rows, ps_commands(rows.map { |_, ppid, _| ppid }.uniq))
@@ -220,8 +218,6 @@ module ClaudeInbox
220
218
  }
221
219
  end
222
220
 
223
- private
224
-
225
221
  def kill_when_agents_view(pid)
226
222
  loop do
227
223
  sleep WATCH_INTERVAL
@@ -243,6 +239,12 @@ module ClaudeInbox
243
239
  raise Error, "#{argv[1]} failed: #{r.err.strip}" unless r.success?
244
240
  true
245
241
  end
242
+
243
+ def start_bg(argv, cwd, what)
244
+ r = Subprocess.capture(*argv, chdir: cwd)
245
+ raise Error, "#{what} failed: #{(r.err + r.out).strip}" unless r.success?
246
+ r.out[/\b[0-9a-f]{8}\b/] || r.out.strip
247
+ end
246
248
  end
247
249
 
248
250
  # Reads a committed JSON fixture instead of the daemon.