claude-inbox 0.2.0 → 0.3.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 (39) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +160 -152
  3. data/exe/claude-inbox +23 -4
  4. data/lib/claude_inbox/agents_client.rb +57 -55
  5. data/lib/claude_inbox/app.rb +42 -26
  6. data/lib/claude_inbox/config.rb +24 -0
  7. data/lib/claude_inbox/dialog.rb +3 -1
  8. data/lib/claude_inbox/images.rb +37 -6
  9. data/lib/claude_inbox/job_state.rb +23 -8
  10. data/lib/claude_inbox/keymap.rb +1 -1
  11. data/lib/claude_inbox/new_session_form.rb +29 -39
  12. data/lib/claude_inbox/painter.rb +32 -0
  13. data/lib/claude_inbox/palette.rb +4 -7
  14. data/lib/claude_inbox/peek.rb +2 -2
  15. data/lib/claude_inbox/poller.rb +4 -3
  16. data/lib/claude_inbox/pull_requests.rb +5 -5
  17. data/lib/claude_inbox/records.rb +7 -2
  18. data/lib/claude_inbox/remote/http.rb +137 -0
  19. data/lib/claude_inbox/remote/icon.png +0 -0
  20. data/lib/claude_inbox/remote/icon.svg +8 -0
  21. data/lib/claude_inbox/remote/listener.rb +436 -0
  22. data/lib/claude_inbox/remote/page.html +477 -0
  23. data/lib/claude_inbox/remote/pairing.rb +111 -0
  24. data/lib/claude_inbox/remote/pairing_dialog.rb +93 -0
  25. data/lib/claude_inbox/remote/start.rb +202 -0
  26. data/lib/claude_inbox/remote.rb +10 -0
  27. data/lib/claude_inbox/renderer.rb +57 -57
  28. data/lib/claude_inbox/session.rb +7 -21
  29. data/lib/claude_inbox/session_request.rb +151 -0
  30. data/lib/claude_inbox/sessions.rb +0 -1
  31. data/lib/claude_inbox/settings.rb +19 -7
  32. data/lib/claude_inbox/store.rb +0 -2
  33. data/lib/claude_inbox/subprocess.rb +9 -1
  34. data/lib/claude_inbox/terminal.rb +9 -3
  35. data/lib/claude_inbox/text.rb +7 -1
  36. data/lib/claude_inbox/trust.rb +23 -0
  37. data/lib/claude_inbox/vt_screen.rb +1 -1
  38. data/lib/claude_inbox.rb +1 -1
  39. metadata +14 -1
@@ -0,0 +1,202 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "openssl"
5
+ require_relative "../agents_client"
6
+ require_relative "../images"
7
+ require_relative "../job_state"
8
+ require_relative "../session"
9
+ require_relative "../session_request"
10
+ require_relative "../settings"
11
+ require_relative "../text"
12
+ require_relative "../trust"
13
+ require_relative "http"
14
+
15
+ module ClaudeInbox
16
+ module Remote
17
+ # What a paired phone may ask for: the choices, and a session started
18
+ # the way the `n` form starts one, within the permission cap. Listener
19
+ # hands it a request only once the token has checked out.
20
+ class Start
21
+ # Path => [verb, the method that answers it].
22
+ ROUTES = {"/api/options" => ["GET", :options], "/api/sessions" => ["POST", :post_session]}.freeze
23
+ VERBS = ROUTES.transform_values(&:first).freeze
24
+ MAX_BODY = 16 * 1024 * 1024
25
+ MAX_IMAGES = 8
26
+ BODY_TIMEOUT = 120
27
+ KEYS_KEPT = 16
28
+
29
+ def initialize(client:, store:, queue:, allowed_modes:, fixture: false, images_dir: Images::DEFAULT_DIR,
30
+ jobs_dir: JobState::DEFAULT_DIR, trust: Trust.method(:projects), settings: Settings.method(:defaults), bridge_wait: 3)
31
+ @client = client
32
+ @store = store
33
+ @queue = queue
34
+ @allowed_modes = allowed_modes
35
+ @fixture = fixture
36
+ @images_dir = images_dir
37
+ @jobs_dir = jobs_dir
38
+ @trust = trust
39
+ @settings = settings
40
+ @bridge_wait = bridge_wait
41
+ @mutex = Mutex.new
42
+ @spawn_lock = Mutex.new
43
+ @keys = {}
44
+ end
45
+
46
+ # => [status, headers, body, note], `note` being what `N` lists, if
47
+ # anything; or raises Http::Error.
48
+ def call(request, io, via) = send(ROUTES.fetch(request.path)[1], request, io, via)
49
+
50
+ private
51
+
52
+ def options(_request, _io, _via) = json(200, choices)
53
+
54
+ def json(status, body) = [status, Http::JSON_TYPE, JSON.generate(@fixture ? body.merge(fixture: true) : body)]
55
+
56
+ def choices
57
+ paths = dir_paths
58
+ {
59
+ models: AgentsClient::MODELS,
60
+ efforts: AgentsClient::EFFORTS,
61
+ permission_modes: AgentsClient::PERMISSION_MODES & @allowed_modes,
62
+ dirs: paths.map { |path| {path: path, label: SessionRequest.label(path, paths), defaults: @settings.call(path).to_h} }
63
+ }
64
+ end
65
+
66
+ # Where sessions ran lately, newest first, then every directory whose
67
+ # trust dialog was accepted: the likely choices first. Only an order;
68
+ # the CLI still decides where a session may start.
69
+ def dir_paths
70
+ recent = @store.sessions.sort_by { |s| -s.started_at.to_i }.filter_map { |s| s.cwd && SessionRequest.strip_worktree(s.cwd) }
71
+ (recent + @trust.call).uniq.select { |dir| File.directory?(dir) }
72
+ end
73
+
74
+ # Checked cheapest first, and nothing is written to disk until every
75
+ # check has passed. A retry with the same Idempotency-Key gets the
76
+ # first answer instead of a second session; the key sent with a
77
+ # different request is refused, since that answer isn't this one's.
78
+ def post_session(request, io, via)
79
+ params = read_json(io, request)
80
+ key = request.headers["idempotency-key"]
81
+ key = nil if key.to_s.empty?
82
+ digest = OpenSSL::Digest.digest("SHA256", JSON.generate(params)) if key
83
+ images = take_images(params)
84
+ earlier = key && claim(key, digest)
85
+ return json(200, earlier) if earlier
86
+ started = nil
87
+ begin
88
+ started = launch(params, images, via)
89
+ [*json(201, started), "started #{started[:id]}"]
90
+ ensure
91
+ settle(key, digest, started) if key
92
+ end
93
+ rescue AgentsClient::Error => e
94
+ reason = Text.printable(e.message.lines.first.to_s.strip)
95
+ @queue << [:notice, "remote start failed: #{reason}"]
96
+ raise Http::Error.new(500, e.message, source: "claude", note: reason)
97
+ end
98
+
99
+ def read_json(io, request)
100
+ type = request.headers["content-type"].to_s.split(";").first.to_s.strip
101
+ raise Http::Error.new(415, "send the request as application/json") unless type.casecmp?("application/json")
102
+ body = request.read_body(io, max: MAX_BODY, deadline: Http.monotonic + BODY_TIMEOUT).force_encoding(Encoding::UTF_8)
103
+ raise Http::Error.new(400, "the body isn't UTF-8") unless body.valid_encoding?
104
+ params = JSON.parse(body)
105
+ raise Http::Error.new(400, "the body must be a JSON object") unless params.is_a?(Hash)
106
+ params
107
+ rescue JSON::ParserError
108
+ raise Http::Error.new(400, "the body isn't JSON")
109
+ end
110
+
111
+ # Decoded and sniffed now, saved only once the request has passed.
112
+ def take_images(params)
113
+ images = params.delete("images") || []
114
+ raise Http::Error.new(422, "images is a list of {\"data\": base64}", field: "images") unless images.is_a?(Array)
115
+ raise Http::Error.new(413, "at most #{MAX_IMAGES} images", field: "images") if images.size > MAX_IMAGES
116
+ images.each_with_index.map do |image, i|
117
+ bytes = decode(image)
118
+ next bytes if bytes && Images.extension(bytes)
119
+ raise Http::Error.new(415, "image #{i + 1} isn't a PNG, JPEG, GIF or WebP image", field: "images", index: i)
120
+ end
121
+ end
122
+
123
+ def decode(image)
124
+ data = image["data"] if image.is_a?(Hash)
125
+ data.gsub(/\s+/, "").unpack1("m0") if data.is_a?(String)
126
+ rescue ArgumentError
127
+ nil
128
+ end
129
+
130
+ # The body an earlier request with this key got, or nil once the key
131
+ # is this request's; one still starting is a 409.
132
+ def claim(key, digest)
133
+ @mutex.synchronize do
134
+ sent, earlier = @keys[key]
135
+ if sent
136
+ raise Http::Error.new(422, "this Idempotency-Key was sent with a different request") unless sent == digest
137
+ raise Http::Error.new(409, "already starting") if earlier == :spawning
138
+ return earlier
139
+ end
140
+ @keys.shift while @keys.size >= KEYS_KEPT
141
+ @keys[key] = [digest, :spawning]
142
+ nil
143
+ end
144
+ end
145
+
146
+ # A failed start frees its key, so a retry goes through rather than
147
+ # getting 409 for ever.
148
+ def settle(key, digest, started)
149
+ @mutex.synchronize do
150
+ if started then @keys[key] = [digest, started]
151
+ else @keys.delete(key)
152
+ end
153
+ end
154
+ end
155
+
156
+ def launch(params, images, via)
157
+ values = SessionRequest.from_params(params, dirs: dir_paths)
158
+ field, message = SessionRequest.problem(values)
159
+ raise Http::Error.new(422, message, field: field) if field
160
+ defaults = @settings.call(values[:cwd])
161
+ values = SessionRequest.resolve(values, defaults)
162
+ values[:permission_mode] = capped_mode(values[:permission_mode] || defaults.permission_mode || builtin_mode)
163
+ paths = images.each_with_index.map { |bytes, i| Images.save(bytes, dir: @images_dir, index: i + 1) }
164
+ values[:prompt] = SessionRequest.attach(values[:prompt], paths)
165
+ @queue << [:notice, "remote: starting session…"]
166
+ id = Http.utf8(@spawn_lock.synchronize { @client.spawn(**values) })
167
+ url = remote_url(id, wait: values[:remote] ? @bridge_wait : 0)
168
+ @queue << [:remote_started, id, via]
169
+ {id: id, name: values[:name], cwd: values[:cwd], url: url}
170
+ rescue SessionRequest::Invalid => e
171
+ raise Http::Error.new(422, e.message, field: e.field)
172
+ end
173
+
174
+ # Unset, the mode is what the settings files say, so a project defaulting
175
+ # to bypassPermissions can't pass as "default". The spawn always names the
176
+ # mode that passed: the flag beats every settings file short of managed
177
+ # policy, so one the inbox doesn't read can't widen it.
178
+ def capped_mode(mode)
179
+ return mode if @allowed_modes.include?(mode)
180
+ raise Http::Error.new(403, "permission mode #{mode} isn't allowed from another device", field: "permission_mode")
181
+ end
182
+
183
+ # Unset in settings, the CLI's own default is auto or manual, never
184
+ # wider. Passed as "default" the CLI takes manual, so auto is named
185
+ # outright wherever it is allowed.
186
+ def builtin_mode = @allowed_modes.include?("auto") ? "auto" : "default"
187
+
188
+ # The session registers its bridge a moment after `claude --bg`
189
+ # returns; without it in time the reply has no URL, only the id.
190
+ # Without --remote-control it seldom registers one (docs/cli-quirks.md),
191
+ # so that start looks once rather than waits.
192
+ def remote_url(id, wait:)
193
+ deadline = Http.monotonic + wait
194
+ loop do
195
+ url = Session.new(id: id, job_state: JobState.read(id, jobs_dir: @jobs_dir)).remote_url
196
+ return url if url || Http.monotonic >= deadline
197
+ sleep 0.2
198
+ end
199
+ end
200
+ end
201
+ end
202
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeInbox
4
+ # Starting sessions from another device. App reaches it only through
5
+ # Remote::Listener.
6
+ module Remote
7
+ end
8
+ end
9
+
10
+ require_relative "remote/listener"
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "pastel"
4
- require "tty-cursor"
5
4
  require_relative "text"
6
5
  require_relative "palette"
7
6
  require_relative "theme"
@@ -20,11 +19,17 @@ module ClaudeInbox
20
19
 
21
20
  Frame = Struct.new(:lines, :items, :top, :list_width)
22
21
 
23
- # What App hands Renderer for one frame. `peek` is a Peek::View.
22
+ CHROME_ROWS = 2 # header + footer
23
+ MIN_LIST_WIDTH = 44 # with the peek open, the list keeps at least this many columns
24
+
25
+ def self.body_height(height) = height - CHROME_ROWS
26
+
27
+ # What App hands Renderer for one frame. `peek` is a Peek::View and
28
+ # `listening` a Remote::Listener::Snapshot.
24
29
  View = Data.define(:width, :height, :now, :selected, :top, :expanded, :peek, :modal, :screen,
25
- :status, :usage, :filter, :filter_editing, :tick, :loading) do
30
+ :status, :usage, :filter, :filter_editing, :tick, :loading, :listening) do
26
31
  def initialize(width:, height:, now:, selected: nil, top: 0, expanded: {}, peek: nil, modal: nil, screen: nil,
27
- status: nil, usage: nil, filter: nil, filter_editing: false, tick: 0, loading: nil) = super
32
+ status: nil, usage: nil, filter: nil, filter_editing: false, tick: 0, loading: nil, listening: nil) = super
28
33
  end
29
34
 
30
35
  SECTION_TITLES = {
@@ -37,6 +42,10 @@ module ClaudeInbox
37
42
 
38
43
  SECTION_HUES = {pinned: :cyan, needs_you: :red, active: :yellow, snoozed: :purple}.freeze
39
44
 
45
+ # GitHub's colors. A draft has no hue, so it dims; a state missing from
46
+ # the table isn't named on the badge at all.
47
+ PR_HUES = {"OPEN" => :green, "DRAFT" => nil, "MERGED" => :purple, "CLOSED" => :red}.freeze
48
+
40
49
  SPINNER = %w[⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏].freeze
41
50
 
42
51
  # The first poll usually lands inside a second. Past LOADING_QUIET the
@@ -61,11 +70,10 @@ module ClaudeInbox
61
70
  ["za", "fold"], ["/", "filter"], ["q", "quit"]
62
71
  ].freeze
63
72
 
64
- def initialize(color: true, min_left: 44, home: Dir.home)
73
+ def initialize(color: true, home: Dir.home)
65
74
  @p = Pastel.new(enabled: color)
66
75
  @theme = Theme.new(enabled: color)
67
76
  @palette = Palette.new(enabled: color)
68
- @min_left = min_left
69
77
  @home = home
70
78
  end
71
79
 
@@ -75,7 +83,7 @@ module ClaudeInbox
75
83
  return full_screen(sections, view) if view.screen
76
84
  width, height, now, selected = view.width, view.height, view.now, view.selected
77
85
  list_w = width_for_list(width, view.peek)
78
- view_h = height - 2 # header + footer
86
+ view_h = Renderer.body_height(height)
79
87
  body, items =
80
88
  if view.loading
81
89
  [loading_state(list_w, view_h, view.loading, view.tick), []]
@@ -106,7 +114,7 @@ module ClaudeInbox
106
114
 
107
115
  def width_for_list(width, peek)
108
116
  return width unless peek
109
- [(width * 0.4).floor, @min_left].max.clamp(0, width)
117
+ [(width * 0.4).floor, MIN_LIST_WIDTH].max.clamp(0, width)
110
118
  end
111
119
 
112
120
  def clamp_top(top, size, view_h, items, selected)
@@ -118,33 +126,43 @@ module ClaudeInbox
118
126
 
119
127
  def full_screen(sections, view)
120
128
  width = view.width
121
- view_h = view.height - 2
129
+ view_h = Renderer.body_height(view.height)
122
130
  body = view.screen[:lines].first(view_h)
123
131
  body += [""] * (view_h - body.size)
124
132
  lines = [header(sections, width, view)] + body.map { |l| Text.pad(l, width) } + [Text.pad(" " + view.screen[:footer], width)]
125
- Frame.new(lines, [nil] * (view_h + 2), view.top, width)
133
+ Frame.new(lines, [nil] * view.height, view.top, width)
126
134
  end
127
135
 
128
136
  # ----- chrome -------------------------------------------------------------
129
137
 
130
138
  def header(sections, width, view)
131
139
  brand = " " + @theme.cyan_bold("▌ claude-inbox")
132
- right = [view.status && @p.dim(view.status), view.usage && usage_meters(view.usage, view.now)].compact.join(@p.dim(" · "))
133
- right += " " unless right.empty?
140
+ right = header_right(view, width - Text.width(brand) - 3)
134
141
  room = width - Text.width(brand) - Text.width(right) - 3
135
- chips = view.loading ? "" : header_chips(sections, compact: false)
136
- chips = header_chips(sections, compact: true) if Text.width(chips) > room
142
+ chips = view.loading ? "" : header_chips(sections, compact: false, listening: view.listening)
143
+ chips = header_chips(sections, compact: true, listening: view.listening) if Text.width(chips) > room
137
144
  chips = "" if Text.width(chips) > room
138
145
  Text.pad(brand + " " + chips, width - Text.width(right)) + right
139
146
  end
140
147
 
148
+ # The notice outranks the usage meters: they shed their reset times, then
149
+ # go, before the notice itself is cut to fit.
150
+ def header_right(view, room)
151
+ meters = view.usage ? [usage_meters(view.usage, view.now), usage_meters(view.usage, view.now, resets: false)] : []
152
+ (meters + [nil]).each do |m|
153
+ right = [view.status && @p.dim(view.status), m].compact.join(@p.dim(" · "))
154
+ return right.empty? ? right : right + " " if Text.width(right) < room
155
+ end
156
+ (view.status && room > 1) ? @p.dim(Text.truncate(view.status, room - 1)) + " " : ""
157
+ end
158
+
141
159
  # "session ██░░░░░░░░ 24% · 3h left" per window, drawn and colored the way
142
160
  # the context bar in the user's status line is, so the two read alike.
143
- def usage_meters(windows, now)
161
+ def usage_meters(windows, now, resets: true)
144
162
  windows.map do |w|
145
163
  filled = w.percent / 10
146
164
  bar = "█" * filled + "░" * (10 - filled)
147
- left = w.resets_at && w.resets_at.to_i - now.to_i
165
+ left = w.resets_at.to_i - now.to_i if resets && w.resets_at
148
166
  figure = left&.positive? ? "#{w.percent}% · #{Text.age(left)} left" : "#{w.percent}%"
149
167
  "#{@p.dim(w.label)} #{@theme.public_send(usage_hue(w.percent), bar)} #{@p.dim(figure)}"
150
168
  end.join(" ")
@@ -157,7 +175,7 @@ module ClaudeInbox
157
175
  end
158
176
  end
159
177
 
160
- def header_chips(sections, compact:)
178
+ def header_chips(sections, compact:, listening: nil)
161
179
  pinned = sections.pinned.size
162
180
  needing = sections.needs_you.size
163
181
  working = sections.active.count { |r| r.session.effective_state == "working" && !r.session.waiting_on_work? }
@@ -177,7 +195,16 @@ module ClaudeInbox
177
195
  header_chip(settled, "◦", "settled", compact) { |s| @p.dim(s) }
178
196
  ].compact
179
197
  chips << @p.dim("nothing running") if sections.all.empty?
180
- chips.join(compact ? " " : @p.dim(" · "))
198
+ [*chips, listening_chip(listening)].compact.join(compact ? " " : @p.dim(" · "))
199
+ end
200
+
201
+ # Red when the listener was asked for and isn't up. Nothing when it
202
+ # wasn't asked for.
203
+ def listening_chip(listening)
204
+ case listening&.state
205
+ when :listening then @theme.blue("◉ #{"lan" if listening.lan}:#{listening.port}")
206
+ when :in_use, :held, :failed then @theme.red("◉ !")
207
+ end
181
208
  end
182
209
 
183
210
  def header_chip(count, glyph, words, compact)
@@ -189,11 +216,16 @@ module ClaudeInbox
189
216
  text =
190
217
  if view.filter_editing then " " + @theme.cyan_bold("/") + line(view.filter, width - 2)
191
218
  elsif view.filter then " " + @theme.cyan_bold("/") + view.filter.to_s + @p.dim(" esc clears")
192
- else " " + KEYS.map { |k, d| @theme.cyan_bold(k) + " " + @p.dim(d) }.join(" ")
219
+ else " " + footer_keys(view).map { |k, d| @theme.cyan_bold(k) + " " + @p.dim(d) }.join(" ")
193
220
  end
194
221
  Text.pad(text, width)
195
222
  end
196
223
 
224
+ def footer_keys(view)
225
+ return KEYS unless view.listening&.lan
226
+ KEYS.flat_map { |key| (key[0] == "n") ? [key, ["N", "pair"]] : [key] }
227
+ end
228
+
197
229
  def line(buffer, width) = buffer.row(width, cursor: caret)
198
230
 
199
231
  def section_title(name, count, width)
@@ -203,8 +235,9 @@ module ClaudeInbox
203
235
  Text.pad(" " + section_color(name, "▎") + section_color(name, @p.bold(title)) + @p.dim("─" * fill) + @p.dim(count_s), width)
204
236
  end
205
237
 
206
- def section_color(name, s)
207
- hue = SECTION_HUES[name]
238
+ def section_color(name, s) = tint(SECTION_HUES[name], s)
239
+
240
+ def tint(hue, s)
208
241
  hue ? @theme.public_send(hue, s) : @p.dim(s)
209
242
  end
210
243
 
@@ -366,19 +399,13 @@ module ClaudeInbox
366
399
  pr ? base + @p.dim(" · ") + pr : base
367
400
  end
368
401
 
369
- # "#885 open" in GitHub's colors: green open, dim draft, purple merged,
370
- # red closed. Only the first PR is shown; the peek subtitle lists them all.
402
+ # "#885 open". Only the first PR is shown; the peek subtitle lists them all.
371
403
  def pr_badge(s, section)
372
404
  pr = s.pr
373
405
  return nil unless pr
374
406
  return @p.dim("#{pr.short} #{pr.state&.downcase}".strip) if section == :settled
375
- case pr.state
376
- when "OPEN" then @theme.green("#{pr.short} open")
377
- when "DRAFT" then @p.dim("#{pr.short} draft")
378
- when "MERGED" then @theme.purple("#{pr.short} merged")
379
- when "CLOSED" then @theme.red("#{pr.short} closed")
380
- else @p.dim(pr.short)
381
- end
407
+ return @p.dim(pr.short) unless PR_HUES.key?(pr.state)
408
+ tint(PR_HUES[pr.state], "#{pr.short} #{pr.state.downcase}")
382
409
  end
383
410
 
384
411
  def state_badge(s)
@@ -439,31 +466,4 @@ module ClaudeInbox
439
466
  out
440
467
  end
441
468
  end
442
-
443
- # Diffs successive frames and writes only changed rows. No erase-to-end-of-
444
- # line after a row: in the terminal's last column the cursor stays put
445
- # (pending wrap), so EL would eat the glyph just drawn.
446
- class Painter
447
- def initialize(out, cursor: TTY::Cursor)
448
- @out = out
449
- @cursor = cursor
450
- @prev = []
451
- end
452
-
453
- def paint(lines)
454
- buf = +""
455
- lines.each_with_index do |line, i|
456
- next if @prev[i] == line
457
- buf << @cursor.move_to(0, i) << line
458
- end
459
- if @prev.size > lines.size
460
- (lines.size...@prev.size).each { |i| buf << @cursor.move_to(0, i) << @cursor.clear_line }
461
- end
462
- @out.print buf unless buf.empty?
463
- @out.flush
464
- @prev = lines
465
- end
466
-
467
- def invalidate = @prev = []
468
- end
469
469
  end
@@ -14,10 +14,8 @@ module ClaudeInbox
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.
17
- def initialize(id: nil, cwd: nil, kind: nil, started_at: nil, session_id: nil, name: nil,
18
- state: nil, pid: nil, status: nil, waiting_for: nil, origin: nil, prs: nil, job_state: nil, bridge_id: nil)
19
- super(id: id, cwd: cwd, kind: kind, started_at: started_at, session_id: session_id, name: name,
20
- state: state, pid: pid, status: status, waiting_for: waiting_for, origin: origin, prs: prs || [], job_state: job_state, bridge_id: bridge_id)
17
+ def initialize(prs: nil, **given)
18
+ super(**self.class.members.to_h { |m| [m, nil] }, **given, prs: prs || [])
21
19
  end
22
20
 
23
21
  def self.from_hash(h)
@@ -59,9 +57,9 @@ module ClaudeInbox
59
57
 
60
58
  def terminal? = interactive? && !remote? && !unattended?
61
59
 
62
- # Where claude.ai/code shows this session. Every background session
63
- # registers a bridge and records it in the job file; a worker a `claude
64
- # remote-control` server spawned carries it on its command line.
60
+ # Where claude.ai/code shows this session. A background session with
61
+ # Remote Control on records its bridge in the job file; a worker a
62
+ # `claude remote-control` server spawned carries it on its command line.
65
63
  def remote_url
66
64
  bridge = bridge_id || job_state&.bridge_id
67
65
  bridge && "https://claude.ai/code/session_#{bridge.delete_prefix("cse_")}"
@@ -84,20 +82,8 @@ module ClaudeInbox
84
82
  def intent = job_state&.intent
85
83
 
86
84
  # The session's own one-line account of where it is, the same line
87
- # `claude agents` prints under a row: what it needs while blocked, what
88
- # it produced once done, otherwise its status line. Nil without a job
89
- # file, or before the session has said anything.
90
- def summary
91
- return nil unless job_state
92
- line =
93
- case effective_state
94
- when "blocked" then job_state.needs || job_state.detail
95
- when "done" then job_state.result || job_state.detail
96
- else job_state.detail
97
- end
98
- line = line.to_s.gsub(/\s+/, " ").strip
99
- line.empty? ? nil : line
100
- end
85
+ # `claude agents` prints under a row. Nil without a job file.
86
+ def summary = job_state&.summary(effective_state)
101
87
 
102
88
  def needs_you? = %w[blocked failed].include?(effective_state)
103
89
 
@@ -0,0 +1,151 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "agents_client"
4
+
5
+ module ClaudeInbox
6
+ # The rules a new session's settings follow, whichever way it is asked
7
+ # for: the `n` form, or a request that arrives as a hash of JSON values.
8
+ module SessionRequest
9
+ class Invalid < StandardError
10
+ attr_reader :field
11
+
12
+ def initialize(field, message)
13
+ @field = field
14
+ super(message)
15
+ end
16
+ end
17
+
18
+ KEYS = %w[prompt name cwd model effort permission_mode worktree remote].freeze
19
+ CHOICES = {
20
+ model: AgentsClient::MODELS,
21
+ effort: AgentsClient::EFFORTS,
22
+ permission_mode: AgentsClient::PERMISSION_MODES
23
+ }.freeze
24
+ FLAGS = {true => true, false => false, "yes" => true, "no" => false}.freeze
25
+ MAX_PROMPT = 100_000
26
+ MAX_NAME = 100
27
+
28
+ # The hash NewSessionForm#values builds, before `resolve`, from a
29
+ # request's fields, or Invalid. "default" is nil. An unknown key is
30
+ # refused rather than ignored, so a misspelt setting can't fall back to
31
+ # its default unnoticed. A blank prompt, or a directory that does not
32
+ # exist, passes here; `problem` reports it as the form does.
33
+ def self.from_params(params, dirs: [])
34
+ params = params.transform_keys(&:to_s)
35
+ unknown = params.keys - KEYS
36
+ raise Invalid.new(unknown.first.to_sym, "unknown key: #{unknown.first.inspect}") if unknown.any?
37
+ {
38
+ prompt: prompt_param(params["prompt"]),
39
+ name: name_param(params["name"]),
40
+ cwd: cwd_param(params["cwd"], dirs),
41
+ model: choice_param(:model, params["model"]),
42
+ effort: choice_param(:effort, params["effort"]),
43
+ permission_mode: choice_param(:permission_mode, params["permission_mode"]),
44
+ worktree: flag_param(:worktree, params["worktree"]) || false,
45
+ remote: flag_param(:remote, params["remote"])
46
+ }
47
+ end
48
+
49
+ # Unset Remote Control follows /config, then is passed either way so
50
+ # the daemon records it in respawnFlags. The rest stay nil, for the CLI.
51
+ def self.resolve(values, defaults)
52
+ values[:remote].nil? ? values.merge(remote: defaults.remote == "yes") : values
53
+ end
54
+
55
+ # What stops these values from starting a session, as [field, message]
56
+ # for the form to show and focus, or nil when nothing does.
57
+ def self.problem(values)
58
+ return [:prompt, "a prompt is required"] if values[:prompt].empty?
59
+ return [:cwd, "no such directory: #{values[:cwd]}"] unless File.directory?(values[:cwd])
60
+ nil
61
+ end
62
+
63
+ # Saved images go into the prompt as the @ mentions the CLI reads. An
64
+ # `[Image #n]` becomes the nth path, the way a chip expands in the form;
65
+ # a path the prompt never points at goes on the end.
66
+ def self.attach(prompt, paths)
67
+ used = []
68
+ text = prompt.gsub(/\[Image #(\d+)\]/i) do |token|
69
+ n = token[/\d+/].to_i
70
+ next token unless n.between?(1, paths.size)
71
+ used << n
72
+ AgentsClient.mention(paths[n - 1])
73
+ end
74
+ rest = paths.reject.with_index(1) { |_, n| used.include?(n) }.map { |path| AgentsClient.mention(path) }
75
+ [text, rest.join("\n")].reject(&:empty?).join("\n\n")
76
+ end
77
+
78
+ # A session's cwd may sit inside a worktree another agent is using;
79
+ # carrying that into a new prompt would spawn the new agent there too,
80
+ # writing over the same files. Fall back to the repo it was cut from.
81
+ def self.strip_worktree(cwd) = cwd.to_s.sub(%r{/\.claude/worktrees/[^/]+(?:/.*)?\z}, "")
82
+
83
+ # The daemon's summary line can echo the prompt to the terminal, so no
84
+ # control character but tab and newline gets that far. A CRLF, the
85
+ # newline an HTTP client may send, is taken as a newline.
86
+ def self.prompt_param(value)
87
+ prompt = string_param(:prompt, value || "").strip.gsub(/\r\n?/, "\n")
88
+ raise Invalid.new(:prompt, "the prompt is over #{MAX_PROMPT} characters") if prompt.length > MAX_PROMPT
89
+ if prompt.match?(/[[:cntrl:]&&[^\t\n]]/)
90
+ raise Invalid.new(:prompt, "the prompt has a control character other than tab or newline")
91
+ end
92
+ prompt
93
+ end
94
+
95
+ # The name comes back as the row's label, printed as it is.
96
+ def self.name_param(value)
97
+ return nil if value.nil?
98
+ name = string_param(:name, value).strip
99
+ raise Invalid.new(:name, "a name is one line") if name.match?(/[[:cntrl:]\u2028\u2029]/)
100
+ raise Invalid.new(:name, "the name is over #{MAX_NAME} characters") if name.length > MAX_NAME
101
+ name.empty? ? nil : name
102
+ end
103
+
104
+ # The fewest trailing components that name `path` alone among `dirs`.
105
+ def self.label(path, dirs)
106
+ parts = path.split("/").reject(&:empty?)
107
+ (1..parts.size).each do |n|
108
+ label = parts.last(n).join("/")
109
+ return label if dirs.one? { |other| other.end_with?("/#{label}") }
110
+ end
111
+ path
112
+ end
113
+
114
+ # A path is taken as given. Anything else names one of `dirs` by its
115
+ # `label`.
116
+ def self.cwd_param(value, dirs)
117
+ cwd = string_param(:cwd, value || "").strip
118
+ raise Invalid.new(:cwd, "a directory is required") if cwd.empty?
119
+ raise Invalid.new(:cwd, "a directory is one line") if cwd.match?(/[[:cntrl:]\u2028\u2029]/)
120
+ return File.expand_path(cwd) if cwd.match?(%r{\A(/|~(/|\z))})
121
+ found = dirs.select { |dir| dir.end_with?("/#{cwd}") }
122
+ raise Invalid.new(:cwd, "no directory called #{cwd}") if found.empty?
123
+ raise Invalid.new(:cwd, "#{cwd} could be any of #{found.join(", ")}") if found.size > 1
124
+ found.first
125
+ end
126
+
127
+ def self.choice_param(key, value)
128
+ return nil if value.nil? || value == "default"
129
+ return value if CHOICES[key].include?(value)
130
+ raise Invalid.new(key, "#{key} is one of #{CHOICES[key].join(", ")}")
131
+ end
132
+
133
+ def self.flag_param(key, value)
134
+ return nil if value.nil?
135
+ FLAGS.fetch(value) { raise Invalid.new(key, "#{key} is true, false, yes or no") }
136
+ end
137
+
138
+ # Each of these ends up in an argv or a path, where a NUL byte raises.
139
+ # The bytes are checked as UTF-8 whatever the string's tag says: a
140
+ # binary string always passes `valid_encoding?`.
141
+ def self.string_param(key, value)
142
+ raise Invalid.new(key, "#{key} must be a string") unless value.is_a?(String)
143
+ text = String.new(value, encoding: Encoding::UTF_8)
144
+ raise Invalid.new(key, "#{key} is not valid UTF-8") unless text.valid_encoding?
145
+ raise Invalid.new(key, "#{key} contains a NUL byte") if text.include?("\0")
146
+ text
147
+ end
148
+
149
+ private_class_method :prompt_param, :name_param, :cwd_param, :choice_param, :flag_param, :string_param
150
+ end
151
+ end
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "job_state"
4
- require_relative "pull_requests"
5
4
 
6
5
  module ClaudeInbox
7
6
  # Puts the list together. JobState goes before PullRequests because the PR