claude-inbox 0.3.0 → 0.5.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 (44) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +93 -165
  3. data/exe/claude-inbox +49 -4
  4. data/lib/claude_inbox/actions.rb +91 -0
  5. data/lib/claude_inbox/agents_client.rb +15 -10
  6. data/lib/claude_inbox/app.rb +58 -18
  7. data/lib/claude_inbox/headless.rb +30 -0
  8. data/lib/claude_inbox/job_state.rb +4 -4
  9. data/lib/claude_inbox/keymap.rb +0 -2
  10. data/lib/claude_inbox/mod.rb +71 -0
  11. data/lib/claude_inbox/new_session_form.rb +67 -31
  12. data/lib/claude_inbox/painter.rb +3 -6
  13. data/lib/claude_inbox/paste.rb +2 -0
  14. data/lib/claude_inbox/poller.rb +46 -25
  15. data/lib/claude_inbox/pull_requests.rb +32 -18
  16. data/lib/claude_inbox/rate_limits.rb +2 -4
  17. data/lib/claude_inbox/reaper.rb +1 -3
  18. data/lib/claude_inbox/remote/http.rb +11 -8
  19. data/lib/claude_inbox/remote/listener.rb +4 -9
  20. data/lib/claude_inbox/remote/start.rb +4 -11
  21. data/lib/claude_inbox/renderer.rb +8 -16
  22. data/lib/claude_inbox/session_request.rb +10 -0
  23. data/lib/claude_inbox/settings.rb +1 -0
  24. data/lib/claude_inbox/slash_commands.rb +13 -12
  25. data/lib/claude_inbox/snapshot.rb +47 -0
  26. data/lib/claude_inbox/store/entry.rb +0 -2
  27. data/lib/claude_inbox/store/row.rb +7 -5
  28. data/lib/claude_inbox/store.rb +24 -9
  29. data/lib/claude_inbox/switch.rb +38 -0
  30. data/lib/claude_inbox/terminal.rb +8 -0
  31. data/lib/claude_inbox/text_buffer.rb +84 -33
  32. data/lib/claude_inbox/writer_lock.rb +58 -0
  33. data/lib/claude_inbox.rb +1 -10
  34. data/mod/.claude-plugin/plugin.json +20 -0
  35. data/mod/hooks/hooks.json +1 -0
  36. data/mod/hooks/register.tsx +491 -0
  37. data/mod/tests/contract.test.tsx +21 -0
  38. data/mod/tests/pane.test.tsx +217 -0
  39. data/mod/tests/snapshot.fixture.ts +44 -0
  40. data/mod/tests/snooze.test.tsx +31 -0
  41. data/mod/tsconfig.json +3 -0
  42. data/mod/types/index.d.ts +28 -0
  43. metadata +16 -2
  44. data/lib/claude_inbox/remote.rb +0 -10
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "records"
4
+
5
+ module ClaudeInbox
6
+ # The sections as the screen shows them, written to a file for another
7
+ # front end to read, so the triage rules run once, here. Rewritten when
8
+ # the rows change and every HEARTBEAT seconds regardless, so a reader can
9
+ # tell a quiet inbox from one that has quit.
10
+ class Snapshot
11
+ DEFAULT_PATH = File.join(Dir.home, ".config", "claude-inbox", "snapshot.json")
12
+ HEARTBEAT = 5
13
+
14
+ def self.disabled = new(path: nil)
15
+
16
+ def initialize(path: DEFAULT_PATH)
17
+ @path = path
18
+ @body = nil
19
+ @written_at = nil
20
+ @mutex = Mutex.new
21
+ end
22
+
23
+ # Called from the poller's threads and the screen's alike.
24
+ def write(sections, now)
25
+ return unless @path
26
+ body = sections.to_h { |name, rows| [name.to_s, rows.map { |row| row_hash(row) }] }
27
+ @mutex.synchronize do
28
+ return if body == @body && now.to_i - @written_at < HEARTBEAT
29
+ @body = body
30
+ @written_at = now.to_i
31
+ Records.save(@path, {"version" => 1, "written_at" => @written_at, "sections" => body})
32
+ end
33
+ end
34
+
35
+ private
36
+
37
+ def row_hash(row)
38
+ s = row.session
39
+ pr = s.pr
40
+ {
41
+ "id" => row.key, "session" => s.session_id, "label" => row.label, "state" => s.effective_state, "actionable" => s.actionable?,
42
+ "waiting" => s.waiting_on_work? || nil, "line" => s.summary, "wake_at" => row.wake_at, "remote" => s.remote_url,
43
+ "pr" => pr && {"short" => pr.short, "state" => pr.state&.downcase, "url" => pr.url}
44
+ }.compact
45
+ end
46
+ end
47
+ end
@@ -69,8 +69,6 @@ module ClaudeInbox
69
69
  # When `claude rm` last refused this session, so the Reaper backs off.
70
70
  def reap_failed_at = @h["reap_failed_at"]
71
71
 
72
- def reap_error = @h["reap_error"]
73
-
74
72
  def stale?(now) = last_seen && now.to_i - last_seen > PRUNE_AFTER
75
73
 
76
74
  def observe(session, now)
@@ -5,8 +5,6 @@ module ClaudeInbox
5
5
  # A session paired with its Entry (nil until a poll has recorded one), with
6
6
  # the triage rules as its methods; each takes `now`, so a Row is pure.
7
7
  Row = Struct.new(:session, :entry) do
8
- def id = session.id
9
-
10
8
  def key = session.key
11
9
 
12
10
  def selectable? = !key.nil?
@@ -66,12 +64,16 @@ module ClaudeInbox
66
64
  # Resolved PRs settle a `blocked` row too: asking "anything need
67
65
  # changing?" leaves it blocked, and merging answers it. `failed` stays
68
66
  # in view even if its PR landed; a PR nobody knows the state of yet
69
- # (no gh, offline) is ignored.
67
+ # (no gh, offline) is ignored. So is one resolved before the session
68
+ # started: reviewing an old PR, or the CLI resolving a bare PR number
69
+ # against the wrong repo, would otherwise hide a session waiting on you.
70
+ # Until gh says when a PR was resolved, it holds the row in view.
70
71
  def settled?
71
72
  return true if hand_settled?
72
73
  return false if UNSETTLEABLE.include?(session.effective_state)
73
- known = session.prs.select(&:known?)
74
- known.any? && known.all?(&:resolved?)
74
+ started = session.started_at.to_i
75
+ ours = session.prs.select(&:known?).reject { |pr| pr.resolved_before?(started) }
76
+ ours.any? && ours.all? { |pr| pr.resolved_since?(started) }
75
77
  end
76
78
 
77
79
  # Idle time is the only clock, not the Settled section: the PR rule holds
@@ -20,6 +20,7 @@ module ClaudeInbox
20
20
  # should see it, but after REAP_AFTER of not seeing it, you never will.
21
21
  UNREAPABLE = %w[working].freeze
22
22
  SECTIONS = %i[pinned needs_you active snoozed settled].freeze
23
+ NotWritable = Class.new(StandardError)
23
24
  # Sections long enough to be worth hiding behind a fold toggle.
24
25
  FOLDABLE_SECTIONS = %i[snoozed settled].freeze
25
26
 
@@ -56,9 +57,15 @@ module ClaudeInbox
56
57
 
57
58
  DEFAULT_PATH = File.join(Dir.home, ".config", "claude-inbox", "state.json")
58
59
 
59
- def initialize(path: DEFAULT_PATH, clock: -> { Time.now })
60
+ # A store that is not `writable` reads the file but never saves it: the
61
+ # second inbox open at once, which reads along with the first rather
62
+ # than overwriting what it and the pane's requests settled, and sends
63
+ # its own edits to the first through the `relay` as requests.
64
+ def initialize(path: DEFAULT_PATH, clock: -> { Time.now }, writable: true, relay: nil)
60
65
  @path = path
61
66
  @clock = clock
67
+ @writable = writable
68
+ @relay = relay
62
69
  @mutex = Mutex.new
63
70
  @sessions = []
64
71
  @hidden = Set.new
@@ -74,7 +81,7 @@ module ClaudeInbox
74
81
  @hidden &= keys
75
82
  @forgotten &= keys
76
83
  @sessions = sessions.reject { |s| @hidden.include?(s.key) || @forgotten.include?(s.key) }
77
- @entries = self.class.merge_entries(@entries, @sessions, @clock.call)
84
+ @entries = self.class.merge_entries(@writable ? @entries : load, @sessions, @clock.call)
78
85
  save
79
86
  end
80
87
  end
@@ -85,17 +92,19 @@ module ClaudeInbox
85
92
 
86
93
  def sessions = @mutex.synchronize { @sessions.dup }
87
94
 
88
- def snooze(id, choice) = edit(id) { |e| e.snooze(choice, @clock.call) }
95
+ def writable? = @writable
89
96
 
90
- def wake(id) = edit(id) { |e| e.wake(@clock.call) }
97
+ def snooze(id, choice) = edit(id, "snooze", choice: choice.to_s) { |e| e.snooze(choice, @clock.call) }
91
98
 
92
- def acknowledge(id) = edit(id) { |e| e.acknowledge(@clock.call) }
99
+ def wake(id) = edit(id, "wake") { |e| e.wake(@clock.call) }
93
100
 
94
- def settle(id) = edit(id) { |e| e.settle(@clock.call) }
101
+ def acknowledge(id) = edit(id, "acknowledge") { |e| e.acknowledge(@clock.call) }
102
+
103
+ def settle(id) = edit(id, "settle") { |e| e.settle(@clock.call) }
95
104
 
96
105
  def mark_reap_failed(id, message) = edit(id) { |e| e.mark_reap_failed(@clock.call, message) }
97
106
 
98
- def toggle_pin(id) = edit(id) { |e| e.toggle_pin(@clock.call) }
107
+ def toggle_pin(id) = edit(id, "pin") { |e| e.toggle_pin(@clock.call) }
99
108
 
100
109
  def set_alias(id, name) = edit(id) { |e| e.alias = name }
101
110
 
@@ -144,11 +153,17 @@ module ClaudeInbox
144
153
 
145
154
  private
146
155
 
147
- def edit(id)
156
+ # A store that cannot save still makes the change here, for the screen,
157
+ # and asks the writer for it by `action` through the relay, so the next
158
+ # poll finds it done rather than undone. An edit with no request form
159
+ # would only be undone, so it is refused outright.
160
+ def edit(id, action = nil, **detail)
161
+ raise NotWritable, "another claude-inbox owns state.json" if !@writable && action.nil?
148
162
  @mutex.synchronize do
149
163
  yield Entry.new(@entries[id] ||= Entry.blank(@clock.call).to_h)
150
164
  save
151
165
  end
166
+ @relay&.request(action, id, **detail) if !@writable && action
152
167
  end
153
168
 
154
169
  def load
@@ -156,7 +171,7 @@ module ClaudeInbox
156
171
  end
157
172
 
158
173
  def save
159
- return unless @path
174
+ return unless @path && @writable
160
175
  Records.save(@path, {"version" => 1, "sessions" => @entries})
161
176
  end
162
177
  end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "records"
4
+
5
+ module ClaudeInbox
6
+ # A request from another front end to attach to a different session: one
7
+ # file, written by the asker and taken by the inbox attached to the asker's
8
+ # own session. Only that inbox can act on it, which is why the asker gives
9
+ # up on a request nobody takes, and why the request names who asked.
10
+ class Switch
11
+ DEFAULT_PATH = File.join(Dir.home, ".config", "claude-inbox", "switch.json")
12
+
13
+ def self.disabled = new(path: nil)
14
+
15
+ def initialize(path: DEFAULT_PATH)
16
+ @path = path
17
+ end
18
+
19
+ # Whether the session `from` has asked to go elsewhere.
20
+ def requested_for?(from)
21
+ return false if from.nil?
22
+ Records.read(@path)["from"] == from
23
+ end
24
+
25
+ # The requested id if it is one of `attachable`, and the request is gone either way.
26
+ def take(attachable)
27
+ id = Records.read(@path)["id"]
28
+ clear
29
+ attachable.include?(id) ? id : nil
30
+ end
31
+
32
+ def clear
33
+ File.delete(@path) if @path
34
+ rescue Errno::ENOENT
35
+ nil
36
+ end
37
+ end
38
+ end
@@ -76,6 +76,14 @@ module ClaudeInbox
76
76
  enter
77
77
  end
78
78
 
79
+ # Inside `release`: keeps what a failed child printed on the screen
80
+ # until a key is pressed, since `enter` would cover it at once.
81
+ def pause
82
+ @out.print "\r\npress any key to return to claude-inbox"
83
+ @out.flush
84
+ @input.tty? ? @input.getch : @input.getc
85
+ end
86
+
79
87
  # [cols, rows]. Cached: querying the terminal can fall back to spawning
80
88
  # `tput`, which is far too slow to do on every frame. Refreshed by
81
89
  # `resized`, which WINCH and re-entry both call.
@@ -9,20 +9,34 @@ module ClaudeInbox
9
9
  #
10
10
  # Positions count cells, not bytes or characters. A cell is a grapheme
11
11
  # cluster — prompts and session names carry emoji, and character indexes
12
- # cut them in half — or a Chip, an attached image that shows as one
13
- # `[Image #1]` token and moves and deletes as one unit, the way Claude
14
- # Code's own prompt treats a pasted image.
12
+ # cut them in half — or a chip: an attached Image or a long Pasted text,
13
+ # each shown as one token (`[Image #1]`, `[Pasted text #1 +40 lines]`)
14
+ # that moves and deletes as one unit, the way Claude Code's own prompt
15
+ # treats them.
15
16
  #
16
17
  # b = TextBuffer.new("ab")
17
18
  # b.press(:left, "\e[D")
18
19
  # b.press("X", "X")
19
20
  # b.to_s # => "aXb"
20
21
  class TextBuffer
21
- Chip = Struct.new(:n, :path) do
22
+ Image = Struct.new(:n, :path) do
22
23
  def to_s = "[Image ##{n}]"
23
24
  end
24
25
 
26
+ Pasted = Struct.new(:n, :text) do
27
+ def to_s
28
+ lines = text.count("\n")
29
+ "[Pasted text ##{n} #{(lines > 0) ? "+#{lines} lines" : "#{text.size} chars"}]"
30
+ end
31
+ end
32
+
33
+ # Past either, a paste folds into a Pasted chip: laid out in full it
34
+ # buries the prompt around it, and wrapping it costs every frame.
35
+ FOLD_CHARS = 800
36
+ FOLD_LINES = 10
37
+
25
38
  def initialize(text = "")
39
+ @widths = Hash.new { |h, s| h[s] = Text.width(s) }
26
40
  @g = text.grapheme_clusters
27
41
  @cursor = @g.size
28
42
  end
@@ -31,13 +45,17 @@ module ClaudeInbox
31
45
 
32
46
  def empty? = @g.empty?
33
47
 
34
- # Grapheme offset of the cursor. Rendering goes through #row / #view;
35
- # this is here for callers that need to reason about position (tests).
36
- attr_reader :cursor
37
-
38
- def chips = @g.grep(Chip)
39
-
40
- def expand = @g.map { |c| c.is_a?(Chip) ? yield(c) : c }.join
48
+ # The text as it will be sent: a Pasted chip unfolds to its text, and
49
+ # the block says what each Image becomes.
50
+ def expand
51
+ @g.map { |c|
52
+ case c
53
+ when Image then yield(c)
54
+ when Pasted then c.text
55
+ else c
56
+ end
57
+ }.join
58
+ end
41
59
 
42
60
  # Swaps the whole text out and parks the cursor at the end — what
43
61
  # completion wants after it extends a path.
@@ -62,13 +80,13 @@ module ClaudeInbox
62
80
  @cursor += g.size
63
81
  end
64
82
 
65
- # Numbered after the chips already in the text, so a second image is
83
+ # Chips are numbered per kind and never reused, so a second image is
66
84
  # `[Image #2]` even after the first was deleted, as Claude Code does.
67
- def attach(path)
68
- chip = Chip.new(@next_chip = (@next_chip || 0) + 1, path)
69
- @g.insert(@cursor, chip)
70
- @cursor += 1
71
- chip
85
+ def attach(path) = place(Image.new(@images = (@images || 0) + 1, path))
86
+
87
+ def paste(text)
88
+ return insert(text) if text.size <= FOLD_CHARS && text.count("\n") < FOLD_LINES
89
+ place(Pasted.new(@pastes = (@pastes || 0) + 1, text))
72
90
  end
73
91
 
74
92
  # Handles one keypress — readline's editing keys, plus printable text —
@@ -98,12 +116,18 @@ module ClaudeInbox
98
116
  # gets plain truncated text instead.
99
117
  def row(width, cursor: nil)
100
118
  return Text.truncate(to_s, width) unless cursor
101
- first = 0
102
- first += 1 while width_of(@g[first...@cursor]) > width - 1
119
+ first = @cursor
120
+ used = 0
121
+ while first > 0 && used + cell_width(@g[first - 1]) <= width - 1
122
+ first -= 1
123
+ used += cell_width(@g[first])
124
+ end
103
125
  cells = []
126
+ used = 0
104
127
  @g[first..].each do |c|
105
- break if width_of(cells) + width_of([c]) > width
128
+ break if used + cell_width(c) > width
106
129
  cells << c
130
+ used += cell_width(c)
107
131
  end
108
132
  paint(cells, @cursor - first, cursor)
109
133
  end
@@ -129,12 +153,14 @@ module ClaudeInbox
129
153
 
130
154
  private
131
155
 
132
- def width_of(cells) = cells.sum { |c| Text.width(c.to_s) }
156
+ def width_of(cells) = cells.sum { |c| cell_width(c) }
157
+
158
+ def cell_width(cell) = @widths[cell.to_s]
133
159
 
134
160
  def paint(cells, offset, cursor, chip = nil)
135
161
  out = cells.each_with_index.map do |c, i|
136
162
  s = c.to_s
137
- s = chip.call(s) if chip && c.is_a?(Chip)
163
+ s = chip.call(s) if chip && !c.is_a?(String)
138
164
  (i == offset) ? cursor.call(s) : s
139
165
  end
140
166
  out << cursor.call(" ") if offset && offset >= cells.size
@@ -160,29 +186,48 @@ module ClaudeInbox
160
186
 
161
187
  # Text.wrap on cells, keeping the trailing spaces the cursor may sit on:
162
188
  # a chip is one cell however wide its label, so the wrap has to measure
163
- # cells rather than a joined string.
189
+ # cells rather than a joined string. Widths are kept running rather than
190
+ # re-measured per word: this runs every frame, over the whole prompt.
164
191
  def segments(cells, width)
165
- return [cells] if width_of(cells) <= width
166
- words = cells.slice_when { |c, _| c == " " }.to_a
167
192
  lines = []
168
193
  line = []
169
- words.each do |word|
170
- if !line.empty? && width_of(line) + width_of(word.reverse.drop_while { |c| c == " " }) > width
194
+ used = 0
195
+ cells.slice_when { |c, _| c == " " }.each do |word|
196
+ word_w = width_of(word)
197
+ bare_w = (word.last == " ") ? word_w - 1 : word_w
198
+ if !line.empty? && used + bare_w > width
171
199
  lines << line
172
200
  line = []
201
+ used = 0
173
202
  end
174
- while width_of(word) > width
175
- cut = word.size - 1
176
- cut -= 1 while cut > 0 && width_of(word[0...cut]) > width
177
- lines << word[0...cut]
178
- word = word[cut..]
203
+ at = 0
204
+ while word_w > width
205
+ cut, cut_w = fit(word, at, width)
206
+ lines << word[at...at + cut]
207
+ at += cut
208
+ word_w -= cut_w
179
209
  end
180
- line += word
210
+ line.concat(word[at..])
211
+ used += word_w
181
212
  end
182
213
  lines << line unless line.empty?
183
214
  lines
184
215
  end
185
216
 
217
+ # How many cells of `word` from `at` fit in `width`, and their width.
218
+ # Always at least one, so a glyph wider than the box still moves on.
219
+ def fit(word, at, width)
220
+ count = 0
221
+ used = 0
222
+ (at...word.size).each do |i|
223
+ w = cell_width(word[i])
224
+ break if count > 0 && used + w > width
225
+ count += 1
226
+ used += w
227
+ end
228
+ [count, used]
229
+ end
230
+
186
231
  # The newline itself belongs to the line it ends, so a cursor sitting on
187
232
  # it lands past the end of that line rather than at the start of the next.
188
233
  def logical_lines
@@ -200,6 +245,12 @@ module ClaudeInbox
200
245
 
201
246
  def cursor_row(rows) = rows.rindex { |_, start| start <= @cursor } || 0
202
247
 
248
+ def place(chip)
249
+ @g.insert(@cursor, chip)
250
+ @cursor += 1
251
+ chip
252
+ end
253
+
203
254
  def delete(at, length)
204
255
  return if at < 0 || length <= 0
205
256
  @g.slice!(at, length)
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module ClaudeInbox
6
+ # One snapshot writer per machine: the inbox you can see, else a headless
7
+ # one. The file names the holder's role beside its pid, so an inbox takes
8
+ # the lock over from a headless process, which it ends, and never from
9
+ # another inbox; a headless process that finds the lock held exits.
10
+ class WriterLock
11
+ DEFAULT_PATH = File.join(Dir.home, ".config", "claude-inbox", "writer.lock")
12
+ EVICT_TRIES = 20
13
+
14
+ def self.disabled = new(path: nil)
15
+
16
+ def initialize(path: DEFAULT_PATH)
17
+ @path = path
18
+ @file = nil
19
+ end
20
+
21
+ # True once held. Only a headless holder is evicted, and only by an inbox.
22
+ def take(role:)
23
+ return true unless @path
24
+ FileUtils.mkdir_p(File.dirname(@path))
25
+ file = File.open(@path, File::RDWR | File::CREAT, 0o600)
26
+ return hold(file, role) if file.flock(File::LOCK_EX | File::LOCK_NB)
27
+ holder_role, holder = file.read.split
28
+ return refuse(file) unless role == "inbox" && holder_role == "headless"
29
+ Process.kill("TERM", holder.to_i) if holder.to_i.positive? && holder.to_i != Process.pid
30
+ EVICT_TRIES.times do
31
+ return hold(file, role) if file.flock(File::LOCK_EX | File::LOCK_NB)
32
+ sleep 0.1
33
+ end
34
+ refuse(file)
35
+ rescue Errno::ESRCH, Errno::EPERM
36
+ # The holder the file names is gone already: the lock is free.
37
+ file.flock(File::LOCK_EX | File::LOCK_NB) ? hold(file, role) : refuse(file)
38
+ end
39
+
40
+ private
41
+
42
+ # After reading the holder the position sits at the end, and truncate does
43
+ # not move it, so the write is rewound first or it lands after NUL padding.
44
+ def hold(file, role)
45
+ file.rewind
46
+ file.truncate(0)
47
+ file.write("#{role} #{Process.pid}")
48
+ file.flush
49
+ @file = file # only kept so GC never closes it and drops the flock
50
+ true
51
+ end
52
+
53
+ def refuse(file)
54
+ file.close
55
+ false
56
+ end
57
+ end
58
+ end
data/lib/claude_inbox.rb CHANGED
@@ -1,14 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ClaudeInbox
4
- VERSION = "0.3.0"
4
+ VERSION = "0.5.0"
5
5
  end
6
-
7
- require_relative "claude_inbox/palette"
8
- require_relative "claude_inbox/session"
9
- require_relative "claude_inbox/job_state"
10
- require_relative "claude_inbox/pull_requests"
11
- require_relative "claude_inbox/agents_client"
12
- require_relative "claude_inbox/sessions"
13
- require_relative "claude_inbox/store"
14
- require_relative "claude_inbox/reaper"
@@ -0,0 +1,20 @@
1
+ {
2
+ "name": "inbox-pane",
3
+ "version": "0.2.0",
4
+ "description": "claude-inbox's sections in a pane beside the transcript, with its keys.",
5
+ "types": "./types/index.d.ts",
6
+ "userConfig": {
7
+ "command": {
8
+ "type": "string",
9
+ "title": "claude-inbox command",
10
+ "description": "The claude-inbox executable to run headless when no inbox is writing the snapshot.",
11
+ "default": "claude-inbox"
12
+ },
13
+ "openOnStart": {
14
+ "type": "boolean",
15
+ "title": "Open on start",
16
+ "description": "Open the pane when a session starts. Off, /inbox opens it.",
17
+ "default": true
18
+ }
19
+ }
20
+ }
@@ -0,0 +1 @@
1
+ { "modules": ["./register.tsx"] }