claude-inbox 0.12.3 → 0.13.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +12 -4
- data/exe/claude-inbox +4 -0
- data/lib/claude_inbox/actions.rb +1 -1
- data/lib/claude_inbox/agents_client.rb +11 -27
- data/lib/claude_inbox/app/attaching.rb +1 -1
- data/lib/claude_inbox/app.rb +8 -6
- data/lib/claude_inbox/cli.rb +6 -5
- data/lib/claude_inbox/dialog.rb +8 -7
- data/lib/claude_inbox/headless.rb +6 -3
- data/lib/claude_inbox/images.rb +2 -3
- data/lib/claude_inbox/job_state.rb +4 -13
- data/lib/claude_inbox/new_session_form.rb +10 -18
- data/lib/claude_inbox/palette.rb +2 -2
- data/lib/claude_inbox/poller.rb +3 -2
- data/lib/claude_inbox/pull_requests.rb +15 -23
- data/lib/claude_inbox/reaper.rb +11 -13
- data/lib/claude_inbox/remote/http.rb +6 -6
- data/lib/claude_inbox/remote/start.rb +1 -1
- data/lib/claude_inbox/renderer.rb +12 -14
- data/lib/claude_inbox/session.rb +1 -1
- data/lib/claude_inbox/sessions.rb +3 -1
- data/lib/claude_inbox/slash_commands.rb +3 -10
- data/lib/claude_inbox/snapshot.rb +1 -0
- data/lib/claude_inbox/store/entry.rb +1 -5
- data/lib/claude_inbox/store/row.rb +14 -7
- data/lib/claude_inbox/store.rb +14 -3
- data/lib/claude_inbox/subprocess.rb +2 -4
- data/lib/claude_inbox/terminal.rb +5 -10
- data/lib/claude_inbox/text.rb +5 -4
- data/lib/claude_inbox/text_buffer.rb +13 -15
- data/lib/claude_inbox/transcript.rb +76 -0
- data/lib/claude_inbox/updater.rb +5 -3
- data/lib/claude_inbox/vt_screen.rb +2 -1
- data/lib/claude_inbox.rb +1 -1
- data/mod/hooks/register.tsx +13 -2
- data/mod/tests/contract.test.tsx +1 -0
- data/mod/tests/pane.test.tsx +12 -0
- data/mod/tests/snapshot.fixture.ts +2 -1
- data/mod/types/index.d.ts +1 -0
- metadata +16 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 36fa381f8d03173896c6b44dff47534ed54119c9ff529833a9783c7f5c524506
|
|
4
|
+
data.tar.gz: '0386da94a6ffa5259bbad165857821f44b082dc5a64d957beae352c46dc61100'
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: bb933d5723ee131285824d2faea2570762854a4695da2ecd98716d5b9d981b2b36aece7abbda8acf92fa063b47f52f40ac2931136ef5fecee194f0fda7cdc72c
|
|
7
|
+
data.tar.gz: 3593591f4192056035fdc13e3480a26824794be46df0d8aa5b72fdb5c84a4b6b039d658b4279ec64cdf35ec94c2d65533117321c01bdd9b3e06a4267571a83b6
|
data/README.md
CHANGED
|
@@ -49,7 +49,8 @@ It opens when a session starts. To stop that, set
|
|
|
49
49
|
|
|
50
50
|
## Sections
|
|
51
51
|
|
|
52
|
-
1. **Pinned.** Rows you pinned with `t`.
|
|
52
|
+
1. **Pinned.** Rows you pinned with `t`. Snoozing a pinned row moves it to
|
|
53
|
+
Snoozed, marked `★`, until it wakes back into Pinned.
|
|
53
54
|
2. **Needs you.** Blocked or failed sessions, or ones whose pull request has
|
|
54
55
|
failing checks or changes requested, that you haven't attached to yet.
|
|
55
56
|
3. **Active.** Everything else that's running or not yet settled, including
|
|
@@ -58,6 +59,10 @@ It opens when a session starts. To stop that, set
|
|
|
58
59
|
5. **Settled.** Settled with `x`, or every pull request is merged or closed.
|
|
59
60
|
Collapsed.
|
|
60
61
|
|
|
62
|
+
`--sort=activity` puts the session that spoke most recently first in every
|
|
63
|
+
section but Snoozed, read from its transcript. Put it in the config file to
|
|
64
|
+
keep it.
|
|
65
|
+
|
|
61
66
|
Under each row is what the session is waiting on, what it produced, or its
|
|
62
67
|
status line. A row with a pull request shows it as `#885 open`, `#885 merged`
|
|
63
68
|
and so on, with `✗ checks`, `changes requested` or `conflicts` after it when
|
|
@@ -65,7 +70,9 @@ something is wrong, and `+2` after it when the session has two more.
|
|
|
65
70
|
A steady `◌` with `idle · 1 shell` means the agent stopped
|
|
66
71
|
and is waiting on a shell or sub-agent it started. A blue `●` marks a session you
|
|
67
72
|
haven't attached to since it got blocked or finished; one you have attached
|
|
68
|
-
to since is dimmed, unless `/color` tints it.
|
|
73
|
+
to since is dimmed, unless `/color` tints it. The age after the badge is how
|
|
74
|
+
long since the session last said anything, read from its transcript: a
|
|
75
|
+
`working` row that spoke 30s ago is still talking, one silent for 2h is not.
|
|
69
76
|
|
|
70
77
|
## Keys
|
|
71
78
|
|
|
@@ -93,7 +100,7 @@ Vim bindings. Arrows and the mouse work too.
|
|
|
93
100
|
| `P` | link a pull request by hand (empty clears) |
|
|
94
101
|
| `X` | stop the session; `Enter` resumes it later |
|
|
95
102
|
| `Ctrl-x` | delete the session, transcript and worktree for good |
|
|
96
|
-
| `/` | filter; `Enter` keeps it, `Esc` clears |
|
|
103
|
+
| `/` | filter by name, directory, prompt, status or pull request (`#885` or its title); `Enter` keeps it, `Esc` clears |
|
|
97
104
|
| `R` | poll now |
|
|
98
105
|
| `U` | update claude-inbox when the header says a newer one is out |
|
|
99
106
|
| `q` | quit |
|
|
@@ -154,7 +161,8 @@ covers ssh, the JSON API and an iOS Shortcut.
|
|
|
154
161
|
## Pull requests
|
|
155
162
|
|
|
156
163
|
Pull requests show up when a session's transcript links to them. Install
|
|
157
|
-
`gh` to keep their state current. Use `P` to link one by hand
|
|
164
|
+
`gh` to keep their state current. Use `P` to link one by hand, and `/#885`
|
|
165
|
+
to find the session behind a pull request.
|
|
158
166
|
|
|
159
167
|
## Notifications
|
|
160
168
|
|
data/exe/claude-inbox
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env ruby
|
|
2
2
|
# frozen_string_literal: true
|
|
3
3
|
|
|
4
|
+
# Claude's files are UTF-8 whatever the locale says; under LANG=C a read of
|
|
5
|
+
# ~/.claude.json would otherwise come back US-ASCII and fail to parse.
|
|
6
|
+
Encoding.default_external = Encoding::UTF_8
|
|
7
|
+
|
|
4
8
|
require "claude_inbox"
|
|
5
9
|
require "claude_inbox/cli"
|
|
6
10
|
|
data/lib/claude_inbox/actions.rb
CHANGED
|
@@ -39,7 +39,7 @@ module ClaudeInbox
|
|
|
39
39
|
# Applies every request on disk to `store` and returns how many it took.
|
|
40
40
|
def drain(store)
|
|
41
41
|
return 0 unless @dir && File.directory?(@dir)
|
|
42
|
-
keys = store.
|
|
42
|
+
keys = store.actionable_keys
|
|
43
43
|
Dir.glob(File.join(@dir, "*.json")).sort.count { |path| take(path, store, keys) }
|
|
44
44
|
end
|
|
45
45
|
|
|
@@ -27,7 +27,6 @@ module ClaudeInbox
|
|
|
27
27
|
classify_origins(parse(r.out))
|
|
28
28
|
end
|
|
29
29
|
|
|
30
|
-
# Poll interval for the agents-view watchdog below.
|
|
31
30
|
WATCH_INTERVAL = 0.05
|
|
32
31
|
|
|
33
32
|
# Hands the terminal to the child; caller must have restored cooked mode.
|
|
@@ -35,14 +34,11 @@ module ClaudeInbox
|
|
|
35
34
|
# False when the attach failed on its own, as a session that cannot be
|
|
36
35
|
# woken does: the CLI prints why and exits 1.
|
|
37
36
|
#
|
|
38
|
-
#
|
|
39
|
-
#
|
|
40
|
-
#
|
|
41
|
-
#
|
|
42
|
-
#
|
|
43
|
-
# lands back in the inbox instead of native agent view.
|
|
44
|
-
# The block, polled with the command line, ends the attach early when it
|
|
45
|
-
# answers true: how a switch request from another front end is honoured.
|
|
37
|
+
# ← turns `claude attach` into `claude agents` in place, and nothing
|
|
38
|
+
# switches off only that (docs/cli-quirks.md), so the watchdog kills the
|
|
39
|
+
# child once it does. The block, polled alongside, ends the attach early
|
|
40
|
+
# when it answers true. That is how a switch request from another front
|
|
41
|
+
# end is honoured.
|
|
46
42
|
def attach(id, &stop)
|
|
47
43
|
pid = Process.spawn(*Subprocess.command(@bin, "attach", id))
|
|
48
44
|
watchdog = Thread.new { kill_when_agents_view(pid, &stop) }
|
|
@@ -76,8 +72,7 @@ module ClaudeInbox
|
|
|
76
72
|
# its path completion escapes them; a quoted path is not recognized.
|
|
77
73
|
def self.mention(path) = "@" + path.gsub(" ", "\\ ")
|
|
78
74
|
|
|
79
|
-
#
|
|
80
|
-
# prompt goes after "--", where a leading dash is text rather than an
|
|
75
|
+
# The prompt goes after "--", where a leading dash is text rather than an
|
|
81
76
|
# unknown option, and --remote-control sits just before it: its optional
|
|
82
77
|
# name would otherwise eat the prompt (both checked on 2.1.282).
|
|
83
78
|
def self.spawn_args(bin, prompt:, model: nil, effort: nil, permission_mode: nil, worktree: false, name: nil, remote: false)
|
|
@@ -115,14 +110,9 @@ module ClaudeInbox
|
|
|
115
110
|
# you type in: the headless print mode and the SDK's stream protocol.
|
|
116
111
|
HEADLESS_FLAGS = %w[-p --print --input-format --output-format].freeze
|
|
117
112
|
|
|
118
|
-
# pid => origin, given `ps` for the sessions and for their parents.
|
|
119
|
-
#
|
|
120
|
-
#
|
|
121
|
-
# :headless `claude -p "..."` or an SDK stream run. Its parent is
|
|
122
|
-
# whatever shell spawned it, not the claude that asked for
|
|
123
|
-
# it, so only its own command line gives it away
|
|
124
|
-
# :subagent parented by another claude process
|
|
125
|
-
# :terminal everything else, which is a claude you are sitting in
|
|
113
|
+
# pid => origin, given `ps` for the sessions and for their parents.
|
|
114
|
+
# A headless run's parent is whatever shell spawned it, not the claude
|
|
115
|
+
# that asked for it, so only its own command line gives it away.
|
|
126
116
|
def self.origins(rows, parent_cmd)
|
|
127
117
|
rows.to_h do |pid, ppid, cmd|
|
|
128
118
|
parent = parent_cmd[ppid].to_s
|
|
@@ -143,7 +133,7 @@ module ClaudeInbox
|
|
|
143
133
|
|
|
144
134
|
# pid => bridge session id, for the workers a `claude remote-control`
|
|
145
135
|
# server spawned: each is started with `--session-id cse_…`, the same id
|
|
146
|
-
# a background session records as bridgeSessionId.
|
|
136
|
+
# a background session records as bridgeSessionId.
|
|
147
137
|
def self.bridge_ids(rows)
|
|
148
138
|
rows.filter_map { |pid, _, cmd| (m = cmd.match(/--session-id[= ](cse_\w+)/)) && [pid, m[1]] }.to_h
|
|
149
139
|
end
|
|
@@ -185,10 +175,6 @@ module ClaudeInbox
|
|
|
185
175
|
assign_origins(sessions, origins_from(rows), self.class.bridge_ids(rows))
|
|
186
176
|
end
|
|
187
177
|
|
|
188
|
-
# Tags each interactive session with where it is driven from (pid =>
|
|
189
|
-
# origin, terminal when unlisted) and the bridge a remote one is served
|
|
190
|
-
# over, and drops the unattended ones, whose parent is the row worth
|
|
191
|
-
# showing.
|
|
192
178
|
def assign_origins(sessions, origins, bridges)
|
|
193
179
|
sessions
|
|
194
180
|
.map { |s| s.interactive? ? s.with(origin: origins.fetch(s.pid, :terminal), bridge_id: bridges[s.pid]) : s }
|
|
@@ -259,9 +245,7 @@ module ClaudeInbox
|
|
|
259
245
|
@bridges = bridges
|
|
260
246
|
end
|
|
261
247
|
|
|
262
|
-
# Fixture rows carry no process tree
|
|
263
|
-
# pid => origin map the test hands in, otherwise as a terminal, then drop
|
|
264
|
-
# the unattended ones same as the real client does.
|
|
248
|
+
# Fixture rows carry no process tree.
|
|
265
249
|
def list
|
|
266
250
|
assign_origins(parse(File.read(@path)), @origins, @bridges)
|
|
267
251
|
end
|
|
@@ -35,7 +35,7 @@ module ClaudeInbox
|
|
|
35
35
|
@terminal.release do
|
|
36
36
|
@terminal.pause unless @client.attach(id) { @switch.requested_for?(from) }
|
|
37
37
|
end
|
|
38
|
-
id = @switch.take(@store.
|
|
38
|
+
id = @switch.take(@store.actionable_keys)
|
|
39
39
|
end
|
|
40
40
|
end
|
|
41
41
|
|
data/lib/claude_inbox/app.rb
CHANGED
|
@@ -47,7 +47,7 @@ module ClaudeInbox
|
|
|
47
47
|
|
|
48
48
|
# The reaper deletes sessions and the listener lets other machines in,
|
|
49
49
|
# so both are off unless `bin/claude-inbox` switches them on.
|
|
50
|
-
def initialize(client: AgentsClient.new, store: Store.new, pull_requests: PullRequests.new,
|
|
50
|
+
def initialize(client: AgentsClient.new, store: Store.new, pull_requests: PullRequests.new, transcripts: Transcript.new,
|
|
51
51
|
rate_limits: RateLimits.new, reaper: Reaper.disabled, snapshot: Snapshot.disabled, switch: Switch.disabled,
|
|
52
52
|
actions: Actions.disabled, notifier: Notifier.disabled, listen: nil, updater: nil, argv: [], out: $stdout, input: $stdin, color: true,
|
|
53
53
|
trust: Trust.method(:projects), terminal: Terminal.new(out, input), queue: Queue.new, pane_offer: Mod::Offer.disabled,
|
|
@@ -67,7 +67,7 @@ module ClaudeInbox
|
|
|
67
67
|
@renderer = Renderer.new
|
|
68
68
|
@input = Input.new(input, out)
|
|
69
69
|
@queue = queue
|
|
70
|
-
@poller = Poller.new(client: client, store: store, pull_requests: pull_requests,
|
|
70
|
+
@poller = Poller.new(client: client, store: store, pull_requests: pull_requests, transcripts: transcripts,
|
|
71
71
|
reaper: reaper, queue: @queue, snapshot: snapshot, actions: actions, notifier: notifier)
|
|
72
72
|
@listener = listen ? Remote::Listener.new(client: client, store: store, queue: @queue, **listen) : Remote::Listener.disabled
|
|
73
73
|
@selected = nil
|
|
@@ -87,7 +87,7 @@ module ClaudeInbox
|
|
|
87
87
|
end
|
|
88
88
|
|
|
89
89
|
def run
|
|
90
|
-
@booted_at =
|
|
90
|
+
@booted_at = monotonic
|
|
91
91
|
install_traps
|
|
92
92
|
@terminal.enter
|
|
93
93
|
@poller.start
|
|
@@ -264,7 +264,7 @@ module ClaudeInbox
|
|
|
264
264
|
end
|
|
265
265
|
|
|
266
266
|
def render
|
|
267
|
-
t0 =
|
|
267
|
+
t0 = monotonic
|
|
268
268
|
now = Time.now
|
|
269
269
|
all = @store.sections(now)
|
|
270
270
|
@snapshot.write(all, now)
|
|
@@ -283,7 +283,7 @@ module ClaudeInbox
|
|
|
283
283
|
frame = frame.plain unless @color # --no-color: decided here and nowhere else
|
|
284
284
|
placed(frame)
|
|
285
285
|
@terminal.paint(frame.lines)
|
|
286
|
-
dt =
|
|
286
|
+
dt = monotonic - t0
|
|
287
287
|
Debug.log("render #{(dt * 1000).round}ms") if dt > 0.05
|
|
288
288
|
end
|
|
289
289
|
|
|
@@ -309,11 +309,13 @@ module ClaudeInbox
|
|
|
309
309
|
# already says how to retry.
|
|
310
310
|
def loading_for
|
|
311
311
|
return nil if @last_poll || @error || !@booted_at
|
|
312
|
-
|
|
312
|
+
monotonic - @booted_at
|
|
313
313
|
end
|
|
314
314
|
|
|
315
315
|
def notice(msg)
|
|
316
316
|
@notice = [msg, Time.now + 4]
|
|
317
317
|
end
|
|
318
|
+
|
|
319
|
+
def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
318
320
|
end
|
|
319
321
|
end
|
data/lib/claude_inbox/cli.rb
CHANGED
|
@@ -59,7 +59,7 @@ module ClaudeInbox
|
|
|
59
59
|
opts[:color] = options[:color]
|
|
60
60
|
opts[:notifier] = notifier
|
|
61
61
|
return opts if options[:fixture] || writer_lock.take(role: "inbox")
|
|
62
|
-
opts.merge(store: Store.new(writable: false, relay: Actions.new), reaper: Reaper.disabled,
|
|
62
|
+
opts.merge(store: Store.new(writable: false, relay: Actions.new, sort: options[:sort]), reaper: Reaper.disabled,
|
|
63
63
|
snapshot: Snapshot.disabled, actions: Actions.disabled, pane_offer: Mod::Offer.disabled, notifier: Notifier.disabled)
|
|
64
64
|
end
|
|
65
65
|
|
|
@@ -113,7 +113,7 @@ module ClaudeInbox
|
|
|
113
113
|
|
|
114
114
|
def headless
|
|
115
115
|
abort "claude-inbox: --headless takes no --fixture" if options[:fixture]
|
|
116
|
-
Headless.new(client: AgentsClient.new, store: Store.new, updater: updater, notifier: notifier).run
|
|
116
|
+
Headless.new(client: AgentsClient.new, store: Store.new(sort: options[:sort]), updater: updater, notifier: notifier).run
|
|
117
117
|
end
|
|
118
118
|
|
|
119
119
|
# Only what a check within the day already found: a cold launch never waits
|
|
@@ -132,7 +132,7 @@ module ClaudeInbox
|
|
|
132
132
|
# token phones are paired with nor hold the real inbox's lock.
|
|
133
133
|
dir = File.join(Dir.tmpdir, "claude-inbox-fixture")
|
|
134
134
|
{client: FixtureClient.new(options[:fixture]),
|
|
135
|
-
store: Store.new(path: nil),
|
|
135
|
+
store: Store.new(path: nil, sort: options[:sort]),
|
|
136
136
|
pull_requests: PullRequests.new(cache_path: File.join(fixtures, "gh-pr-status-cache.json"), resolved_path: nil, gh: nil),
|
|
137
137
|
listen: options[:listen]&.merge(fixture: true, pairing: Remote::Pairing.new(path: File.join(dir, "listen.json")),
|
|
138
138
|
lock_path: File.join(dir, "listen.lock"), images_dir: File.join(dir, "images"))}
|
|
@@ -144,7 +144,7 @@ module ClaudeInbox
|
|
|
144
144
|
# comes only from here, and only when asked for.
|
|
145
145
|
def live_options
|
|
146
146
|
client = AgentsClient.new
|
|
147
|
-
store = Store.new
|
|
147
|
+
store = Store.new(sort: options[:sort])
|
|
148
148
|
{client: client, store: store, reaper: options[:reap] ? Reaper.new(client, store) : Reaper.disabled,
|
|
149
149
|
snapshot: Snapshot.new, switch: Switch.new, actions: Actions.new, pane_offer: Mod::Offer.new,
|
|
150
150
|
listen: options[:listen], updater: updater,
|
|
@@ -162,7 +162,7 @@ module ClaudeInbox
|
|
|
162
162
|
def writer_lock = (@lock ||= WriterLock.new)
|
|
163
163
|
|
|
164
164
|
def parse(argv)
|
|
165
|
-
opts = {color: true, update_check: true}
|
|
165
|
+
opts = {color: true, update_check: true, sort: "default"}
|
|
166
166
|
parser = OptionParser.new(USAGE) do |p|
|
|
167
167
|
p.on("--version", "Print the version") {
|
|
168
168
|
@out.puts VERSION
|
|
@@ -176,6 +176,7 @@ module ClaudeInbox
|
|
|
176
176
|
p.on("--headless", "Keep the pane's snapshot fresh without a screen") { opts[:headless] = true }
|
|
177
177
|
p.on("--reap", "Delete background sessions idle 14 days") { opts[:reap] = true }
|
|
178
178
|
p.on("--no-color", "Draw without color") { opts[:color] = false }
|
|
179
|
+
p.on("--sort=ORDER", Store::SORTS, "Order every section by activity, newest message first") { |order| opts[:sort] = order }
|
|
179
180
|
p.on("--notify", "Send a desktop notification when a session needs you or finishes") { opts[:notify] = true }
|
|
180
181
|
p.on("--auto-update", "Install a release found today before launching") { opts[:auto_update] = true }
|
|
181
182
|
p.on("--no-update-check", "Never look for a newer release") { opts[:update_check] = false }
|
data/lib/claude_inbox/dialog.rb
CHANGED
|
@@ -28,11 +28,11 @@ module ClaudeInbox
|
|
|
28
28
|
.split("\n")
|
|
29
29
|
end
|
|
30
30
|
|
|
31
|
+
private
|
|
32
|
+
|
|
31
33
|
# How wide the box wants to be; the frame caps it at the terminal.
|
|
32
34
|
def fit = self.class::WIDTH
|
|
33
35
|
|
|
34
|
-
private
|
|
35
|
-
|
|
36
36
|
def key(name) = @theme.cyan_bold(name)
|
|
37
37
|
|
|
38
38
|
# A key and what it does.
|
|
@@ -83,10 +83,6 @@ module ClaudeInbox
|
|
|
83
83
|
|
|
84
84
|
def title = " #{kind.to_s.capitalize} pull request "
|
|
85
85
|
|
|
86
|
-
# Titles vary, so the box grows to the longest line plus some air,
|
|
87
|
-
# and never shrinks below a plain dialog.
|
|
88
|
-
def fit = [WIDTH, lines(Float::INFINITY, nil).map { |l| Text.width(l) }.max + MARGIN].max
|
|
89
|
-
|
|
90
86
|
def lines(width, _caret)
|
|
91
87
|
(@prs.each_with_index.map { |pr, i| " #{key((i + 1).to_s)} #{label(pr)}" } +
|
|
92
88
|
["", " #{hint(KEYS.fetch(kind), "#{kind} #{@prs.first.short}")} · #{key("esc")} #{@theme.dim("cancel")}"])
|
|
@@ -102,11 +98,16 @@ module ClaudeInbox
|
|
|
102
98
|
|
|
103
99
|
private
|
|
104
100
|
|
|
101
|
+
# Titles vary, so the box grows to the longest line plus some air,
|
|
102
|
+
# and never shrinks below a plain dialog.
|
|
103
|
+
def fit = [WIDTH, lines(Float::INFINITY, nil).map { |l| Text.width(l) }.max + MARGIN].max
|
|
104
|
+
|
|
105
105
|
# The list's badge, then the title. A bare #190 is ambiguous only
|
|
106
106
|
# once the list spans repos.
|
|
107
107
|
def label(pr)
|
|
108
108
|
name = (@prs.map(&:repo).uniq.size > 1) ? pr.qualified : pr.short
|
|
109
|
-
|
|
109
|
+
badge = @theme.pr_badge(pr, name)
|
|
110
|
+
pr.title ? badge + @theme.dim(" · ") + pr.title : badge
|
|
110
111
|
end
|
|
111
112
|
end
|
|
112
113
|
|
|
@@ -17,7 +17,7 @@ module ClaudeInbox
|
|
|
17
17
|
class Headless
|
|
18
18
|
RECHECK_EVERY = 60
|
|
19
19
|
|
|
20
|
-
def initialize(client:, store:, pull_requests: PullRequests.new, actions: Actions.new, snapshot: Snapshot.new,
|
|
20
|
+
def initialize(client:, store:, pull_requests: PullRequests.new, transcripts: Transcript.new, actions: Actions.new, snapshot: Snapshot.new,
|
|
21
21
|
lock: WriterLock.new, updater: nil, notifier: Notifier.disabled, recheck_every: RECHECK_EVERY)
|
|
22
22
|
@lock = lock
|
|
23
23
|
@snapshot = snapshot
|
|
@@ -25,7 +25,7 @@ module ClaudeInbox
|
|
|
25
25
|
@recheck_every = recheck_every
|
|
26
26
|
@notice = nil
|
|
27
27
|
@queue = Queue.new
|
|
28
|
-
@poller = Poller.new(client: client, store: store, pull_requests: pull_requests, reaper: Reaper.disabled,
|
|
28
|
+
@poller = Poller.new(client: client, store: store, pull_requests: pull_requests, transcripts: transcripts, reaper: Reaper.disabled,
|
|
29
29
|
queue: @queue, snapshot: snapshot, actions: actions, notifier: notifier)
|
|
30
30
|
end
|
|
31
31
|
|
|
@@ -33,8 +33,11 @@ module ClaudeInbox
|
|
|
33
33
|
def run
|
|
34
34
|
return false unless @lock.take(role: "headless")
|
|
35
35
|
@poller.start
|
|
36
|
-
Thread.new { watch_for_updates } if @updater
|
|
36
|
+
updates = Thread.new { watch_for_updates } if @updater
|
|
37
37
|
loop { @queue.pop }
|
|
38
|
+
ensure
|
|
39
|
+
@poller.stop
|
|
40
|
+
updates&.kill
|
|
38
41
|
end
|
|
39
42
|
|
|
40
43
|
private
|
data/lib/claude_inbox/images.rb
CHANGED
|
@@ -42,13 +42,12 @@ module ClaudeInbox
|
|
|
42
42
|
# two requests saving in the same millisecond apart. Readable by the
|
|
43
43
|
# owner only: a photo can be anything.
|
|
44
44
|
def self.save(bytes, dir:, index:, now: Time.now)
|
|
45
|
-
|
|
46
|
-
ext = extension(data)
|
|
45
|
+
ext = extension(bytes)
|
|
47
46
|
raise Unsupported, "not a PNG, JPEG, GIF or WebP image" unless ext
|
|
48
47
|
FileUtils.mkdir_p(dir)
|
|
49
48
|
prune(dir, now)
|
|
50
49
|
path = File.join(dir, "#{now.strftime("%Y%m%d-%H%M%S-%L")}-#{index}-#{SecureRandom.hex(3)}#{ext}")
|
|
51
|
-
File.open(path, File::WRONLY | File::CREAT | File::EXCL | File::BINARY, 0o600) { |f| f.write(
|
|
50
|
+
File.open(path, File::WRONLY | File::CREAT | File::EXCL | File::BINARY, 0o600) { |f| f.write(bytes) }
|
|
52
51
|
path
|
|
53
52
|
end
|
|
54
53
|
|
|
@@ -8,17 +8,9 @@ module ClaudeInbox
|
|
|
8
8
|
# ~/.claude/jobs/<id>/state.json. The only reader of that file, and the
|
|
9
9
|
# only writer: a new session's color.
|
|
10
10
|
#
|
|
11
|
-
# `claude agents --json`
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
# The file tells them apart: `tempo` is the agent's own pulse, `fan` names
|
|
15
|
-
# each outstanding piece of work, and `detail` is the session's own status
|
|
16
|
-
# line, with `needs` naming what it is waiting on while blocked and
|
|
17
|
-
# `output.result` what it produced once done. It also carries the PR links
|
|
18
|
-
# the daemon scanned out of the transcript and the transcript's path, which
|
|
19
|
-
# PullRequests reads through here, the color `/color` set on the session,
|
|
20
|
-
# which `claude agents --json` drops, and `intent`, the prompt the session
|
|
21
|
-
# was started from, and `createdAt`, when it was.
|
|
11
|
+
# `claude agents --json` says "working" both while the agent thinks and
|
|
12
|
+
# while it sits idle on a shell or sub-agent it started. This file tells
|
|
13
|
+
# the two apart, and holds what the JSON drops (docs/cli-quirks.md).
|
|
22
14
|
#
|
|
23
15
|
# Never cached. A color changes the moment you type `/color`, so every poll
|
|
24
16
|
# asks the file again.
|
|
@@ -40,7 +32,7 @@ module ClaudeInbox
|
|
|
40
32
|
|
|
41
33
|
# Each background session with its job file read onto `job_state`, or nil
|
|
42
34
|
# when there is none: interactive sessions have no job file, and neither
|
|
43
|
-
# does one the daemon has already forgotten.
|
|
35
|
+
# does one the daemon has already forgotten.
|
|
44
36
|
def self.enrich(sessions, jobs_dir:)
|
|
45
37
|
sessions.map { |s| s.background? ? s.with(job_state: read(s.id, jobs_dir: jobs_dir)) : s }
|
|
46
38
|
end
|
|
@@ -91,7 +83,6 @@ module ClaudeInbox
|
|
|
91
83
|
# a wake, are the only record of Remote Control being on it.
|
|
92
84
|
def remote_control? = flags.include?("--remote-control")
|
|
93
85
|
|
|
94
|
-
# True when the agent has stopped and is only waiting on what it started.
|
|
95
86
|
def waiting_on_work? = agent_idle? && in_flight?
|
|
96
87
|
|
|
97
88
|
# What the session needs while blocked, what it produced once done,
|
|
@@ -12,12 +12,11 @@ require_relative "text_buffer"
|
|
|
12
12
|
require_relative "theme"
|
|
13
13
|
|
|
14
14
|
module ClaudeInbox
|
|
15
|
-
#
|
|
16
|
-
#
|
|
17
|
-
# App wraps them in a box.
|
|
15
|
+
# The `n` form a new session is composed in. Pure: it says what happened
|
|
16
|
+
# and the App acts on it.
|
|
18
17
|
class NewSessionForm
|
|
19
18
|
# `value` is a TextBuffer on :text and :multiline fields and the picked
|
|
20
|
-
# string on :choice ones
|
|
19
|
+
# string on :choice ones.
|
|
21
20
|
Field = Struct.new(:key, :label, :kind, :value, :choices)
|
|
22
21
|
|
|
23
22
|
# A directory the Directory field offers, shown by its shortest
|
|
@@ -42,7 +41,6 @@ module ClaudeInbox
|
|
|
42
41
|
def initialize(cwd:, dirs: [], home: Dir.home, clipboard: Images.method(:from_clipboard))
|
|
43
42
|
@p = Pastel.new(enabled: true)
|
|
44
43
|
@theme = Theme.new
|
|
45
|
-
@palette = Palette.new
|
|
46
44
|
@home = home
|
|
47
45
|
@clipboard = clipboard
|
|
48
46
|
@places = dirs.map { |path| Place.new(path, SessionRequest.label(path, dirs), path.sub(/\A#{Regexp.escape(home)}(?=\/|\z)/, "~")) }
|
|
@@ -72,11 +70,9 @@ module ClaudeInbox
|
|
|
72
70
|
|
|
73
71
|
# => :cancel | :start | :start_and_attach | :changed
|
|
74
72
|
def press(name, raw)
|
|
75
|
-
#
|
|
76
|
-
#
|
|
77
|
-
#
|
|
78
|
-
# it; there is no way to cancel a spawn already under way, so every
|
|
79
|
-
# key is swallowed until the App reports back.
|
|
73
|
+
# A start leaves the form up until the spawn has worked, so a failure
|
|
74
|
+
# can hand the prompt back. A spawn under way can't be cancelled, so
|
|
75
|
+
# every key is swallowed until the App reports back.
|
|
80
76
|
return :changed if @busy
|
|
81
77
|
return confirm_discard_press(name, raw) if @confirm_discard
|
|
82
78
|
@error = nil
|
|
@@ -150,8 +146,7 @@ module ClaudeInbox
|
|
|
150
146
|
@defaults = Settings.defaults(cwd, home: @home)
|
|
151
147
|
end
|
|
152
148
|
|
|
153
|
-
#
|
|
154
|
-
# every choice visible. Exactly `height` lines.
|
|
149
|
+
# The whole body, exactly `height` lines.
|
|
155
150
|
def screen(width, height)
|
|
156
151
|
inner_w = width - 4
|
|
157
152
|
fixed = 3 + 2 + 1 + (@fields.size - 1) + 1
|
|
@@ -190,11 +185,8 @@ module ClaudeInbox
|
|
|
190
185
|
@theme.hints(keys)
|
|
191
186
|
end
|
|
192
187
|
|
|
193
|
-
# The App's `claude --bg` call failed after
|
|
194
|
-
#
|
|
195
|
-
# a permissions error. The composed prompt is still here, so the form
|
|
196
|
-
# reopens on it with the reason shown instead of the App just logging
|
|
197
|
-
# the failure and losing the draft.
|
|
188
|
+
# The App's `claude --bg` call failed after a start. The form shows why,
|
|
189
|
+
# over the prompt it kept.
|
|
198
190
|
def submission_failed(message)
|
|
199
191
|
@busy = false
|
|
200
192
|
@error = message
|
|
@@ -326,7 +318,7 @@ module ClaudeInbox
|
|
|
326
318
|
f.choices.map { |c|
|
|
327
319
|
text = (c == DEFAULT) ? default_text(f) : c
|
|
328
320
|
if c == f.value then (on ? @theme.pill(" #{text} ", :cyan) : @theme.cyan_bold(" #{text} "))
|
|
329
|
-
elsif f.key == :color then
|
|
321
|
+
elsif f.key == :color then Palette.paint(" #{text} ", c)
|
|
330
322
|
else @p.dim(" #{text} ")
|
|
331
323
|
end
|
|
332
324
|
}.join(" ")
|
data/lib/claude_inbox/palette.rb
CHANGED
|
@@ -9,7 +9,7 @@ module ClaudeInbox
|
|
|
9
9
|
# Closing with 39 (default foreground) rather than 0 (reset everything)
|
|
10
10
|
# matters: the label is often already bold or italic and a full reset
|
|
11
11
|
# would strip that back off.
|
|
12
|
-
|
|
12
|
+
module Palette
|
|
13
13
|
ANSI = {
|
|
14
14
|
"red" => 31, "green" => 32, "yellow" => 33,
|
|
15
15
|
"blue" => 34, "purple" => 35, "cyan" => 36
|
|
@@ -22,7 +22,7 @@ module ClaudeInbox
|
|
|
22
22
|
|
|
23
23
|
RESET = "\e[39m"
|
|
24
24
|
|
|
25
|
-
def paint(text, name)
|
|
25
|
+
def self.paint(text, name)
|
|
26
26
|
seq = SEQUENCES[name]
|
|
27
27
|
seq ? seq + text + RESET : text
|
|
28
28
|
end
|
data/lib/claude_inbox/poller.rb
CHANGED
|
@@ -16,12 +16,13 @@ module ClaudeInbox
|
|
|
16
16
|
INTERVAL = 4
|
|
17
17
|
ACTIONS_INTERVAL = 0.5
|
|
18
18
|
|
|
19
|
-
def initialize(client:, store:, pull_requests:, reaper:, queue:, snapshot:, actions:,
|
|
19
|
+
def initialize(client:, store:, pull_requests:, transcripts:, reaper:, queue:, snapshot:, actions:,
|
|
20
20
|
notifier: Notifier.disabled, interval: INTERVAL, clock: -> { Time.now })
|
|
21
21
|
@client = client
|
|
22
22
|
@clock = clock
|
|
23
23
|
@store = store
|
|
24
24
|
@pull_requests = pull_requests
|
|
25
|
+
@transcripts = transcripts
|
|
25
26
|
@reaper = reaper
|
|
26
27
|
@queue = queue
|
|
27
28
|
@snapshot = snapshot
|
|
@@ -54,7 +55,7 @@ module ClaudeInbox
|
|
|
54
55
|
# state moved.
|
|
55
56
|
def once
|
|
56
57
|
now = @clock.call
|
|
57
|
-
sessions = Sessions.load(client: @client, pull_requests: @pull_requests, overrides: @store.pr_overrides)
|
|
58
|
+
sessions = Sessions.load(client: @client, pull_requests: @pull_requests, transcripts: @transcripts, overrides: @store.pr_overrides)
|
|
58
59
|
doomed = @reaper.due(sessions, now)
|
|
59
60
|
@store.hide(doomed)
|
|
60
61
|
publish(sessions)
|
|
@@ -54,25 +54,18 @@ module ClaudeInbox
|
|
|
54
54
|
|
|
55
55
|
# Finds the PRs a session is tied to and keeps their state fresh.
|
|
56
56
|
#
|
|
57
|
-
#
|
|
58
|
-
#
|
|
59
|
-
#
|
|
60
|
-
#
|
|
61
|
-
#
|
|
62
|
-
# The daemon keeps one PR per session, so StackLinks adds the rest of a
|
|
63
|
-
# stack the session opened.
|
|
64
|
-
# Interactive sessions have no job file; for those the store's `pr` override
|
|
65
|
-
# is the only source.
|
|
57
|
+
# The links come from the daemon's scan of each background session's
|
|
58
|
+
# transcript, so a session that merely mentions a PR gets it too. The
|
|
59
|
+
# daemon keeps one PR per session, so StackLinks adds the rest of a stack
|
|
60
|
+
# the session opened. Interactive sessions have no job file; for those the
|
|
61
|
+
# store's `pr` override is the only source.
|
|
66
62
|
#
|
|
67
|
-
#
|
|
68
|
-
#
|
|
69
|
-
#
|
|
70
|
-
#
|
|
71
|
-
# said so, and when, it is written to RESOLVED_PATH and never asked about
|
|
72
|
-
# again. Claude Code's cache never says when, and says nothing at all about
|
|
73
|
-
# most of a link scan's PRs: it only covers PRs its own sessions opened.
|
|
63
|
+
# A merged or closed PR never changes again, so once gh has said so, and
|
|
64
|
+
# when, it goes in RESOLVED_PATH and gh is never asked about it again.
|
|
65
|
+
# Claude Code's cache never says when, and only covers PRs its own
|
|
66
|
+
# sessions opened.
|
|
74
67
|
#
|
|
75
|
-
# `enrich` never touches gh
|
|
68
|
+
# `enrich` never touches gh, since it stands between `claude agents` and
|
|
76
69
|
# the first frame. `refresh` is the slow half, one network round trip per
|
|
77
70
|
# open PR, and the poller calls it after the list has already gone up.
|
|
78
71
|
class PullRequests
|
|
@@ -93,11 +86,10 @@ module ClaudeInbox
|
|
|
93
86
|
@stacks = StackLinks.new
|
|
94
87
|
end
|
|
95
88
|
|
|
96
|
-
# Each session with its `prs` set from what is already known
|
|
97
|
-
#
|
|
98
|
-
#
|
|
99
|
-
#
|
|
100
|
-
# override replaces the scanned list.
|
|
89
|
+
# Each session with its `prs` set from what is already known. The scanned
|
|
90
|
+
# links come off `job_state`, so Sessions.load reads that first.
|
|
91
|
+
# `overrides` maps session key => url for links set by hand; an override
|
|
92
|
+
# replaces the scanned list.
|
|
101
93
|
def enrich(sessions, overrides)
|
|
102
94
|
sessions.map do |s|
|
|
103
95
|
urls = overrides[s.key] ? [overrides[s.key]] : scanned(s.job_state)
|
|
@@ -127,7 +119,7 @@ module ClaudeInbox
|
|
|
127
119
|
end
|
|
128
120
|
end
|
|
129
121
|
|
|
130
|
-
# Parse `gh pr view --json` output.
|
|
122
|
+
# Parse `gh pr view --json` output.
|
|
131
123
|
def self.parse(url, json)
|
|
132
124
|
h = JSON.parse(json)
|
|
133
125
|
state = (h["state"] == "OPEN" && h["isDraft"]) ? "DRAFT" : h["state"]
|
data/lib/claude_inbox/reaper.rb
CHANGED
|
@@ -8,14 +8,13 @@ module ClaudeInbox
|
|
|
8
8
|
# Reaps sessions that have sat idle past Store::REAP_AFTER.
|
|
9
9
|
#
|
|
10
10
|
# The one thing in the inbox that destroys anything without being asked
|
|
11
|
-
# first, so
|
|
12
|
-
#
|
|
13
|
-
#
|
|
11
|
+
# first, so all of it lives here, along with the append-only log that is
|
|
12
|
+
# the last record a session ever existed once `claude rm` has taken its
|
|
13
|
+
# transcript.
|
|
14
14
|
#
|
|
15
|
-
# Unpushed work is safe
|
|
16
|
-
#
|
|
17
|
-
#
|
|
18
|
-
# end of it. Refusals are logged and retried at most daily.
|
|
15
|
+
# Unpushed work is safe: `claude rm` refuses it, and nothing here
|
|
16
|
+
# overrides that (docs/cli-quirks.md). Refusals are logged and retried at
|
|
17
|
+
# most daily.
|
|
19
18
|
class Reaper
|
|
20
19
|
RETRY_AFTER = 24 * 3600
|
|
21
20
|
DEFAULT_LOG = File.join(Dir.home, ".config", "claude-inbox", "reaped.log")
|
|
@@ -27,14 +26,13 @@ module ClaudeInbox
|
|
|
27
26
|
@enabled = enabled
|
|
28
27
|
end
|
|
29
28
|
|
|
30
|
-
# For --fixture runs and tests: selects nothing, deletes nothing
|
|
31
|
-
# needs neither a client nor a store to do it.
|
|
29
|
+
# For --fixture runs and tests: selects nothing, deletes nothing.
|
|
32
30
|
def self.disabled = new(nil, nil, enabled: false)
|
|
33
31
|
|
|
34
|
-
#
|
|
35
|
-
#
|
|
36
|
-
#
|
|
37
|
-
#
|
|
32
|
+
# Returns the keys it deleted, so the caller can drop those rows before
|
|
33
|
+
# they reach the store. Refusals come back as survivors rather than
|
|
34
|
+
# exceptions: one worktree with unpushed commits must not stop the rest
|
|
35
|
+
# of the sweep.
|
|
38
36
|
#
|
|
39
37
|
# Raises if the log cannot be opened, before anything is deleted. No
|
|
40
38
|
# audit trail, no reaping.
|