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.
Files changed (40) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +442 -0
  4. data/exe/claude-inbox +33 -0
  5. data/lib/claude_inbox/agents_client.rb +224 -0
  6. data/lib/claude_inbox/app.rb +533 -0
  7. data/lib/claude_inbox/debug.rb +16 -0
  8. data/lib/claude_inbox/dialog.rb +105 -0
  9. data/lib/claude_inbox/images.rb +58 -0
  10. data/lib/claude_inbox/job_state.rb +89 -0
  11. data/lib/claude_inbox/keymap.rb +71 -0
  12. data/lib/claude_inbox/logs.rb +66 -0
  13. data/lib/claude_inbox/mouse.rb +34 -0
  14. data/lib/claude_inbox/new_session_form.rb +363 -0
  15. data/lib/claude_inbox/palette.rb +40 -0
  16. data/lib/claude_inbox/paste.rb +42 -0
  17. data/lib/claude_inbox/peek.rb +81 -0
  18. data/lib/claude_inbox/poller.rb +98 -0
  19. data/lib/claude_inbox/pull_requests.rb +155 -0
  20. data/lib/claude_inbox/rate_limits.rb +48 -0
  21. data/lib/claude_inbox/reaper.rb +103 -0
  22. data/lib/claude_inbox/records.rb +28 -0
  23. data/lib/claude_inbox/renderer.rb +447 -0
  24. data/lib/claude_inbox/session.rb +100 -0
  25. data/lib/claude_inbox/sessions.rb +18 -0
  26. data/lib/claude_inbox/settings.rb +39 -0
  27. data/lib/claude_inbox/slash_commands.rb +116 -0
  28. data/lib/claude_inbox/store/entry.rb +144 -0
  29. data/lib/claude_inbox/store/row.rb +101 -0
  30. data/lib/claude_inbox/store/sections.rb +47 -0
  31. data/lib/claude_inbox/store/selection.rb +16 -0
  32. data/lib/claude_inbox/store.rb +156 -0
  33. data/lib/claude_inbox/subprocess.rb +53 -0
  34. data/lib/claude_inbox/terminal.rb +101 -0
  35. data/lib/claude_inbox/text.rb +98 -0
  36. data/lib/claude_inbox/text_buffer.rb +216 -0
  37. data/lib/claude_inbox/theme.rb +35 -0
  38. data/lib/claude_inbox/vt_screen.rb +138 -0
  39. data/lib/claude_inbox.rb +14 -0
  40. 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