claude-inbox 0.11.0 → 0.11.2
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/lib/claude_inbox/app/navigating.rb +9 -0
- data/lib/claude_inbox/app/spawning.rb +4 -4
- data/lib/claude_inbox/app/triaging.rb +11 -3
- data/lib/claude_inbox/dialog.rb +1 -1
- data/lib/claude_inbox/headless.rb +18 -3
- data/lib/claude_inbox/pull_requests.rb +2 -0
- data/lib/claude_inbox/remote/page.html +3 -0
- data/lib/claude_inbox/remote/start.rb +3 -2
- data/lib/claude_inbox/renderer.rb +1 -1
- data/lib/claude_inbox/session_request.rb +15 -0
- data/lib/claude_inbox/writer_lock.rb +11 -2
- data/lib/claude_inbox.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 86190c0236a4084ec5b00e5044886f6a6b5ab8b51c8e81ac56d7ee9a78619753
|
|
4
|
+
data.tar.gz: 763db348bacbb927ec5c5fae6344a02d449ddbf8aeb7dc4460594168e523b5dd
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 532e078a269b17bfafd4eb6bbe83893a8be012740bd7fbac7c4625f074c9ce40df5afb9475e0cfbdd09963200265e35dc4335ecfacadfe4a653a83f78033f369
|
|
7
|
+
data.tar.gz: 51632f06152f27dd1708304ecdc4f9eea0ff9dfba18d8ce302f44a8e7b1d260472d56abf80151d1d48f947faf7432ea2ed47133037c43b570972cc171a4b9e24
|
|
@@ -47,6 +47,15 @@ module ClaudeInbox
|
|
|
47
47
|
select(stops[(idx + delta).clamp(0, stops.size - 1)])
|
|
48
48
|
end
|
|
49
49
|
|
|
50
|
+
# Before a row leaves the list: the stop below it, or above it at the
|
|
51
|
+
# bottom, so working down a section doesn't throw the cursor to the top.
|
|
52
|
+
def select_neighbour
|
|
53
|
+
stops = filtered.selections(@expanded)
|
|
54
|
+
idx = stops.index(@selected)
|
|
55
|
+
rest = stops - [@selected]
|
|
56
|
+
select(rest[idx.clamp(..rest.size - 1)]) if idx && rest.any?
|
|
57
|
+
end
|
|
58
|
+
|
|
50
59
|
# Tab / Shift-Tab: first selectable row of the next / previous section.
|
|
51
60
|
def jump_section(dir)
|
|
52
61
|
sections = filtered
|
|
@@ -28,11 +28,11 @@ module ClaudeInbox
|
|
|
28
28
|
# it we stay in the inbox and poll, so the new row shows up right away
|
|
29
29
|
# rather than at the next tick.
|
|
30
30
|
def start_session(form, attach:)
|
|
31
|
-
v = form.values
|
|
31
|
+
v, note = SessionRequest.without_stray_worktree(form.values)
|
|
32
32
|
notice("starting session…")
|
|
33
33
|
in_background do
|
|
34
34
|
id = @client.spawn(**v)
|
|
35
|
-
@queue << [:notice, "started #{id}"]
|
|
35
|
+
@queue << [:notice, ["started #{id}", note].compact.join(" — ")]
|
|
36
36
|
@queue << [:form_started, form]
|
|
37
37
|
@queue << [:select, id]
|
|
38
38
|
attach ? @queue << [:attach, id] : @poller.soon
|
|
@@ -53,8 +53,8 @@ module ClaudeInbox
|
|
|
53
53
|
|
|
54
54
|
# Someone at the desk may be mid-thought, so a start from another
|
|
55
55
|
# device never moves the cursor, attaches or closes what is open.
|
|
56
|
-
def remote_started(id, via)
|
|
57
|
-
notice("started #{id} from #{via}")
|
|
56
|
+
def remote_started(id, via, note = nil)
|
|
57
|
+
notice(["started #{id} from #{via}", note].compact.join(" — "))
|
|
58
58
|
@poller.soon
|
|
59
59
|
end
|
|
60
60
|
end
|
|
@@ -33,7 +33,9 @@ module ClaudeInbox
|
|
|
33
33
|
|
|
34
34
|
def settle_selected
|
|
35
35
|
return unless require_storable
|
|
36
|
-
@
|
|
36
|
+
key = @selected.key
|
|
37
|
+
select_neighbour
|
|
38
|
+
@store.settle(key)
|
|
37
39
|
notice("settled — u brings it back")
|
|
38
40
|
end
|
|
39
41
|
|
|
@@ -44,10 +46,16 @@ module ClaudeInbox
|
|
|
44
46
|
|
|
45
47
|
def snooze_chosen
|
|
46
48
|
return @modal = Dialog::Prompt.new(:snooze, @modal.id, "") if @modal.choice == :custom
|
|
47
|
-
|
|
49
|
+
snooze_modal_row(@modal.choice)
|
|
48
50
|
@modal = nil
|
|
49
51
|
end
|
|
50
52
|
|
|
53
|
+
# A poll can move the cursor while the menu is up, so only step off a row still under it.
|
|
54
|
+
def snooze_modal_row(choice)
|
|
55
|
+
select_neighbour if @selected == Store::Selection.row(@modal.id)
|
|
56
|
+
@store.snooze(@modal.id, choice)
|
|
57
|
+
end
|
|
58
|
+
|
|
51
59
|
def open_alias_editor
|
|
52
60
|
return unless require_storable && require_writable("rename")
|
|
53
61
|
current = @store.alias_for(@selected.key) || ""
|
|
@@ -67,7 +75,7 @@ module ClaudeInbox
|
|
|
67
75
|
elsif @modal.kind == :snooze
|
|
68
76
|
wake_at = SnoozeTime.parse(value, Time.now)
|
|
69
77
|
return notice("try 45m, 2h, 3d, 17:00, 5pm, fri, tomorrow or tonight") unless wake_at
|
|
70
|
-
|
|
78
|
+
snooze_modal_row(wake_at)
|
|
71
79
|
elsif value.empty? || PullRequests.valid_url?(value)
|
|
72
80
|
@store.set_pr(@modal.id, value)
|
|
73
81
|
@poller.soon
|
data/lib/claude_inbox/dialog.rb
CHANGED
|
@@ -15,11 +15,15 @@ module ClaudeInbox
|
|
|
15
15
|
# reaps, since deleting sessions stays with the inbox you can see, and it
|
|
16
16
|
# exits at once while an inbox holds the writer lock.
|
|
17
17
|
class Headless
|
|
18
|
+
RECHECK_EVERY = 60
|
|
19
|
+
|
|
18
20
|
def initialize(client:, store:, pull_requests: PullRequests.new, actions: Actions.new, snapshot: Snapshot.new,
|
|
19
|
-
lock: WriterLock.new, updater: nil, notifier: Notifier.disabled)
|
|
21
|
+
lock: WriterLock.new, updater: nil, notifier: Notifier.disabled, recheck_every: RECHECK_EVERY)
|
|
20
22
|
@lock = lock
|
|
21
23
|
@snapshot = snapshot
|
|
22
24
|
@updater = updater
|
|
25
|
+
@recheck_every = recheck_every
|
|
26
|
+
@notice = nil
|
|
23
27
|
@queue = Queue.new
|
|
24
28
|
@poller = Poller.new(client: client, store: store, pull_requests: pull_requests, reaper: Reaper.disabled,
|
|
25
29
|
queue: @queue, snapshot: snapshot, actions: actions, notifier: notifier)
|
|
@@ -29,7 +33,7 @@ module ClaudeInbox
|
|
|
29
33
|
def run
|
|
30
34
|
return false unless @lock.take(role: "headless")
|
|
31
35
|
@poller.start
|
|
32
|
-
|
|
36
|
+
Thread.new { watch_for_updates } if @updater
|
|
33
37
|
loop { @queue.pop }
|
|
34
38
|
end
|
|
35
39
|
|
|
@@ -38,8 +42,19 @@ module ClaudeInbox
|
|
|
38
42
|
# After the poller is off, since the check may wait on the network. The
|
|
39
43
|
# pane cannot swap the process under Claude Code, so the snapshot only
|
|
40
44
|
# says a newer one is out, and a check that cannot tell says nothing.
|
|
45
|
+
# Re-checked because this process outlives an update applied from a
|
|
46
|
+
# terminal; the updater's day-long cache keeps that off the network.
|
|
47
|
+
def watch_for_updates
|
|
48
|
+
loop do
|
|
49
|
+
check_for_update
|
|
50
|
+
sleep @recheck_every
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
41
54
|
def check_for_update
|
|
42
|
-
|
|
55
|
+
notice = @updater.check&.notice
|
|
56
|
+
return if notice == @notice
|
|
57
|
+
@notice = @snapshot.update = notice
|
|
43
58
|
@poller.soon
|
|
44
59
|
rescue Updater::Error
|
|
45
60
|
nil
|
|
@@ -140,6 +140,7 @@ code { font: 15px ui-monospace, SFMono-Regular, Menlo, monospace; }
|
|
|
140
140
|
<section id="done" class="card" hidden>
|
|
141
141
|
<p>Started <code id="done-id"></code></p>
|
|
142
142
|
<p class="muted" id="done-where"></p>
|
|
143
|
+
<p class="muted" id="done-note" hidden></p>
|
|
143
144
|
<p class="muted" id="done-later">claude.ai/code lists it once it has registered, in a moment.</p>
|
|
144
145
|
<div class="actions">
|
|
145
146
|
<a class="button" id="done-url" target="_blank" rel="noopener">Open in Claude</a>
|
|
@@ -421,6 +422,8 @@ function started(session, remote) {
|
|
|
421
422
|
$("name").value = "";
|
|
422
423
|
$("done-id").textContent = session.id;
|
|
423
424
|
$("done-where").textContent = [session.name, session.cwd].filter(Boolean).join(" · ");
|
|
425
|
+
$("done-note").textContent = session.note || "";
|
|
426
|
+
$("done-note").hidden = !session.note;
|
|
424
427
|
const link = $("done-url");
|
|
425
428
|
link.hidden = !/^https:\/\//.test(session.url || "");
|
|
426
429
|
if (!link.hidden) link.href = session.url;
|
|
@@ -156,11 +156,12 @@ module ClaudeInbox
|
|
|
156
156
|
values[:permission_mode] = capped_mode(values[:permission_mode] || defaults.permission_mode || builtin_mode)
|
|
157
157
|
paths = images.each_with_index.map { |bytes, i| Images.save(bytes, dir: @images_dir, index: i + 1) }
|
|
158
158
|
values[:prompt] = SessionRequest.attach(values[:prompt], paths)
|
|
159
|
+
values, note = SessionRequest.without_stray_worktree(values)
|
|
159
160
|
@queue << [:notice, "remote: starting session…"]
|
|
160
161
|
id = Http.utf8(@spawn_lock.synchronize { @client.spawn(**values) })
|
|
161
162
|
url = remote_url(id, wait: values[:remote] ? @bridge_wait : 0)
|
|
162
|
-
@queue << [:remote_started, id, via]
|
|
163
|
-
{id: id, name: values[:name], cwd: values[:cwd], url: url}
|
|
163
|
+
@queue << [:remote_started, id, via, note].compact
|
|
164
|
+
{id: id, name: values[:name], cwd: values[:cwd], url: url}.merge(note ? {note: note} : {})
|
|
164
165
|
rescue SessionRequest::Invalid => e
|
|
165
166
|
raise Http::Error.new(422, e.message, field: e.field)
|
|
166
167
|
end
|
|
@@ -317,7 +317,7 @@ module ClaudeInbox
|
|
|
317
317
|
|
|
318
318
|
label_w = [width - chrome - Text.width(project_text), 0].max
|
|
319
319
|
label = Text.truncate(row.label, label_w)
|
|
320
|
-
label = style_label(label, row, quiet, sel ||
|
|
320
|
+
label = style_label(label, row, quiet, sel || unread)
|
|
321
321
|
first = " #{marker} #{glyph} " + Text.pad(label, label_w) + " " + meta + " " + project + " "
|
|
322
322
|
|
|
323
323
|
return [Text.pad(first, width)] unless live
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "pathname"
|
|
3
4
|
require_relative "agents_client"
|
|
4
5
|
|
|
5
6
|
module ClaudeInbox
|
|
@@ -77,6 +78,20 @@ module ClaudeInbox
|
|
|
77
78
|
[text, rest.join("\n")].reject(&:empty?).join("\n\n")
|
|
78
79
|
end
|
|
79
80
|
|
|
81
|
+
# `claude --bg --worktree` outside a git repository dies before init
|
|
82
|
+
# (docs/cli-quirks.md). A folder above several repos is a fair place to
|
|
83
|
+
# start from, so there the session starts without a worktree and the
|
|
84
|
+
# note says so.
|
|
85
|
+
def self.without_stray_worktree(values)
|
|
86
|
+
return [values, nil] unless values[:worktree] && !inside_repo?(values[:cwd])
|
|
87
|
+
[values.merge(worktree: false), "not in a git repo, so no worktree"]
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# A worktree or a submodule has a `.git` file rather than a folder.
|
|
91
|
+
def self.inside_repo?(dir)
|
|
92
|
+
Pathname(File.expand_path(dir)).ascend.any? { |d| (d + ".git").exist? }
|
|
93
|
+
end
|
|
94
|
+
|
|
80
95
|
# A session's cwd may sit inside a worktree another agent is using;
|
|
81
96
|
# carrying that into a new prompt would spawn the new agent there too,
|
|
82
97
|
# writing over the same files. Fall back to the repo it was cut from.
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "fileutils"
|
|
4
|
+
require_relative "subprocess"
|
|
4
5
|
|
|
5
6
|
module ClaudeInbox
|
|
6
7
|
# One snapshot writer per machine: the inbox you can see, else a headless
|
|
@@ -25,8 +26,8 @@ module ClaudeInbox
|
|
|
25
26
|
file = File.open(@path, File::RDWR | File::CREAT, 0o600)
|
|
26
27
|
return hold(file, role) if file.flock(File::LOCK_EX | File::LOCK_NB)
|
|
27
28
|
holder_role, holder = file.read.split
|
|
28
|
-
return refuse(file) unless role == "inbox" && holder_role == "headless"
|
|
29
|
-
Process.kill("TERM", holder.to_i)
|
|
29
|
+
return refuse(file) unless role == "inbox" && holder_role == "headless" && headless?(holder.to_i)
|
|
30
|
+
Process.kill("TERM", holder.to_i)
|
|
30
31
|
EVICT_TRIES.times do
|
|
31
32
|
return hold(file, role) if file.flock(File::LOCK_EX | File::LOCK_NB)
|
|
32
33
|
sleep 0.1
|
|
@@ -39,6 +40,14 @@ module ClaudeInbox
|
|
|
39
40
|
|
|
40
41
|
private
|
|
41
42
|
|
|
43
|
+
# The file can name a pid that no longer holds the lock (#322), which by
|
|
44
|
+
# now may be some other process; only a headless claude-inbox is ended.
|
|
45
|
+
def headless?(pid)
|
|
46
|
+
return false unless pid.positive? && pid != Process.pid
|
|
47
|
+
command = Subprocess.capture("ps", "-o", "command=", "-p", pid.to_s).out
|
|
48
|
+
command.include?("claude-inbox") && command.split.include?("--headless")
|
|
49
|
+
end
|
|
50
|
+
|
|
42
51
|
# After reading the holder the position sits at the end, and truncate does
|
|
43
52
|
# not move it, so the write is rewound first or it lands after NUL padding.
|
|
44
53
|
def hold(file, role)
|
data/lib/claude_inbox.rb
CHANGED