claude-inbox 0.12.2 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +12 -4
- 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 +2 -2
- data/lib/claude_inbox/cli.rb +6 -5
- data/lib/claude_inbox/dialog.rb +2 -1
- data/lib/claude_inbox/headless.rb +2 -2
- 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 +1 -2
- 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 +6 -6
- data/lib/claude_inbox/session.rb +1 -1
- data/lib/claude_inbox/sessions.rb +3 -1
- data/lib/claude_inbox/snapshot.rb +1 -0
- data/lib/claude_inbox/store/entry.rb +4 -11
- data/lib/claude_inbox/store/row.rb +13 -11
- data/lib/claude_inbox/store.rb +14 -3
- data/lib/claude_inbox/text.rb +5 -4
- data/lib/claude_inbox/text_buffer.rb +9 -13
- data/lib/claude_inbox/transcript.rb +76 -0
- 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 +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f8b8a6141fcc9efc19386143e9412d4ebab669e73350ee42205e6a07fd05b290
|
|
4
|
+
data.tar.gz: 412882713110008db6ff1d96fe8b786e5d61e82ea9b349202c12560f290a86b3
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: f87a453630132a470dc22b345e8f611ae0e58f04bc877c52ee1c53c4358a306377529a10ae8d807058dc32e1c9d039fc2480dd962a89d382a16e00d35faf6908
|
|
7
|
+
data.tar.gz: de74aeac784fdc777336fdaa4db38cfae93e7458873809e2c3e52c0a5a69dc53d2391cec86d73e2d52882ad9634c4567553f3d6151e2c40c571a7eca0abc8db5
|
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/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
|
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
|
@@ -106,7 +106,8 @@ module ClaudeInbox
|
|
|
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
|
|
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,
|
|
@@ -42,7 +42,6 @@ module ClaudeInbox
|
|
|
42
42
|
def initialize(cwd:, dirs: [], home: Dir.home, clipboard: Images.method(:from_clipboard))
|
|
43
43
|
@p = Pastel.new(enabled: true)
|
|
44
44
|
@theme = Theme.new
|
|
45
|
-
@palette = Palette.new
|
|
46
45
|
@home = home
|
|
47
46
|
@clipboard = clipboard
|
|
48
47
|
@places = dirs.map { |path| Place.new(path, SessionRequest.label(path, dirs), path.sub(/\A#{Regexp.escape(home)}(?=\/|\z)/, "~")) }
|
|
@@ -326,7 +325,7 @@ module ClaudeInbox
|
|
|
326
325
|
f.choices.map { |c|
|
|
327
326
|
text = (c == DEFAULT) ? default_text(f) : c
|
|
328
327
|
if c == f.value then (on ? @theme.pill(" #{text} ", :cyan) : @theme.cyan_bold(" #{text} "))
|
|
329
|
-
elsif f.key == :color then
|
|
328
|
+
elsif f.key == :color then Palette.paint(" #{text} ", c)
|
|
330
329
|
else @p.dim(" #{text} ")
|
|
331
330
|
end
|
|
332
331
|
}.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.
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "../text"
|
|
4
|
+
|
|
3
5
|
module ClaudeInbox
|
|
4
6
|
module Remote
|
|
5
7
|
# The little of HTTP/1.1 the listener speaks: one request per
|
|
@@ -42,7 +44,10 @@ module ClaudeInbox
|
|
|
42
44
|
@details = details
|
|
43
45
|
end
|
|
44
46
|
|
|
45
|
-
|
|
47
|
+
# The JSON generator warns on stderr, which is the terminal, about a
|
|
48
|
+
# binary string and raises on a bad byte, and a message can quote CLI
|
|
49
|
+
# output or request bytes.
|
|
50
|
+
def body = {error: Text.utf8(message), **details.transform_values { |v| v.is_a?(Integer) ? v : Text.utf8(v) }}
|
|
46
51
|
|
|
47
52
|
private
|
|
48
53
|
|
|
@@ -80,11 +85,6 @@ module ClaudeInbox
|
|
|
80
85
|
|
|
81
86
|
def self.monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
82
87
|
|
|
83
|
-
# The JSON generator warns on stderr, which is the terminal, about a
|
|
84
|
-
# binary string and raises on a bad byte, and a message can quote CLI
|
|
85
|
-
# output or request bytes.
|
|
86
|
-
def self.utf8(text) = String.new(text.to_s, encoding: Encoding::UTF_8).scrub("\uFFFD")
|
|
87
|
-
|
|
88
88
|
# `deadline` (on the monotonic clock) bounds the whole head, not each
|
|
89
89
|
# read: a per-read timeout lets a byte every few seconds hold the
|
|
90
90
|
# connection for ever.
|
|
@@ -158,7 +158,7 @@ module ClaudeInbox
|
|
|
158
158
|
values[:prompt] = SessionRequest.attach(values[:prompt], paths)
|
|
159
159
|
values, note = SessionRequest.without_stray_worktree(values)
|
|
160
160
|
@queue << [:notice, "remote: starting session…"]
|
|
161
|
-
id =
|
|
161
|
+
id = Text.utf8(@spawn_lock.synchronize { @client.spawn(**values) })
|
|
162
162
|
url = remote_url(id, wait: values[:remote] ? @bridge_wait : 0)
|
|
163
163
|
@queue << [:remote_started, id, via, note].compact
|
|
164
164
|
{id: id, name: values[:name], cwd: values[:cwd], url: url}.merge(note ? {note: note} : {})
|
|
@@ -65,7 +65,6 @@ module ClaudeInbox
|
|
|
65
65
|
def initialize(home: Dir.home)
|
|
66
66
|
@p = Pastel.new(enabled: true)
|
|
67
67
|
@theme = Theme.new
|
|
68
|
-
@palette = Palette.new
|
|
69
68
|
@home = home
|
|
70
69
|
end
|
|
71
70
|
|
|
@@ -354,7 +353,7 @@ module ClaudeInbox
|
|
|
354
353
|
elsif bold then @p.bold(label)
|
|
355
354
|
else label
|
|
356
355
|
end
|
|
357
|
-
|
|
356
|
+
Palette.paint(styled, row.session.color)
|
|
358
357
|
end
|
|
359
358
|
|
|
360
359
|
def glyph_for(s, section, tick)
|
|
@@ -375,16 +374,17 @@ module ClaudeInbox
|
|
|
375
374
|
base =
|
|
376
375
|
case section
|
|
377
376
|
when :snoozed
|
|
378
|
-
row.parked? ? @theme.purple("parked") : @theme.purple("wakes in #{Text.age(row.wake_at.to_i - now.to_i)}")
|
|
377
|
+
wake = row.parked? ? @theme.purple("parked") : @theme.purple("wakes in #{Text.age(row.wake_at.to_i - now.to_i)}")
|
|
378
|
+
row.pinned? ? wake + @theme.cyan(" ★") : wake
|
|
379
379
|
when :settled
|
|
380
|
-
@p.dim("#{s.state} · #{Text.age(now.to_i - row.
|
|
380
|
+
@p.dim("#{s.state} · #{Text.age(now.to_i - row.spoke_at)}")
|
|
381
381
|
else
|
|
382
382
|
if s.interactive?
|
|
383
383
|
where = s.remote? ? "remote" : "your terminal"
|
|
384
|
-
age =
|
|
384
|
+
age = Text.age(now.to_i - row.spoke_at)
|
|
385
385
|
state_badge(s) + @p.dim(" · #{where} · #{age}")
|
|
386
386
|
else
|
|
387
|
-
age =
|
|
387
|
+
age = @p.dim(" · " + Text.age(now.to_i - row.spoke_at))
|
|
388
388
|
remote = s.remote_control? ? @theme.blue(" ⇅") : ""
|
|
389
389
|
state_badge(s) + remote + age
|
|
390
390
|
end
|
data/lib/claude_inbox/session.rb
CHANGED
|
@@ -10,7 +10,7 @@ module ClaudeInbox
|
|
|
10
10
|
# move under it.
|
|
11
11
|
Session = Data.define(
|
|
12
12
|
:id, :cwd, :kind, :started_at, :session_id, :name,
|
|
13
|
-
:state, :pid, :status, :waiting_for, :origin, :prs, :job_state, :bridge_id
|
|
13
|
+
:state, :pid, :status, :waiting_for, :origin, :prs, :job_state, :bridge_id, :last_message_at
|
|
14
14
|
) do
|
|
15
15
|
# Every member is optional so the parser and the specs name only what they
|
|
16
16
|
# have; prs is [] rather than nil so nobody asks whether PullRequests has run.
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "job_state"
|
|
4
|
+
require_relative "transcript"
|
|
4
5
|
|
|
5
6
|
module ClaudeInbox
|
|
6
7
|
# Puts the list together. JobState goes before PullRequests because the PR
|
|
@@ -9,8 +10,9 @@ module ClaudeInbox
|
|
|
9
10
|
module Sessions
|
|
10
11
|
module_function
|
|
11
12
|
|
|
12
|
-
def load(client:, pull_requests:, overrides:)
|
|
13
|
+
def load(client:, pull_requests:, transcripts:, overrides:)
|
|
13
14
|
sessions = JobState.enrich(client.list, jobs_dir: client.jobs_dir)
|
|
15
|
+
sessions = transcripts.enrich(sessions)
|
|
14
16
|
pull_requests.enrich(sessions, overrides)
|
|
15
17
|
end
|
|
16
18
|
end
|
|
@@ -45,6 +45,7 @@ module ClaudeInbox
|
|
|
45
45
|
{
|
|
46
46
|
"id" => row.key, "session" => s.session_id, "label" => row.label, "state" => s.effective_state, "actionable" => s.actionable?,
|
|
47
47
|
"waiting" => s.waiting_on_work? || nil, "unread" => row.unread? || nil, "line" => s.summary, "wake_at" => row.wake_at, "remote" => s.remote_url, "color" => s.color,
|
|
48
|
+
"last_message_at" => s.last_message_at&.to_i,
|
|
48
49
|
"pr" => pr && {"short" => pr.short, "state" => pr.state&.downcase, "url" => pr.url}
|
|
49
50
|
}.compact
|
|
50
51
|
end
|
|
@@ -26,11 +26,7 @@ module ClaudeInbox
|
|
|
26
26
|
when Integer then choice
|
|
27
27
|
when :m15 then now.to_i + 15 * 60
|
|
28
28
|
when :h1 then now.to_i + 3600
|
|
29
|
-
when :tomorrow_9am
|
|
30
|
-
t = Time.at(now.to_i)
|
|
31
|
-
t9 = Time.new(t.year, t.month, t.day, 9, 0, 0, t.utc_offset)
|
|
32
|
-
t9 += 86_400 if t9 <= t
|
|
33
|
-
t9.to_i
|
|
29
|
+
when :tomorrow_9am then SnoozeTime.parse("9am", now)
|
|
34
30
|
when :until_woken then UNTIL_WOKEN
|
|
35
31
|
else raise ArgumentError, "unknown snooze #{choice.inspect}"
|
|
36
32
|
end
|
|
@@ -50,8 +46,6 @@ module ClaudeInbox
|
|
|
50
46
|
|
|
51
47
|
def parked? = wake_at == UNTIL_WOKEN
|
|
52
48
|
|
|
53
|
-
def snoozed_at = @h["snoozed_at"]
|
|
54
|
-
|
|
55
49
|
def last_state = @h["last_state"]
|
|
56
50
|
|
|
57
51
|
def state_since = @h["state_since"]
|
|
@@ -82,6 +76,9 @@ module ClaudeInbox
|
|
|
82
76
|
def observe(session, now)
|
|
83
77
|
now_i = now.to_i
|
|
84
78
|
if last_state != session.effective_state
|
|
79
|
+
# Only a change of state wakes a snooze early, not trouble on the
|
|
80
|
+
# PR: a session blocked when you snoozed it stays snoozed.
|
|
81
|
+
@h.delete("wake_at") if last_state && session.needs_you?
|
|
85
82
|
@h["last_state"] = session.effective_state
|
|
86
83
|
@h["state_since"] = now_i
|
|
87
84
|
end
|
|
@@ -96,7 +93,6 @@ module ClaudeInbox
|
|
|
96
93
|
@h["last_seen"] = now_i
|
|
97
94
|
row = Row.new(session: session, entry: self)
|
|
98
95
|
@h.delete("wake_at") if row.woken?(now_i)
|
|
99
|
-
@h.delete("snoozed_at") unless wake_at
|
|
100
96
|
@h.delete("settled_at") unless row.hand_settled?
|
|
101
97
|
@h.delete("acknowledged_at") unless row.acknowledged?
|
|
102
98
|
@h.delete("revived_at") unless row.revived?
|
|
@@ -105,13 +101,11 @@ module ClaudeInbox
|
|
|
105
101
|
|
|
106
102
|
def snooze(choice, now)
|
|
107
103
|
@h["wake_at"] = self.class.snooze_until(choice, now)
|
|
108
|
-
@h["snoozed_at"] = now.to_i
|
|
109
104
|
end
|
|
110
105
|
|
|
111
106
|
# Also lifts a hand-settle and a PR settle, so `u` undoes `x` and a resolved PR alike.
|
|
112
107
|
def wake(now)
|
|
113
108
|
@h.delete("wake_at")
|
|
114
|
-
@h.delete("snoozed_at")
|
|
115
109
|
@h.delete("settled_at")
|
|
116
110
|
@h["revived_at"] = now.to_i
|
|
117
111
|
end
|
|
@@ -121,7 +115,6 @@ module ClaudeInbox
|
|
|
121
115
|
def settle(now)
|
|
122
116
|
@h["settled_at"] = now.to_i
|
|
123
117
|
@h.delete("wake_at")
|
|
124
|
-
@h.delete("snoozed_at")
|
|
125
118
|
@h.delete("revived_at")
|
|
126
119
|
end
|
|
127
120
|
|
|
@@ -19,6 +19,10 @@ module ClaudeInbox
|
|
|
19
19
|
|
|
20
20
|
def state_since = entry&.state_since
|
|
21
21
|
|
|
22
|
+
# When the session last said anything, as its transcript has it; the
|
|
23
|
+
# inbox's own clock, then the process start, when there is none to ask.
|
|
24
|
+
def spoke_at = session.last_message_at&.to_i || state_since || session.started_at&.to_i || 0
|
|
25
|
+
|
|
22
26
|
def pinned_at = entry&.pinned_at
|
|
23
27
|
|
|
24
28
|
def pinned? = entry&.pinned?
|
|
@@ -26,20 +30,17 @@ module ClaudeInbox
|
|
|
26
30
|
def reap_failed_at = entry&.reap_failed_at
|
|
27
31
|
|
|
28
32
|
# Names get shortened and forgotten, so the filter also reads what the
|
|
29
|
-
# session was asked to do
|
|
33
|
+
# session was asked to do, what it says it is doing now, and the pull
|
|
34
|
+
# requests linked to it, so `#885` finds the session behind a review.
|
|
30
35
|
def matches?(query)
|
|
31
36
|
q = query.downcase
|
|
32
|
-
|
|
37
|
+
prs = session.prs.flat_map { |pr| [pr.qualified, pr.url, pr.title] }
|
|
38
|
+
[label, session.cwd, session.intent, session.summary, *prs].any? { |field| field.to_s.downcase.include?(q) }
|
|
33
39
|
end
|
|
34
40
|
|
|
35
|
-
#
|
|
36
|
-
#
|
|
37
|
-
def woken?(now)
|
|
38
|
-
return false if wake_at.nil?
|
|
39
|
-
return true if session.needs_you? && entry.state_since.to_i > entry.snoozed_at.to_i
|
|
40
|
-
return false if parked?
|
|
41
|
-
wake_at.to_i <= now.to_i
|
|
42
|
-
end
|
|
41
|
+
# Its timer has run out. Becoming blocked or failed wakes it sooner,
|
|
42
|
+
# but only Entry#observe sees that change happen.
|
|
43
|
+
def woken?(now) = !wake_at.nil? && !parked? && wake_at.to_i <= now.to_i
|
|
43
44
|
|
|
44
45
|
def snoozed?(now) = wake_at && !woken?(now)
|
|
45
46
|
|
|
@@ -108,10 +109,11 @@ module ClaudeInbox
|
|
|
108
109
|
end
|
|
109
110
|
|
|
110
111
|
# The terminal outranks everything: never settle or hide a session you are in.
|
|
112
|
+
# A snooze outranks the pin, which stays put and returns when it wakes.
|
|
111
113
|
def section(now)
|
|
112
114
|
if session.terminal? then session.needs_you? ? :needs_you : :active
|
|
113
|
-
elsif pinned? then :pinned
|
|
114
115
|
elsif snoozed?(now) then :snoozed
|
|
116
|
+
elsif pinned? then :pinned
|
|
115
117
|
elsif !revived? && settled? then :settled # a resolved PR outranks Needs You
|
|
116
118
|
elsif session.needs_you? || pr_needs_you? then acknowledged? ? :active : :needs_you
|
|
117
119
|
else :active
|
data/lib/claude_inbox/store.rb
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "records"
|
|
4
|
+
require_relative "snooze_time"
|
|
4
5
|
require_relative "store/entry"
|
|
5
6
|
require_relative "store/row"
|
|
6
7
|
require_relative "store/selection"
|
|
@@ -40,7 +41,13 @@ module ClaudeInbox
|
|
|
40
41
|
Row.new(session: session, entry: hash && Entry.new(hash))
|
|
41
42
|
end
|
|
42
43
|
|
|
43
|
-
|
|
44
|
+
# `activity` puts the session that spoke most recently first in every
|
|
45
|
+
# section but Snoozed, which stays in wake order. The default keeps each
|
|
46
|
+
# section's own order: Pinned by pin, Needs You and Settled by the state
|
|
47
|
+
# clock, Active by start with running sessions first.
|
|
48
|
+
SORTS = %w[default activity].freeze
|
|
49
|
+
|
|
50
|
+
def self.sectionize(sessions, entries, now, sort: "default")
|
|
44
51
|
sec = Sections.new(pinned: [], needs_you: [], active: [], snoozed: [], settled: [])
|
|
45
52
|
sessions.each do |s|
|
|
46
53
|
row = row_for(s, entries)
|
|
@@ -51,6 +58,7 @@ module ClaudeInbox
|
|
|
51
58
|
sec.active.sort_by! { |r| [r.session.finished? ? 1 : 0, -(r.session.started_at&.to_i || 0)] }
|
|
52
59
|
sec.snoozed.sort_by! { |r| r.parked? ? [1, 0] : [0, r.wake_at.to_i] }
|
|
53
60
|
sec.settled.sort_by! { |r| -(r.state_since || 0) }
|
|
61
|
+
%i[pinned needs_you active settled].each { |name| sec[name].sort_by! { |r| -r.spoke_at } } if sort == "activity"
|
|
54
62
|
sec
|
|
55
63
|
end
|
|
56
64
|
|
|
@@ -60,9 +68,10 @@ module ClaudeInbox
|
|
|
60
68
|
# second inbox open at once, which reads along with the first rather
|
|
61
69
|
# than overwriting what it and the pane's requests settled, and sends
|
|
62
70
|
# its own edits to the first through the `relay` as requests.
|
|
63
|
-
def initialize(path: DEFAULT_PATH, clock: -> { Time.now }, writable: true, relay: nil)
|
|
71
|
+
def initialize(path: DEFAULT_PATH, clock: -> { Time.now }, writable: true, relay: nil, sort: "default")
|
|
64
72
|
@path = path
|
|
65
73
|
@clock = clock
|
|
74
|
+
@sort = sort
|
|
66
75
|
@writable = writable
|
|
67
76
|
@relay = relay
|
|
68
77
|
@mutex = Mutex.new
|
|
@@ -86,11 +95,13 @@ module ClaudeInbox
|
|
|
86
95
|
end
|
|
87
96
|
|
|
88
97
|
def sections(now = @clock.call)
|
|
89
|
-
@mutex.synchronize { self.class.sectionize(@sessions, @entries, now) }
|
|
98
|
+
@mutex.synchronize { self.class.sectionize(@sessions, @entries, now, sort: @sort) }
|
|
90
99
|
end
|
|
91
100
|
|
|
92
101
|
def sessions = @mutex.synchronize { @sessions.dup }
|
|
93
102
|
|
|
103
|
+
def actionable_keys = sessions.select(&:actionable?).map(&:key)
|
|
104
|
+
|
|
94
105
|
def writable? = @writable
|
|
95
106
|
|
|
96
107
|
def snooze(id, choice) = edit(id, "snooze", choice: choice.to_s) { |e| e.snooze(choice, @clock.call) }
|
data/lib/claude_inbox/text.rb
CHANGED
|
@@ -15,11 +15,12 @@ module ClaudeInbox
|
|
|
15
15
|
|
|
16
16
|
def strip_ansi(s) = s.gsub(ANSI, "")
|
|
17
17
|
|
|
18
|
+
# Bytes that aren't UTF-8 become U+FFFD.
|
|
19
|
+
def utf8(s) = String.new(s.to_s, encoding: Encoding::UTF_8).scrub("\uFFFD")
|
|
20
|
+
|
|
18
21
|
# Control characters as \xNN, so quoted text can't reach the terminal
|
|
19
|
-
# as an escape.
|
|
20
|
-
def printable(s)
|
|
21
|
-
String.new(s.to_s, encoding: Encoding::UTF_8).scrub("\uFFFD").gsub(/[[:cntrl:]]/) { |c| format("\\x%02x", c.ord) }
|
|
22
|
-
end
|
|
22
|
+
# as an escape.
|
|
23
|
+
def printable(s) = utf8(s).gsub(/[[:cntrl:]]/) { |c| format("\\x%02x", c.ord) }
|
|
23
24
|
|
|
24
25
|
def width(s) = Unicode::DisplayWidth.of(strip_ansi(s))
|
|
25
26
|
|
|
@@ -3,16 +3,14 @@
|
|
|
3
3
|
require_relative "text"
|
|
4
4
|
|
|
5
5
|
module ClaudeInbox
|
|
6
|
-
# One editable string with a cursor in it.
|
|
7
|
-
#
|
|
8
|
-
# they hand over keypresses and ask for something to draw.
|
|
6
|
+
# One editable string with a cursor in it. Callers hand over keypresses
|
|
7
|
+
# and ask for something to draw; they never index into the text.
|
|
9
8
|
#
|
|
10
9
|
# Positions count cells, not bytes or characters. A cell is a grapheme
|
|
11
|
-
# cluster
|
|
12
|
-
# cut
|
|
13
|
-
#
|
|
14
|
-
# that moves and deletes as one unit,
|
|
15
|
-
# treats them.
|
|
10
|
+
# cluster, since prompts and session names carry emoji that character
|
|
11
|
+
# indexes cut in half. Or it is a chip, an attached Image or a long Pasted
|
|
12
|
+
# text shown as one token (`[Image #1]`, `[Pasted text #1 +40 lines]`)
|
|
13
|
+
# that moves and deletes as one unit, as in Claude Code's own prompt.
|
|
16
14
|
#
|
|
17
15
|
# b = TextBuffer.new("ab")
|
|
18
16
|
# b.press(:left, "\e[D")
|
|
@@ -56,14 +54,13 @@ module ClaudeInbox
|
|
|
56
54
|
}.join
|
|
57
55
|
end
|
|
58
56
|
|
|
59
|
-
#
|
|
60
|
-
#
|
|
57
|
+
# The cursor ends up at the end, where completion wants it after it
|
|
58
|
+
# extends a path.
|
|
61
59
|
def replace(text)
|
|
62
60
|
@g = text.grapheme_clusters
|
|
63
61
|
@cursor = @g.size
|
|
64
62
|
end
|
|
65
63
|
|
|
66
|
-
# Everything before the cursor, as one string.
|
|
67
64
|
def head = @g[0...@cursor].join
|
|
68
65
|
|
|
69
66
|
# How completion drops a picked command in over the half-typed one.
|
|
@@ -165,7 +162,7 @@ module ClaudeInbox
|
|
|
165
162
|
|
|
166
163
|
# One entry per display row: [cells, offset of its first cell]. A
|
|
167
164
|
# cursor at the end of a row that is already full has no cell of its
|
|
168
|
-
# own, so it gets a row of its own
|
|
165
|
+
# own, so it gets a row of its own, as a terminal does when text
|
|
169
166
|
# reaches the right margin.
|
|
170
167
|
def wrapped(width)
|
|
171
168
|
rows = logical_lines.flat_map do |line, start|
|
|
@@ -252,7 +249,6 @@ module ClaudeInbox
|
|
|
252
249
|
@cursor = at
|
|
253
250
|
end
|
|
254
251
|
|
|
255
|
-
# Back over any spaces, then over the word itself.
|
|
256
252
|
def delete_word
|
|
257
253
|
at = @cursor
|
|
258
254
|
at -= 1 while at > 0 && @g[at - 1] == " "
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "time"
|
|
5
|
+
require_relative "agents_client"
|
|
6
|
+
|
|
7
|
+
module ClaudeInbox
|
|
8
|
+
# A session's transcript, read from the end for when its last message was
|
|
9
|
+
# sent. Only the `user` and `assistant` records are messages: the CLI
|
|
10
|
+
# appends undated bookkeeping records long after a session has finished,
|
|
11
|
+
# so the file's mtime says nothing.
|
|
12
|
+
#
|
|
13
|
+
# Every poll asks, and a transcript that ends in a long run of other
|
|
14
|
+
# records costs a scan each time, so each file is read again only once
|
|
15
|
+
# it has grown.
|
|
16
|
+
class Transcript
|
|
17
|
+
MESSAGE_TYPES = %w[user assistant].freeze
|
|
18
|
+
CHUNK = 64 * 1024
|
|
19
|
+
|
|
20
|
+
def initialize
|
|
21
|
+
@seen = {} # path => [size, time]
|
|
22
|
+
@mutex = Mutex.new
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def enrich(sessions, home: Dir.home)
|
|
26
|
+
sessions.map { |s| s.with(last_message_at: last_message_at(path_for(s, home))) }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def last_message_at(path)
|
|
30
|
+
return nil unless path
|
|
31
|
+
@mutex.synchronize do
|
|
32
|
+
size = File.size(path)
|
|
33
|
+
seen_size, at = @seen[path]
|
|
34
|
+
return at if seen_size == size
|
|
35
|
+
at = File.open(path, "rb") { |f| scan_back(f) }
|
|
36
|
+
@seen[path] = [size, at]
|
|
37
|
+
at
|
|
38
|
+
end
|
|
39
|
+
rescue SystemCallError
|
|
40
|
+
nil
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def path_for(session, home)
|
|
46
|
+
session.job_state&.transcript_path ||
|
|
47
|
+
(session.cwd && session.session_id && AgentsClient.transcript_path(session.cwd, session.session_id, home: home))
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def scan_back(file)
|
|
51
|
+
pos = file.size
|
|
52
|
+
carry = +""
|
|
53
|
+
while pos > 0
|
|
54
|
+
step = [CHUNK, pos].min
|
|
55
|
+
pos -= step
|
|
56
|
+
file.seek(pos)
|
|
57
|
+
lines = (file.read(step) + carry).split("\n", -1)
|
|
58
|
+
carry = pos.zero? ? +"" : lines.shift
|
|
59
|
+
lines.reverse_each do |line|
|
|
60
|
+
at = message_time(line)
|
|
61
|
+
return at if at
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
nil
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def message_time(line)
|
|
68
|
+
return nil unless MESSAGE_TYPES.any? { |type| line.include?("\"type\":\"#{type}\"") }
|
|
69
|
+
record = JSON.parse(line.force_encoding(Encoding::UTF_8))
|
|
70
|
+
return nil unless MESSAGE_TYPES.include?(record["type"]) && record["timestamp"]
|
|
71
|
+
Time.iso8601(record["timestamp"])
|
|
72
|
+
rescue JSON::ParserError, ArgumentError
|
|
73
|
+
nil
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
data/lib/claude_inbox.rb
CHANGED
data/mod/hooks/register.tsx
CHANGED
|
@@ -38,6 +38,7 @@ export type SnapshotRow = {
|
|
|
38
38
|
unread?: boolean
|
|
39
39
|
line?: string
|
|
40
40
|
wake_at?: number | 'until_woken'
|
|
41
|
+
last_message_at?: number
|
|
41
42
|
remote?: string
|
|
42
43
|
color?: string
|
|
43
44
|
pr?: { short?: string; state?: string; url?: string }
|
|
@@ -142,6 +143,7 @@ export function rowOf(section: Section, one: SnapshotRow): InboxRow {
|
|
|
142
143
|
isUnread: one.unread === true,
|
|
143
144
|
line: one.line,
|
|
144
145
|
wakeAt: one.wake_at,
|
|
146
|
+
lastMessageAt: one.last_message_at,
|
|
145
147
|
remote: one.remote,
|
|
146
148
|
color: one.color,
|
|
147
149
|
pr,
|
|
@@ -155,7 +157,10 @@ function wakeLabel(wakeAt: number | 'until_woken'): string {
|
|
|
155
157
|
}
|
|
156
158
|
|
|
157
159
|
function ago(seconds: number): string {
|
|
158
|
-
|
|
160
|
+
if (seconds < 60) return 'just now'
|
|
161
|
+
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`
|
|
162
|
+
if (seconds < 86_400) return `${Math.floor(seconds / 3600)}h ago`
|
|
163
|
+
return `${Math.floor(seconds / 86_400)}d ago`
|
|
159
164
|
}
|
|
160
165
|
|
|
161
166
|
async function inboxFile($: EngineInterface, path: string): Promise<string> {
|
|
@@ -337,7 +342,8 @@ export const register: Register = (on, options) => {
|
|
|
337
342
|
const chord = await read($, pending)
|
|
338
343
|
const written = await read($, writtenAt)
|
|
339
344
|
const release = await read($, newer)
|
|
340
|
-
const
|
|
345
|
+
const now = await nowS($)
|
|
346
|
+
const ageS = now - written
|
|
341
347
|
const groups = groupsOf(list, folded, viewing)
|
|
342
348
|
const flat = groups.flatMap(group => group.items)
|
|
343
349
|
const at = onList(flat, await read($, cursor))
|
|
@@ -497,6 +503,11 @@ export const register: Register = (on, options) => {
|
|
|
497
503
|
</Text>
|
|
498
504
|
</Box>
|
|
499
505
|
) : null}
|
|
506
|
+
{one.lastMessageAt !== undefined ? (
|
|
507
|
+
<Box flexShrink={0}>
|
|
508
|
+
<Text dimColor> {ago(now - one.lastMessageAt)}</Text>
|
|
509
|
+
</Box>
|
|
510
|
+
) : null}
|
|
500
511
|
</Box>
|
|
501
512
|
{!isQuiet && one.line !== undefined ? (
|
|
502
513
|
<Box paddingLeft={4}>
|
data/mod/tests/contract.test.tsx
CHANGED
|
@@ -18,6 +18,7 @@ test("the rows the pane reads are the ones the gem's Snapshot writes", () => {
|
|
|
18
18
|
['settled', 'c3', 'put away', 'done', true],
|
|
19
19
|
])
|
|
20
20
|
expect(rows[1]?.pr).toEqual({ short: '#7', state: 'open', url: 'https://github.com/o/r/pull/7' })
|
|
21
|
+
expect(rows.map(row => row.lastMessageAt)).toEqual([undefined, 1_789_599_700, undefined])
|
|
21
22
|
expect(rows[0]?.isWaiting).toBe(false)
|
|
22
23
|
expect(rows.map(row => row.isUnread)).toEqual([true, false, true])
|
|
23
24
|
})
|
data/mod/tests/pane.test.tsx
CHANGED
|
@@ -87,6 +87,18 @@ test('a poll takes the notice from the snapshot', async ($, on) => {
|
|
|
87
87
|
expect(set.update).toBe('v0.6.0 available')
|
|
88
88
|
})
|
|
89
89
|
|
|
90
|
+
test('a row ends with how long ago its last message was sent, dimmed, and one without a transcript ends as it was', async ($, on) => {
|
|
91
|
+
mock.env(on, { HOME: '/Users/me' })
|
|
92
|
+
mock.clock(on, { now: 1_700_000_000_000 })
|
|
93
|
+
const dated = { ...ROWS[1], lastMessageAt: 1_700_000_000 - 5 * 3600 }
|
|
94
|
+
on('state.get', ($, e, next) => (e.key === 'rows' ? seeded([dated, ROWS[2]]) : e.key === 'writtenAt' ? seeded(1_700_000_000) : next(e)))
|
|
95
|
+
const ui = await $.ui.mount({ ...PANE, surface: 'terminal' })
|
|
96
|
+
const when = await ui.find({ type: 'Text', text: / 5h ago$/ })
|
|
97
|
+
expect(when?.props.dimColor).toBe(true)
|
|
98
|
+
expect(await ui.findAll({ type: 'Text', text: /ago$/ })).toHaveLength(1)
|
|
99
|
+
await ui.unmount()
|
|
100
|
+
})
|
|
101
|
+
|
|
90
102
|
test('an unread row that needs you takes the same blue dot as one that only finished', async ($, on) => {
|
|
91
103
|
mock.env(on, { HOME: '/Users/me' })
|
|
92
104
|
mock.clock(on, { now: 1_700_000_000_000 })
|
data/mod/types/index.d.ts
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: claude-inbox
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.13.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Jordan Byron
|
|
@@ -167,6 +167,7 @@ files:
|
|
|
167
167
|
- lib/claude_inbox/text.rb
|
|
168
168
|
- lib/claude_inbox/text_buffer.rb
|
|
169
169
|
- lib/claude_inbox/theme.rb
|
|
170
|
+
- lib/claude_inbox/transcript.rb
|
|
170
171
|
- lib/claude_inbox/trust.rb
|
|
171
172
|
- lib/claude_inbox/updater.rb
|
|
172
173
|
- lib/claude_inbox/vt_screen.rb
|