claude-inbox 0.1.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 +7 -0
- data/LICENSE +21 -0
- data/README.md +442 -0
- data/exe/claude-inbox +33 -0
- data/lib/claude_inbox/agents_client.rb +224 -0
- data/lib/claude_inbox/app.rb +533 -0
- data/lib/claude_inbox/debug.rb +16 -0
- data/lib/claude_inbox/dialog.rb +105 -0
- data/lib/claude_inbox/images.rb +58 -0
- data/lib/claude_inbox/job_state.rb +89 -0
- data/lib/claude_inbox/keymap.rb +71 -0
- data/lib/claude_inbox/logs.rb +66 -0
- data/lib/claude_inbox/mouse.rb +34 -0
- data/lib/claude_inbox/new_session_form.rb +363 -0
- data/lib/claude_inbox/palette.rb +40 -0
- data/lib/claude_inbox/paste.rb +42 -0
- data/lib/claude_inbox/peek.rb +81 -0
- data/lib/claude_inbox/poller.rb +98 -0
- data/lib/claude_inbox/pull_requests.rb +155 -0
- data/lib/claude_inbox/rate_limits.rb +48 -0
- data/lib/claude_inbox/reaper.rb +103 -0
- data/lib/claude_inbox/records.rb +28 -0
- data/lib/claude_inbox/renderer.rb +447 -0
- data/lib/claude_inbox/session.rb +100 -0
- data/lib/claude_inbox/sessions.rb +18 -0
- data/lib/claude_inbox/settings.rb +39 -0
- data/lib/claude_inbox/slash_commands.rb +116 -0
- data/lib/claude_inbox/store/entry.rb +144 -0
- data/lib/claude_inbox/store/row.rb +101 -0
- data/lib/claude_inbox/store/sections.rb +47 -0
- data/lib/claude_inbox/store/selection.rb +16 -0
- data/lib/claude_inbox/store.rb +156 -0
- data/lib/claude_inbox/subprocess.rb +53 -0
- data/lib/claude_inbox/terminal.rb +101 -0
- data/lib/claude_inbox/text.rb +98 -0
- data/lib/claude_inbox/text_buffer.rb +216 -0
- data/lib/claude_inbox/theme.rb +35 -0
- data/lib/claude_inbox/vt_screen.rb +138 -0
- data/lib/claude_inbox.rb +14 -0
- metadata +163 -0
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "settings"
|
|
4
|
+
|
|
5
|
+
module ClaudeInbox
|
|
6
|
+
# The slash commands `claude` would offer at its own prompt, read from the
|
|
7
|
+
# same places it reads them: `.claude/commands/*.md` and
|
|
8
|
+
# `.claude/skills/*/SKILL.md` under the project and the home directory,
|
|
9
|
+
# the skills and commands of installed plugins (named `plugin:name`), and
|
|
10
|
+
# the skills claude.ai syncs down (`anthropic-skills:name`). Built-ins
|
|
11
|
+
# live inside the CLI and are not listed; typing one still works, since
|
|
12
|
+
# the prompt goes to `claude --bg` verbatim.
|
|
13
|
+
#
|
|
14
|
+
# Pure: filesystem in, plain values out. Nothing here runs `claude`.
|
|
15
|
+
module SlashCommands
|
|
16
|
+
Command = Struct.new(:name, :description, :source) do
|
|
17
|
+
def to_s = "/#{name}"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
SYNCED_PLUGIN = "anthropic-skills"
|
|
21
|
+
|
|
22
|
+
module_function
|
|
23
|
+
|
|
24
|
+
# The project wins over the home directory when both define the same
|
|
25
|
+
# name, as it does in the CLI.
|
|
26
|
+
def list(cwd:, home: Dir.home)
|
|
27
|
+
found = {}
|
|
28
|
+
add = ->(cmd) { found[cmd.name] ||= cmd }
|
|
29
|
+
local(File.join(cwd, ".claude"), "project").each(&add)
|
|
30
|
+
local(File.join(home, ".claude"), "user").each(&add)
|
|
31
|
+
plugins(home).each(&add)
|
|
32
|
+
synced(home).each(&add)
|
|
33
|
+
found.values.sort_by(&:name)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def match(commands, query)
|
|
37
|
+
q = query.downcase
|
|
38
|
+
starts, rest = commands.partition { |c| c.name.downcase.start_with?(q) }
|
|
39
|
+
starts + rest.select { |c| c.name.downcase.include?(q) }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def local(dir, source)
|
|
43
|
+
skills(File.join(dir, "skills"), source) + commands(File.join(dir, "commands"), source)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def skills(dir, source, prefix: nil)
|
|
47
|
+
Dir.glob(File.join(dir, "*", "SKILL.md")).sort.filter_map do |path|
|
|
48
|
+
meta = frontmatter(path)
|
|
49
|
+
next if meta["user-invocable"] == "false"
|
|
50
|
+
name = File.basename(File.dirname(path))
|
|
51
|
+
Command.new(qualify(prefix, name), meta["description"].to_s, source)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# A command file's name is its own; a subdirectory only groups them.
|
|
56
|
+
def commands(dir, source, prefix: nil)
|
|
57
|
+
Dir.glob(File.join(dir, "**", "*.md")).sort.map do |path|
|
|
58
|
+
name = File.basename(path, ".md")
|
|
59
|
+
Command.new(qualify(prefix, name), frontmatter(path)["description"].to_s, source)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# "name", or "plugin:name" when it comes from a plugin.
|
|
64
|
+
def qualify(prefix, name) = [prefix, name].compact.join(":")
|
|
65
|
+
|
|
66
|
+
# `installed_plugins.json` maps "name@marketplace" to where the plugin
|
|
67
|
+
# was unpacked: one entry, or a list of them, one per install scope.
|
|
68
|
+
def plugins(home)
|
|
69
|
+
installed = Settings.read(File.join(home, ".claude", "plugins", "installed_plugins.json"))
|
|
70
|
+
(installed["plugins"] || {}).flat_map do |key, entries|
|
|
71
|
+
plugin = key.split("@").first
|
|
72
|
+
Array(entries).filter_map { |e| e["installPath"] if e.is_a?(Hash) }.uniq.flat_map do |root|
|
|
73
|
+
skills(File.join(root, "skills"), "plugin", prefix: plugin) +
|
|
74
|
+
commands(File.join(root, "commands"), "plugin", prefix: plugin)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# claude.ai's synced skills sit in one bucket per account under
|
|
80
|
+
# ~/.claude/skills/synced and answer to the anthropic-skills prefix.
|
|
81
|
+
def synced(home)
|
|
82
|
+
Dir.glob(File.join(home, ".claude", "skills", "synced", "*", "")).flat_map do |bucket|
|
|
83
|
+
skills(bucket, "synced", prefix: SYNCED_PLUGIN)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# The YAML front matter's scalar keys, without a YAML parser: enough
|
|
88
|
+
# for `name`, `description` and `user-invocable`. A folded or quoted
|
|
89
|
+
# description is read up to its first line.
|
|
90
|
+
def frontmatter(path)
|
|
91
|
+
lines = File.foreach(path).first(60)
|
|
92
|
+
return {} unless lines.first&.strip == "---"
|
|
93
|
+
out = {}
|
|
94
|
+
key = nil
|
|
95
|
+
lines[1..].map(&:chomp).each do |line|
|
|
96
|
+
break if line.strip == "---"
|
|
97
|
+
if (m = line.match(/\A([\w-]+):\s*(.*)\z/))
|
|
98
|
+
key = m[1]
|
|
99
|
+
out[key] = unquote(m[2].strip)
|
|
100
|
+
elsif key && out[key].empty? && line.start_with?(" ")
|
|
101
|
+
out[key] = unquote(line.strip)
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
out
|
|
105
|
+
rescue Errno::ENOENT, Errno::EACCES
|
|
106
|
+
{}
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def unquote(s)
|
|
110
|
+
s = s.sub(/\A[>|][-+]?\z/, "")
|
|
111
|
+
quoted = s.size >= 2 && ((s.start_with?('"') && s.end_with?('"')) || (s.start_with?("'") && s.end_with?("'")))
|
|
112
|
+
s = s[1..-2] if quoted
|
|
113
|
+
s.gsub("''", "'")
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ClaudeInbox
|
|
4
|
+
class Store
|
|
5
|
+
# What the inbox remembers about one session between polls, over the hash
|
|
6
|
+
# state.json holds; the key names appear nowhere else. Mutators edit that
|
|
7
|
+
# hash in place, since `Store#edit` yields it from the table and saves.
|
|
8
|
+
class Entry
|
|
9
|
+
# For a key edited before any poll has seen it (`s` on a row that only just appeared).
|
|
10
|
+
def self.blank(now) = new("state_since" => now.to_i)
|
|
11
|
+
|
|
12
|
+
# A finished session with no process has been quiet since it finished,
|
|
13
|
+
# not since this poll, so its idle clock starts from `started_at`.
|
|
14
|
+
def self.first_seen(session, now)
|
|
15
|
+
since = (session.finished? && !session.alive? && session.started_at) ? session.started_at.to_i : now.to_i
|
|
16
|
+
new("last_state" => session.effective_state, "state_since" => since)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def self.observe(hash, session, now)
|
|
20
|
+
(hash ? new(hash) : first_seen(session, now)).observe(session, now)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def self.snooze_until(choice, now)
|
|
24
|
+
case choice
|
|
25
|
+
when :m15 then now.to_i + 15 * 60
|
|
26
|
+
when :h1 then now.to_i + 3600
|
|
27
|
+
when :tomorrow_9am
|
|
28
|
+
t = Time.at(now.to_i)
|
|
29
|
+
t9 = Time.new(t.year, t.month, t.day, 9, 0, 0, t.utc_offset)
|
|
30
|
+
t9 += 86_400 if t9 <= t
|
|
31
|
+
t9.to_i
|
|
32
|
+
when :until_woken then UNTIL_WOKEN
|
|
33
|
+
else raise ArgumentError, "unknown snooze #{choice.inspect}"
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def initialize(hash)
|
|
38
|
+
@h = hash
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def to_h = @h
|
|
42
|
+
|
|
43
|
+
def alias_name = @h["alias"]
|
|
44
|
+
|
|
45
|
+
def pr = @h["pr"]
|
|
46
|
+
|
|
47
|
+
def wake_at = @h["wake_at"]
|
|
48
|
+
|
|
49
|
+
def parked? = wake_at == UNTIL_WOKEN
|
|
50
|
+
|
|
51
|
+
def snoozed_at = @h["snoozed_at"]
|
|
52
|
+
|
|
53
|
+
def last_state = @h["last_state"]
|
|
54
|
+
|
|
55
|
+
def state_since = @h["state_since"]
|
|
56
|
+
|
|
57
|
+
def last_seen = @h["last_seen"]
|
|
58
|
+
|
|
59
|
+
def pinned? = @h["pinned"] ? true : false
|
|
60
|
+
|
|
61
|
+
def pinned_at = @h["pinned_at"]
|
|
62
|
+
|
|
63
|
+
def settled_at = @h["settled_at"]
|
|
64
|
+
|
|
65
|
+
def acknowledged_at = @h["acknowledged_at"]
|
|
66
|
+
|
|
67
|
+
def revived_at = @h["revived_at"]
|
|
68
|
+
|
|
69
|
+
# When `claude rm` last refused this session, so the Reaper backs off.
|
|
70
|
+
def reap_failed_at = @h["reap_failed_at"]
|
|
71
|
+
|
|
72
|
+
def reap_error = @h["reap_error"]
|
|
73
|
+
|
|
74
|
+
def stale?(now) = last_seen && now.to_i - last_seen > PRUNE_AFTER
|
|
75
|
+
|
|
76
|
+
def observe(session, now)
|
|
77
|
+
now_i = now.to_i
|
|
78
|
+
if last_state != session.effective_state
|
|
79
|
+
@h["last_state"] = session.effective_state
|
|
80
|
+
@h["state_since"] = now_i
|
|
81
|
+
end
|
|
82
|
+
@h["last_seen"] = now_i
|
|
83
|
+
row = Row.new(session: session, entry: self)
|
|
84
|
+
@h.delete("wake_at") if row.woken?(now_i)
|
|
85
|
+
@h.delete("snoozed_at") unless wake_at
|
|
86
|
+
@h.delete("settled_at") if settled_at && !row.hand_settled?
|
|
87
|
+
@h.delete("acknowledged_at") if acknowledged_at && !row.acknowledged?
|
|
88
|
+
@h.delete("revived_at") if revived_at && !row.revived?
|
|
89
|
+
self
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def snooze(choice, now)
|
|
93
|
+
@h["wake_at"] = self.class.snooze_until(choice, now)
|
|
94
|
+
@h["snoozed_at"] = now.to_i
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Also lifts a hand-settle and a PR settle, so `u` undoes `x` and a resolved PR alike.
|
|
98
|
+
def wake(now)
|
|
99
|
+
@h.delete("wake_at")
|
|
100
|
+
@h.delete("snoozed_at")
|
|
101
|
+
@h.delete("settled_at")
|
|
102
|
+
@h["revived_at"] = now.to_i
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Also lifts a prior revive: otherwise revived_at would still outrank
|
|
106
|
+
# the fresh settle in `Row#section` and `x` after `u` would do nothing.
|
|
107
|
+
def settle(now)
|
|
108
|
+
@h["settled_at"] = now.to_i
|
|
109
|
+
@h.delete("wake_at")
|
|
110
|
+
@h.delete("snoozed_at")
|
|
111
|
+
@h.delete("revived_at")
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def acknowledge(now)
|
|
115
|
+
@h["acknowledged_at"] = now.to_i
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def toggle_pin(now)
|
|
119
|
+
if @h["pinned"]
|
|
120
|
+
@h.delete("pinned")
|
|
121
|
+
@h.delete("pinned_at")
|
|
122
|
+
else
|
|
123
|
+
@h["pinned"] = true
|
|
124
|
+
@h["pinned_at"] = now.to_i
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def alias=(name)
|
|
129
|
+
name.to_s.empty? ? @h.delete("alias") : @h["alias"] = name
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def pr=(url)
|
|
133
|
+
url.to_s.empty? ? @h.delete("pr") : @h["pr"] = url
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# So the Reaper backs off instead of shelling out every poll at a
|
|
137
|
+
# worktree that is never going to let go of its unpushed commits.
|
|
138
|
+
def mark_reap_failed(now, message)
|
|
139
|
+
@h["reap_failed_at"] = now.to_i
|
|
140
|
+
@h["reap_error"] = message.to_s.lines.first&.strip
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ClaudeInbox
|
|
4
|
+
class Store
|
|
5
|
+
# A session paired with its Entry (nil until a poll has recorded one), with
|
|
6
|
+
# the triage rules as its methods; each takes `now`, so a Row is pure.
|
|
7
|
+
Row = Struct.new(:session, :entry) do
|
|
8
|
+
def id = session.id
|
|
9
|
+
|
|
10
|
+
def key = session.key
|
|
11
|
+
|
|
12
|
+
def selectable? = !key.nil?
|
|
13
|
+
|
|
14
|
+
def alias_name = entry&.alias_name
|
|
15
|
+
|
|
16
|
+
def label = alias_name || session.display_name
|
|
17
|
+
|
|
18
|
+
def wake_at = entry&.wake_at
|
|
19
|
+
|
|
20
|
+
def parked? = entry&.parked?
|
|
21
|
+
|
|
22
|
+
def state_since = entry&.state_since
|
|
23
|
+
|
|
24
|
+
def pinned_at = entry&.pinned_at
|
|
25
|
+
|
|
26
|
+
def pinned? = entry&.pinned?
|
|
27
|
+
|
|
28
|
+
def reap_failed_at = entry&.reap_failed_at
|
|
29
|
+
|
|
30
|
+
def matches?(query)
|
|
31
|
+
q = query.downcase
|
|
32
|
+
label.downcase.include?(q) || session.cwd.to_s.downcase.include?(q)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# A session already blocked when you snoozed it stays snoozed; one that
|
|
36
|
+
# *becomes* blocked or fails afterwards wakes early.
|
|
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
|
|
43
|
+
|
|
44
|
+
def snoozed?(now) = wake_at && !woken?(now)
|
|
45
|
+
|
|
46
|
+
# `x` holds through the session finishing; any other state change afterwards lifts it.
|
|
47
|
+
def hand_settled?
|
|
48
|
+
return false unless entry&.settled_at
|
|
49
|
+
session.finished? || entry.state_since.to_i <= entry.settled_at.to_i
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Attaching marks the current state seen: out of Needs You, but into
|
|
53
|
+
# Active rather than Settled, until the state changes again.
|
|
54
|
+
def acknowledged?
|
|
55
|
+
entry&.acknowledged_at && entry.state_since.to_i <= entry.acknowledged_at.to_i
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# `u` overrides hand-settle and the PR rule alike, and holds until the
|
|
59
|
+
# state actually changes rather than just for the next poll.
|
|
60
|
+
def revived?
|
|
61
|
+
entry&.revived_at && entry.state_since.to_i <= entry.revived_at.to_i
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Resolved PRs settle a `blocked` row too: asking "anything need
|
|
65
|
+
# changing?" leaves it blocked, and merging answers it. `failed` stays
|
|
66
|
+
# in view even if its PR landed; a PR nobody knows the state of yet
|
|
67
|
+
# (no gh, offline) is ignored.
|
|
68
|
+
def settled?
|
|
69
|
+
return true if hand_settled?
|
|
70
|
+
return false if UNSETTLEABLE.include?(session.effective_state)
|
|
71
|
+
known = session.prs.select(&:known?)
|
|
72
|
+
known.any? && known.all?(&:resolved?)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Idle time is the only clock, not the Settled section: the PR rule holds
|
|
76
|
+
# an abandoned draft in Active for ever, so the deadest rows are the ones
|
|
77
|
+
# Settled never reaches. A session with no entry waits a poll, so its
|
|
78
|
+
# idle time is seeded from `started_at` rather than guessed.
|
|
79
|
+
def reapable?(now)
|
|
80
|
+
return false unless session.actionable?
|
|
81
|
+
return false if session.alive?
|
|
82
|
+
return false if UNREAPABLE.include?(session.effective_state)
|
|
83
|
+
return false if entry.nil? || pinned? || snoozed?(now)
|
|
84
|
+
!state_since.nil? && now.to_i - state_since.to_i > REAP_AFTER
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# The terminal outranks everything: never settle or hide a session you are in.
|
|
88
|
+
def section(now)
|
|
89
|
+
if session.terminal? then session.needs_you? ? :needs_you : :active
|
|
90
|
+
elsif pinned? then :pinned
|
|
91
|
+
elsif snoozed?(now) then :snoozed
|
|
92
|
+
elsif !revived? && hand_settled? then :settled
|
|
93
|
+
elsif !revived? && settled? then :settled # a resolved PR outranks Needs You
|
|
94
|
+
elsif session.needs_you? then acknowledged? ? :active : :needs_you
|
|
95
|
+
elsif session.finished? then :active
|
|
96
|
+
else :active
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ClaudeInbox
|
|
4
|
+
class Store
|
|
5
|
+
# The Rows of one poll, grouped and ordered, and where the cursor may land among them.
|
|
6
|
+
Sections = Struct.new(:pinned, :needs_you, :active, :snoozed, :settled) do
|
|
7
|
+
def each_section = SECTIONS.each { |k| yield k, self[k] }
|
|
8
|
+
|
|
9
|
+
def all = SECTIONS.flat_map { |k| self[k] }
|
|
10
|
+
|
|
11
|
+
def row(selection)
|
|
12
|
+
all.find { |r| r.key == selection.key } if selection&.row?
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def matching(query)
|
|
16
|
+
return self if query.nil? || query.empty?
|
|
17
|
+
self.class.new(**to_h.transform_values { |rows| rows.select { |r| r.matches?(query) } })
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def section_of(selection)
|
|
21
|
+
return nil if selection.nil?
|
|
22
|
+
return selection.key if selection.fold?
|
|
23
|
+
each_section { |name, rows| return name if rows.any? { |r| r.key == selection.key } }
|
|
24
|
+
nil
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def selections(expanded) = stops(expanded).flat_map(&:last)
|
|
28
|
+
|
|
29
|
+
# `[name, selection]` per section: its first row, or the fold standing in for them.
|
|
30
|
+
def heads(expanded)
|
|
31
|
+
stops(expanded).filter_map { |name, stops| [name, stops.first] if stops.any? }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
# An empty fold is left out, since there is nothing under it to open.
|
|
37
|
+
def stops(expanded)
|
|
38
|
+
SECTIONS.filter_map do |name|
|
|
39
|
+
rows = self[name]
|
|
40
|
+
if !Store.folded?(name, expanded) then [name, rows.select(&:selectable?).map { |r| Selection.row(r.key) }]
|
|
41
|
+
elsif rows.any? then [name, [Selection.fold(name)]]
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ClaudeInbox
|
|
4
|
+
class Store
|
|
5
|
+
# What the cursor is on: a row, by its key, or a fold, by its section name.
|
|
6
|
+
Selection = Data.define(:kind, :key) do
|
|
7
|
+
def self.row(key) = new(kind: :row, key: key)
|
|
8
|
+
|
|
9
|
+
def self.fold(section) = new(kind: :fold, key: section)
|
|
10
|
+
|
|
11
|
+
def row? = kind == :row
|
|
12
|
+
|
|
13
|
+
def fold? = kind == :fold
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "records"
|
|
4
|
+
require_relative "store/entry"
|
|
5
|
+
require_relative "store/row"
|
|
6
|
+
require_relative "store/selection"
|
|
7
|
+
require_relative "store/sections"
|
|
8
|
+
|
|
9
|
+
module ClaudeInbox
|
|
10
|
+
# The last poll and the per-session entry table, behind a mutex, with
|
|
11
|
+
# state.json underneath.
|
|
12
|
+
class Store
|
|
13
|
+
PRUNE_AFTER = 7 * 24 * 3600 # forget entries not seen in a poll for this long
|
|
14
|
+
REAP_AFTER = 14 * 24 * 3600 # seconds idle before a session is deleted outright
|
|
15
|
+
UNTIL_WOKEN = "until_woken"
|
|
16
|
+
# States that hold a row open no matter what its pull requests did.
|
|
17
|
+
UNSETTLEABLE = %w[working failed].freeze
|
|
18
|
+
# States never reaped, however long they have been quiet. Shorter than
|
|
19
|
+
# UNSETTLEABLE on purpose: `failed` earns a permanent row because you
|
|
20
|
+
# should see it, but after REAP_AFTER of not seeing it, you never will.
|
|
21
|
+
UNREAPABLE = %w[working].freeze
|
|
22
|
+
SECTIONS = %i[pinned needs_you active snoozed settled].freeze
|
|
23
|
+
# Sections long enough to be worth hiding behind a fold toggle.
|
|
24
|
+
FOLDABLE_SECTIONS = %i[snoozed settled].freeze
|
|
25
|
+
|
|
26
|
+
# App and Renderer must agree on this, or j/k lands on rows the frame never painted.
|
|
27
|
+
def self.folded?(name, expanded) = FOLDABLE_SECTIONS.include?(name) && !expanded[name]
|
|
28
|
+
|
|
29
|
+
def self.merge_entries(entries, sessions, now)
|
|
30
|
+
out = entries.reject { |_, e| Entry.new(e).stale?(now) }.transform_values(&:dup)
|
|
31
|
+
sessions.each do |s|
|
|
32
|
+
next unless s.key
|
|
33
|
+
out[s.key] = Entry.observe(out[s.key], s, now).to_h
|
|
34
|
+
end
|
|
35
|
+
out
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def self.sectionize(sessions, entries, now)
|
|
39
|
+
sec = Sections.new(pinned: [], needs_you: [], active: [], snoozed: [], settled: [])
|
|
40
|
+
sessions.each do |s|
|
|
41
|
+
row = Row.new(session: s, entry: s.key && entries[s.key] && Entry.new(entries[s.key]))
|
|
42
|
+
sec[row.section(now)] << row
|
|
43
|
+
end
|
|
44
|
+
sec.pinned.sort_by! { |r| -(r.pinned_at || 0) }
|
|
45
|
+
sec.needs_you.sort_by! { |r| -(r.state_since || 0) }
|
|
46
|
+
sec.active.sort_by! { |r| [r.session.finished? ? 1 : 0, -(r.session.started_at&.to_i || 0)] }
|
|
47
|
+
sec.snoozed.sort_by! { |r| r.parked? ? [1, 0] : [0, r.wake_at.to_i] }
|
|
48
|
+
sec.settled.sort_by! { |r| -(r.state_since || 0) }
|
|
49
|
+
sec
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
DEFAULT_PATH = File.join(Dir.home, ".config", "claude-inbox", "state.json")
|
|
53
|
+
|
|
54
|
+
attr_reader :path
|
|
55
|
+
|
|
56
|
+
def initialize(path: DEFAULT_PATH, clock: -> { Time.now })
|
|
57
|
+
@path = path
|
|
58
|
+
@clock = clock
|
|
59
|
+
@mutex = Mutex.new
|
|
60
|
+
@sessions = []
|
|
61
|
+
@hidden = Set.new
|
|
62
|
+
@forgotten = Set.new
|
|
63
|
+
@entries = load
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# A hidden or forgotten key the daemon has stopped listing is released;
|
|
67
|
+
# one it still lists stays out of sight.
|
|
68
|
+
def update(sessions)
|
|
69
|
+
@mutex.synchronize do
|
|
70
|
+
keys = sessions.map(&:key)
|
|
71
|
+
@hidden &= keys
|
|
72
|
+
@forgotten &= keys
|
|
73
|
+
@sessions = sessions.reject { |s| @hidden.include?(s.key) || @forgotten.include?(s.key) }
|
|
74
|
+
@entries = self.class.merge_entries(@entries, @sessions, @clock.call)
|
|
75
|
+
save
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def sections(now = @clock.call)
|
|
80
|
+
@mutex.synchronize { self.class.sectionize(@sessions, @entries, now) }
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def sessions = @mutex.synchronize { @sessions.dup }
|
|
84
|
+
|
|
85
|
+
def snooze(id, choice) = edit(id) { |e| e.snooze(choice, @clock.call) }
|
|
86
|
+
|
|
87
|
+
def wake(id) = edit(id) { |e| e.wake(@clock.call) }
|
|
88
|
+
|
|
89
|
+
def acknowledge(id) = edit(id) { |e| e.acknowledge(@clock.call) }
|
|
90
|
+
|
|
91
|
+
def settle(id) = edit(id) { |e| e.settle(@clock.call) }
|
|
92
|
+
|
|
93
|
+
def mark_reap_failed(id, message) = edit(id) { |e| e.mark_reap_failed(@clock.call, message) }
|
|
94
|
+
|
|
95
|
+
def toggle_pin(id) = edit(id) { |e| e.toggle_pin(@clock.call) }
|
|
96
|
+
|
|
97
|
+
def set_alias(id, name) = edit(id) { |e| e.alias = name }
|
|
98
|
+
|
|
99
|
+
# The local alias, if one is set; nil means the session's own name shows.
|
|
100
|
+
def alias_for(id) = @mutex.synchronize { @entries[id] && Entry.new(@entries[id]).alias_name }
|
|
101
|
+
|
|
102
|
+
def set_pr(id, url) = edit(id) { |e| e.pr = url }
|
|
103
|
+
|
|
104
|
+
# The hand-set link, if any; nil means the scanned links are in force.
|
|
105
|
+
def pr_for(id) = @mutex.synchronize { @entries[id] && Entry.new(@entries[id]).pr }
|
|
106
|
+
|
|
107
|
+
def pr_overrides
|
|
108
|
+
@mutex.synchronize { @entries.transform_values { |e| Entry.new(e).pr }.select { |_, url| url } }
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def row(session) = Row.new(session: session, entry: session.key && entry(session.key)&.then { |e| Entry.new(e) })
|
|
112
|
+
|
|
113
|
+
# The raw hash, for the specs; nothing in lib/ reads it.
|
|
114
|
+
def entry(id) = @mutex.synchronize { @entries[id]&.dup }
|
|
115
|
+
|
|
116
|
+
# Keeps rows out of sight while `claude rm` is on them, so none paints
|
|
117
|
+
# mid-delete; `release` brings back one `rm` refused. Memory only.
|
|
118
|
+
def hide(keys)
|
|
119
|
+
@mutex.synchronize do
|
|
120
|
+
@hidden.merge(keys)
|
|
121
|
+
@sessions = @sessions.reject { |s| keys.include?(s.key) }
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def release(keys) = @mutex.synchronize { @hidden.subtract(keys) }
|
|
126
|
+
|
|
127
|
+
# A session `claude rm` has taken stays hidden until `update` sees the
|
|
128
|
+
# daemon has dropped it too. Kept apart from `hide` so a `release` on the
|
|
129
|
+
# same poll cannot bring back what the user deleted.
|
|
130
|
+
def forget(id)
|
|
131
|
+
@mutex.synchronize do
|
|
132
|
+
@forgotten << id
|
|
133
|
+
@sessions = @sessions.reject { |s| s.key == id }
|
|
134
|
+
save if @entries.delete(id)
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
private
|
|
139
|
+
|
|
140
|
+
def edit(id)
|
|
141
|
+
@mutex.synchronize do
|
|
142
|
+
yield Entry.new(@entries[id] ||= Entry.blank(@clock.call).to_h)
|
|
143
|
+
save
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def load
|
|
148
|
+
Records.read(@path)["sessions"] || {}
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def save
|
|
152
|
+
return unless @path
|
|
153
|
+
Records.save(@path, {"version" => 1, "sessions" => @entries})
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ClaudeInbox
|
|
4
|
+
# Runs a helper command with stdout/stderr captured, in its own session so
|
|
5
|
+
# it is not associated with our tty. Terminal.app (and others) put the
|
|
6
|
+
# "active process" of the tty in the tab title; without this the title
|
|
7
|
+
# flips to "claude" or "ps" on every poll.
|
|
8
|
+
module Subprocess
|
|
9
|
+
Result = Struct.new(:out, :err, :status) do
|
|
10
|
+
def success? = status.success?
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
def capture(*argv, chdir: nil)
|
|
16
|
+
out_r, out_w = IO.pipe
|
|
17
|
+
err_r, err_w = IO.pipe
|
|
18
|
+
pid = fork do
|
|
19
|
+
Process.setsid
|
|
20
|
+
$stdin.reopen(File::NULL)
|
|
21
|
+
$stdout.reopen(out_w)
|
|
22
|
+
$stderr.reopen(err_w)
|
|
23
|
+
out_r.close
|
|
24
|
+
err_r.close
|
|
25
|
+
Dir.chdir(chdir) if chdir
|
|
26
|
+
exec(*argv)
|
|
27
|
+
rescue SystemCallError => e
|
|
28
|
+
$stderr.write(e.message)
|
|
29
|
+
exit! 127
|
|
30
|
+
end
|
|
31
|
+
out_w.close
|
|
32
|
+
err_w.close
|
|
33
|
+
out = +""
|
|
34
|
+
err = +""
|
|
35
|
+
readers = [out_r, err_r]
|
|
36
|
+
until readers.empty?
|
|
37
|
+
ready, = IO.select(readers)
|
|
38
|
+
ready.each do |io|
|
|
39
|
+
chunk = io.read_nonblock(65_536, exception: false)
|
|
40
|
+
if chunk.nil? || chunk == :wait_readable
|
|
41
|
+
next unless chunk.nil?
|
|
42
|
+
readers.delete(io)
|
|
43
|
+
io.close
|
|
44
|
+
else
|
|
45
|
+
(io.equal?(out_r) ? out : err) << chunk
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
_, status = Process.wait2(pid)
|
|
50
|
+
Result.new(out, err, status)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|