samagotchi 0.103.0 → 0.105.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 (83) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +76 -1
  3. data/bin/chi +39 -2
  4. data/docs/architecture.md +36 -24
  5. data/docs/cli.md +97 -21
  6. data/docs/configuration.md +1 -1
  7. data/docs/guardrails.md +15 -1
  8. data/docs/internals/anthropic.md +2 -1
  9. data/docs/internals/context-telemetry.md +6 -2
  10. data/docs/internals/engine.md +22 -1
  11. data/docs/internals/participants.md +167 -0
  12. data/docs/internals/prompt-caching.md +8 -0
  13. data/docs/internals/remote-sessions.md +153 -27
  14. data/docs/internals/working-set.md +4 -3
  15. data/docs/plugins.md +7 -1
  16. data/docs/sessions.md +4 -2
  17. data/docs/sub-agent.md +8 -0
  18. data/docs/testing.md +89 -4
  19. data/lib/samagotchi/bridge/turn_accumulator.rb +2 -1
  20. data/lib/samagotchi/bridge.rb +17 -10
  21. data/lib/samagotchi/bridge_client/event_stream.rb +9 -4
  22. data/lib/samagotchi/bridge_client.rb +4 -1
  23. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  24. data/lib/samagotchi/chi_invocation.rb +13 -16
  25. data/lib/samagotchi/config.rb +1 -1
  26. data/lib/samagotchi/context_status.rb +15 -2
  27. data/lib/samagotchi/engine.rb +52 -8
  28. data/lib/samagotchi/guardrail_wiring.rb +7 -1
  29. data/lib/samagotchi/idle_recap.rb +1 -1
  30. data/lib/samagotchi/idle_scheduler.rb +34 -9
  31. data/lib/samagotchi/kernel_loop.rb +8 -1
  32. data/lib/samagotchi/last_turn.rb +5 -2
  33. data/lib/samagotchi/launch_mode.rb +4 -0
  34. data/lib/samagotchi/llm/chat_loop.rb +9 -1
  35. data/lib/samagotchi/owner_lock.rb +65 -5
  36. data/lib/samagotchi/participant_name.rb +21 -0
  37. data/lib/samagotchi/participants.rb +122 -0
  38. data/lib/samagotchi/queued_command.rb +9 -5
  39. data/lib/samagotchi/remote_attach_command.rb +119 -0
  40. data/lib/samagotchi/run_options.rb +14 -2
  41. data/lib/samagotchi/send_command.rb +1 -1
  42. data/lib/samagotchi/session.rb +8 -4
  43. data/lib/samagotchi/session_access/contract.rb +42 -4
  44. data/lib/samagotchi/session_access/dispatch.rb +29 -10
  45. data/lib/samagotchi/session_access/local.rb +52 -1
  46. data/lib/samagotchi/session_access/remote.rb +112 -16
  47. data/lib/samagotchi/session_access/web_bridge_client.rb +60 -3
  48. data/lib/samagotchi/session_access.rb +85 -3
  49. data/lib/samagotchi/session_continue.rb +1 -1
  50. data/lib/samagotchi/session_inbox.rb +18 -9
  51. data/lib/samagotchi/session_manager.rb +21 -5
  52. data/lib/samagotchi/session_metrics.rb +7 -1
  53. data/lib/samagotchi/session_participants.rb +59 -0
  54. data/lib/samagotchi/sessions_command.rb +14 -11
  55. data/lib/samagotchi/share_command.rb +174 -0
  56. data/lib/samagotchi/steer.rb +83 -10
  57. data/lib/samagotchi/system_prompt.rb +21 -2
  58. data/lib/samagotchi/terminal_ui/attach_launcher.rb +17 -4
  59. data/lib/samagotchi/terminal_ui/attach_link.rb +10 -0
  60. data/lib/samagotchi/terminal_ui/attached_loop.rb +117 -29
  61. data/lib/samagotchi/terminal_ui/formatting.rb +13 -1
  62. data/lib/samagotchi/terminal_ui/local_link.rb +5 -0
  63. data/lib/samagotchi/terminal_ui/reline_seam.rb +21 -1
  64. data/lib/samagotchi/terminal_ui/remote_connector.rb +66 -0
  65. data/lib/samagotchi/terminal_ui/remote_link.rb +245 -0
  66. data/lib/samagotchi/terminal_ui/version_lines.rb +23 -0
  67. data/lib/samagotchi/terminal_ui.rb +4 -1
  68. data/lib/samagotchi/testing/waiting.rb +92 -9
  69. data/lib/samagotchi/tools/task_runtime.rb +17 -3
  70. data/lib/samagotchi/turn_checkpoint.rb +5 -2
  71. data/lib/samagotchi/version.rb +1 -1
  72. data/lib/samagotchi/web/app.rb +187 -74
  73. data/lib/samagotchi/web/grants.rb +303 -0
  74. data/lib/samagotchi/web/participant_access.rb +286 -0
  75. data/lib/samagotchi/web/principal.rb +29 -0
  76. data/lib/samagotchi/web/public/index.html +6 -5
  77. data/lib/samagotchi/web/public/working_set.js +6 -4
  78. data/lib/samagotchi/web/public/working_set_view.js +1 -1
  79. data/lib/samagotchi/web/server.rb +3 -1
  80. data/lib/samagotchi/web/session_access_routes.rb +120 -4
  81. data/lib/samagotchi/worker.rb +44 -11
  82. data/lib/samagotchi/worker_inbound.rb +5 -2
  83. metadata +12 -1
@@ -170,7 +170,7 @@ module Samagotchi
170
170
  private_class_method def self.discard_failed_link(id, state_dir)
171
171
  orphan = SessionChain.next_of(id, state_dir: state_dir) or return
172
172
  if SessionManager.refuse_tui!(orphan, state_dir: state_dir) &&
173
- !SessionManager.stop_session(orphan, state_dir: state_dir, wait: 10)
173
+ !SessionManager.stop_session(orphan, state_dir: state_dir, wait: SessionManager::STOP_WAIT)
174
174
  raise SessionManager::DeleteRefused.new(orphan, :still_stopping)
175
175
  end
176
176
 
@@ -45,18 +45,23 @@ module Samagotchi
45
45
  # "cut" or "queue"
46
46
  # @param images [Array<Hash>] image refs ({file:, name:}) in the
47
47
  # session's images/
48
+ # @param author [String, nil] who wrote the message when it isn't the
49
+ # session's owner (Web::Principal#author); nil, the owner's, is never
50
+ # written, so an owner's file is as it always was
51
+ # @param now [Time] the time the file is named by (a spec's clock)
48
52
  # @return [String, false] the input file's path, or false
49
53
  def self.write_input(session_dir, prompt:, client_id: nil, enqueued_id: nil, no_interrupt: false, images: [],
50
- delivery: nil)
54
+ delivery: nil, author: nil, now: Time.now)
51
55
  images = Array(images)
52
56
  input_dir = File.join(session_dir, INPUT_DIR)
53
57
  FileUtils.mkdir_p(input_dir)
54
58
 
55
- path = File.join(input_dir, "#{Time.now.strftime("%Y%m%d%H%M%S%9N")}.json")
59
+ path = File.join(input_dir, "#{now.strftime("%Y%m%d%H%M%S%9N")}.json")
56
60
  record = { "prompt" => prompt.to_s, "client_id" => client_id, "enqueued_id" => enqueued_id,
57
61
  "no_interrupt" => (no_interrupt ? true : nil),
58
62
  "delivery" => (Delivery.queue?(delivery) ? Delivery::QUEUE : nil),
59
- "images" => (images.empty? ? nil : images.map { |image| image.transform_keys(&:to_s) }) }.compact
63
+ "images" => (images.empty? ? nil : images.map { |image| image.transform_keys(&:to_s) }),
64
+ "author" => author }.compact
60
65
  AtomicFile.write(path, JSON.generate(record))
61
66
  path
62
67
  rescue StandardError
@@ -67,9 +72,11 @@ module Samagotchi
67
72
  # to the conversation between turns. It never starts a turn.
68
73
  # @param source [String] where it came from ("cli", "slack", "session")
69
74
  # @param from_session [String, nil] the sending session, for a peer's note
75
+ # @param now [Time] the time the file is named and stamped by (a spec's clock)
70
76
  # @return [String] the note file's path
71
77
  # @raise [NoteRejected] for an empty note or one over 16 KiB (never cut)
72
- def self.write_note(session_id, text:, source: "cli", from_session: nil, from_cwd: nil, state_dir: nil)
78
+ def self.write_note(session_id, text:, source: "cli", from_session: nil, from_cwd: nil, state_dir: nil,
79
+ now: Time.now)
73
80
  body = checked_text(text)
74
81
  sd = state_dir || Session.default_state_dir
75
82
  notes_dir = File.join(Session.session_dir(session_id, state_dir: sd), NOTES_DIR)
@@ -77,10 +84,10 @@ module Samagotchi
77
84
 
78
85
  # The random part keeps two writers in one nanosecond apart; the
79
86
  # timestamp keeps the names in arrival order.
80
- name = "#{Time.now.strftime("%Y%m%d%H%M%S%9N")}-#{SecureRandom.hex(3)}.json"
87
+ name = "#{now.strftime("%Y%m%d%H%M%S%9N")}-#{SecureRandom.hex(3)}.json"
81
88
  path = File.join(notes_dir, name)
82
89
  record = { "text" => body, "source" => source.to_s, "from_session" => from_session,
83
- "from_cwd" => from_cwd, "created_at" => Time.now.iso8601 }.compact
90
+ "from_cwd" => from_cwd, "created_at" => now.iso8601 }.compact
84
91
  AtomicFile.write(path, JSON.generate(record))
85
92
  path
86
93
  end
@@ -166,10 +173,11 @@ module Samagotchi
166
173
  # so an id'd name and a bare one of the same timestamp keep order);
167
174
  # every reader globs *.txt.
168
175
  # @param enqueued_id [String, nil] the turn's input id (LastTurn#enqueued_id)
169
- def self.write_output(session_dir, response, enqueued_id: nil)
176
+ # @param now [Time] the time the file is named by (a spec's clock)
177
+ def self.write_output(session_dir, response, enqueued_id: nil, now: Time.now)
170
178
  output_dir = File.join(session_dir, OUTPUT_DIR)
171
179
  FileUtils.mkdir_p(output_dir)
172
- timestamp = Time.now.strftime("%Y%m%d%H%M%S%9N")
180
+ timestamp = now.strftime("%Y%m%d%H%M%S%9N")
173
181
  AtomicFile.write(File.join(output_dir, "#{output_name(timestamp, enqueued_id)}.txt"), response.to_s)
174
182
  end
175
183
 
@@ -229,7 +237,8 @@ module Samagotchi
229
237
  data = JSON.parse(File.read(claimed_file).to_s)
230
238
  return UNREADABLE_INPUT unless data.is_a?(Hash)
231
239
 
232
- origin = { client_id: data["client_id"], enqueued_id: data["enqueued_id"] }.compact
240
+ # author: only a file that names one (an owner's names none).
241
+ origin = { client_id: data["client_id"], enqueued_id: data["enqueued_id"], author: data["author"] }.compact
233
242
  images = Array(data["images"]).select { |image| image.is_a?(Hash) }.map { |image| image.transform_keys(&:to_sym) }
234
243
  Input.new(prompt: data["prompt"].to_s, origin: origin.empty? ? nil : origin, no_interrupt: data["no_interrupt"] == true,
235
244
  images: images, delivery: Delivery.queue?(data["delivery"]) ? Delivery::QUEUE : nil)
@@ -69,6 +69,8 @@ module Samagotchi
69
69
  autoload :SessionRetention, File.expand_path("session_retention", __dir__)
70
70
  # session_continue requires this file.
71
71
  autoload :SessionContinue, File.expand_path("session_continue", __dir__)
72
+ # Needed only on an archive or a delete (it loads Web::Grants).
73
+ autoload :Participants, File.expand_path("participants", __dir__)
72
74
 
73
75
  class SessionManager
74
76
  # Raised when the interactive TUI owns the session: it runs its own Engine
@@ -384,6 +386,8 @@ module Samagotchi
384
386
  stopped = tree.select { |sid| owners[sid] }
385
387
  stopped.each { |sid| stop_session(sid, state_dir: sd, wait: wait) }
386
388
  archived, discarded = tree.partition { |sid| ArchiveStore.archive(sid, state_dir: sd) }
389
+ # D8: an archive ends the review (unarchiving doesn't restore it).
390
+ Participants.end_sessions(archived, by: "archive", state_dir: sd)
387
391
  { id: id, archived: archived, stopped: stopped, discarded: discarded }
388
392
  end
389
393
 
@@ -706,6 +710,10 @@ module Samagotchi
706
710
  end
707
711
 
708
712
  STOP_CANCEL_WAIT = 3.0
713
+ # How long a stop from the CLI (chi sessions stop, delete, a continue's
714
+ # orphan) waits for the worker to let go of the session, here or
715
+ # through chi web (POST …/stop's wait, capped at it).
716
+ STOP_WAIT = 10
709
717
  # The cancel reason of a turn a session stop cancels (Bridge::CANCEL_REASONS):
710
718
  # not the user's Stop, which only ends the turn ("user").
711
719
  STOP_CANCEL_REASON = "session_stopped"
@@ -869,7 +877,7 @@ module Samagotchi
869
877
  # @raise [OwnedByTUI] a chi REPL owns it (never stopped from here)
870
878
  # @raise [DeleteRefused] a worker owns it and +stop+ is false, or it
871
879
  # outlived the wait
872
- def self.delete_session(id_or_prefix, state_dir: nil, stop: false, wait: 10)
880
+ def self.delete_session(id_or_prefix, state_dir: nil, stop: false, wait: STOP_WAIT)
873
881
  sd = state_dir || Session.default_state_dir
874
882
  given = id_or_prefix.to_s
875
883
  # Only a plain id: anything else could name a path outside the state dir.
@@ -897,6 +905,7 @@ module Samagotchi
897
905
  end
898
906
  PluginSessionState.remove(id)
899
907
  removed.concat(ContextSources.remove_session(id, state_dir: sd))
908
+ Participants.end_sessions([id], by: "delete", state_dir: sd)
900
909
  { id: id, removed: removed, stopped: !owner.nil? }
901
910
  end
902
911
 
@@ -960,7 +969,8 @@ module Samagotchi
960
969
  # loop run in-process (specs) traps nothing.
961
970
  signals = spawned ? WorkerSignals.new.install! : WorkerSignals::NONE
962
971
  worker = Worker.new(session_id: session_id, state_dir: sd, session_dir: session_dir,
963
- idle_exit_minutes: idle_exit_minutes, poll_interval: poll_interval, signals: signals)
972
+ idle_exit_minutes: idle_exit_minutes, poll_interval: poll_interval, signals: signals,
973
+ owner_lock: @owner_lock)
964
974
  result = begin
965
975
  worker.run
966
976
  rescue StandardError, ScriptError => e
@@ -1137,10 +1147,10 @@ module Samagotchi
1137
1147
  # session's images/
1138
1148
  # @return [String, false] the input file's path, or false.
1139
1149
  def self.write_turn_input(session_id, prompt:, client_id: nil, enqueued_id: nil, no_interrupt: false, state_dir: nil,
1140
- images: [], delivery: nil)
1150
+ images: [], delivery: nil, author: nil)
1141
1151
  session_dir = Session.session_dir(session_id, state_dir: state_dir || Session.default_state_dir)
1142
1152
  SessionInbox.write_input(session_dir, prompt: prompt, client_id: client_id, enqueued_id: enqueued_id,
1143
- no_interrupt: no_interrupt, images: images, delivery: delivery)
1153
+ no_interrupt: no_interrupt, images: images, delivery: delivery, author: author)
1144
1154
  end
1145
1155
 
1146
1156
  # How long a turn waits for the Bridge of a worker a resume just spawned.
@@ -1202,6 +1212,10 @@ module Samagotchi
1202
1212
  # @param no_interrupt [Boolean] the turn runs with the raised iteration
1203
1213
  # limit (an attached TUI's --no-interrupt), through the Bridge or the
1204
1214
  # file; sent only when true, so older callees see no new field
1215
+ # @param author [String, nil] who wrote it when it isn't the session's
1216
+ # owner (Web::Principal#author: chi web stamps it, never the body);
1217
+ # to the Bridge or the file only when given, so an owner's turn is
1218
+ # sent and saved as before
1205
1219
  # @param manager [#resume_session, #write_turn_input] this class, or a
1206
1220
  # stand-in (the web's specs); #session_owner is optional
1207
1221
  # @param bridge [#call, nil] returns the BridgeClient or nil; defaults to
@@ -1215,7 +1229,7 @@ module Samagotchi
1215
1229
  # @raise [OwnedByTUI] a chi REPL owns the session
1216
1230
  # @raise [ArgumentError] no such session
1217
1231
  def self.deliver_turn(session_id, prompt:, client_id: nil, images: [], delivery: nil, no_interrupt: false,
1218
- state_dir: nil, manager: self, bridge: nil)
1232
+ author: nil, state_dir: nil, manager: self, bridge: nil)
1219
1233
  session_dir = Session.session_dir(session_id, state_dir: state_dir || Session.default_state_dir)
1220
1234
  bridge ||= -> { BridgeClient.wait_for(session_id, session_dir: session_dir, timeout: TURN_BRIDGE_WAIT) }
1221
1235
  manager.resume_session(session_id, state_dir: state_dir)
@@ -1230,6 +1244,7 @@ module Samagotchi
1230
1244
  # An old worker ignores the field: it cuts anyway (its own default).
1231
1245
  options[:delivery] = delivery unless Delivery.next_step?(delivery)
1232
1246
  options[:no_interrupt] = true if no_interrupt
1247
+ options[:author] = author if author
1233
1248
  reply = client.post_turn(**options)
1234
1249
  ack = reply.json
1235
1250
  return DeliverResult.new(status: :accepted, via: :bridge, ack: ack) if reply.status == 202 && ack.is_a?(Hash)
@@ -1257,6 +1272,7 @@ module Samagotchi
1257
1272
  input[:images] = images unless images.empty?
1258
1273
  input[:delivery] = delivery unless Delivery.next_step?(delivery)
1259
1274
  input[:no_interrupt] = true if no_interrupt
1275
+ input[:author] = author if author
1260
1276
  path = manager.write_turn_input(session_id, **input)
1261
1277
  return DeliverResult.new(status: :failed) unless path
1262
1278
 
@@ -17,6 +17,7 @@ require_relative "session_metrics/cost"
17
17
  require_relative "session_metrics/usage"
18
18
  require_relative "session_metrics/side_totals"
19
19
  require_relative "side_usage"
20
+ require_relative "steer"
20
21
 
21
22
  module Samagotchi
22
23
  # SessionMetrics is a persistent, error-isolated collector of per-session
@@ -174,6 +175,9 @@ module Samagotchi
174
175
  # The turn the user started by answering the step-limit question with
175
176
  # Continue (:turn_started's +resumed+): it resumed the run before it.
176
177
  :resumed,
178
+ # Who wrote the turn when it wasn't the owner (:turn_started's
179
+ # origin author, "p:<id>"); its record carries it, the owner's none.
180
+ :author,
177
181
  :id,
178
182
  :started_at,
179
183
  :started_monotonic,
@@ -695,6 +699,7 @@ module Samagotchi
695
699
  gen_switch: false,
696
700
  continue: event[:continue] ? true : false,
697
701
  resumed: event[:resumed] ? true : false,
702
+ author: Steer.sender_author(event[:origin].is_a?(Hash) ? event[:origin][:author] : nil),
698
703
  id: event[:turn_id] || SecureRandom.uuid,
699
704
  started_at: now.iso8601(3),
700
705
  started_monotonic: monotonic_time,
@@ -735,6 +740,7 @@ module Samagotchi
735
740
  # from one written before the mark.
736
741
  record[:continue] = @turn.continue
737
742
  record[:resumed] = @turn.resumed
743
+ record[:author] = @turn.author if @turn.author
738
744
  record.merge!(turn_token_fields(@turn))
739
745
  @turn_records << record
740
746
  count_turn(record)
@@ -751,7 +757,7 @@ module Samagotchi
751
757
  {
752
758
  id: @turn.id, status: "canceled", started_at: @turn.started_at, completed_at: now.iso8601(3),
753
759
  duration_ms: elapsed_ms(@turn.started_monotonic), cancellation_reason: "worker_stopped",
754
- continue: @turn.continue, resumed: @turn.resumed
760
+ continue: @turn.continue, resumed: @turn.resumed, **{ author: @turn.author }.compact
755
761
  }.merge(turn_token_fields(@turn))
756
762
  end
757
763
 
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ # Loaded only when a session is read: it brings Web::Grants (and Rack).
5
+ autoload :Participants, File.expand_path("participants", __dir__)
6
+
7
+ # What a session's Engine knows of its participants: participants.json
8
+ # (Participants.read), read at each turn's start (#refresh!), never
9
+ # written here. A session nobody was invited to has no sidecar: one
10
+ # File.exist? per turn. See docs/internals/participants.md (Attribution).
11
+ class SessionParticipants
12
+ # @param state_dir [#call] → the sessions' state dir
13
+ # @param clock [#call] → Time, what "still active" is measured at
14
+ def initialize(state_dir:, clock: -> { Time.now })
15
+ @state_dir = state_dir
16
+ @clock = clock
17
+ @session_id = nil
18
+ @entries = []
19
+ end
20
+
21
+ # Reads +session+'s sidecar again.
22
+ def refresh!(session)
23
+ @session_id = session&.id
24
+ @entries = read
25
+ self
26
+ end
27
+
28
+ # Whether anyone is still in the review (Participants::Entry#active?),
29
+ # as of the last #refresh!.
30
+ def active? = @entries.any? { |entry| entry.active?(@clock.call) }
31
+
32
+ # The name +author+ ("p:<id>") had when it was invited, nil for none
33
+ # (the owner, or a participant the sidecar doesn't name). A miss reads
34
+ # the sidecar again: someone invited after the turn began. +session+:
35
+ # the session it is asked for, when that may not be the last refreshed
36
+ # (the worker's running save, before the Engine's first turn).
37
+ def name_for(author, session: nil)
38
+ return nil unless Steer.participant_author?(author)
39
+
40
+ refresh!(session) if session && session.id != @session_id
41
+ id = author.to_s.delete_prefix("p:")
42
+ entry = @entries.find { |e| e.id == id } || (@entries = read).find { |e| e.id == id }
43
+ entry&.name
44
+ end
45
+
46
+ private
47
+
48
+ def read
49
+ return [] if @session_id.to_s.empty?
50
+
51
+ dir = Session.session_dir(@session_id, state_dir: @state_dir.call)
52
+ return [] unless File.exist?(File.join(dir, "participants.json"))
53
+
54
+ Participants.read(@session_id, state_dir: @state_dir.call)
55
+ rescue StandardError
56
+ []
57
+ end
58
+ end
59
+ end
@@ -176,29 +176,32 @@ module Samagotchi
176
176
  # project's. Machine-local (this cwd's project): remotely every
177
177
  # project's (D4), and --scope=project is --cwd's project there (the
178
178
  # server finds its root). The rows are the server's (list_summaries).
179
+ # The picker (chi note from a script): filters apply before the limit,
180
+ # and test runs stay out, unless this is one (SAMAGOTCHI_ENV=test, CI):
181
+ # then its own sessions are what it looks for. The plain listing shows
182
+ # them, marked [test], here and remotely.
183
+ picker = @opts[:live] || cwd || (format && format != "text")
184
+ include_tests = !picker || Samagotchi::Session.test_session_env?
179
185
  if @access.remote?
180
186
  return usage_error("--scope=project on a remote needs --cwd: a folder there") if scope == "project" && !cwd
181
187
 
182
188
  project = scope == "project" ? cwd : nil
183
189
  scope_note = ->(count) { "#{count} session(s) in #{File.basename(project)} on #{@access.name} (--scope=all: every project)" }
184
- return list_summaries(project, scope_note)
190
+ return list_summaries(project, scope_note, include_tests: include_tests)
185
191
  end
186
192
  project = scope == "all" || cwd ? nil : Samagotchi::ProjectScope.root_for(Dir.pwd)
187
193
  scope_note = ->(count) { "#{count} session(s) in #{File.basename(project)} (--scope=all: every project)" }
188
194
 
189
- # The picker (chi note from a script): filters apply before the limit,
190
- # and test runs stay out, unless this is one (SAMAGOTCHI_ENV=test, CI):
191
- # then its own sessions are what it looks for.
192
- return list_summaries(project, scope_note) if @opts[:live] || cwd || (format && format != "text")
195
+ return list_summaries(project, scope_note, include_tests: include_tests) if picker
193
196
 
194
197
  list_plain(project, scope_note)
195
198
  end
196
199
 
197
- def list_summaries(project, scope_note)
200
+ def list_summaries(project, scope_note, include_tests:)
198
201
  live, cwd, limit, include_archived, format, sort, order =
199
202
  @opts.values_at(:live, :cwd, :limit, :include_archived, :format, :sort, :order)
200
203
  summaries = @access.summaries(
201
- live: live, cwd: cwd, limit: limit || (live ? 10 : nil), include_tests: Samagotchi::Session.test_session_env?,
204
+ live: live, cwd: cwd, limit: limit || (live ? 10 : nil), include_tests: include_tests,
202
205
  project_root: project,
203
206
  include_archived: include_archived, sort: sort, order: order
204
207
  )
@@ -389,7 +392,7 @@ module Samagotchi
389
392
  ok = ids.uniq.map do |given|
390
393
  begin
391
394
  id = @access.resolve_id(given)
392
- released = @access.stop(id, wait: 10)
395
+ released = @access.stop(id, wait: Samagotchi::SessionManager::STOP_WAIT)
393
396
  rescue Samagotchi::SessionManager::OwnedByTUI => e
394
397
  @stdout.flush
395
398
  @stderr.puts e.message
@@ -399,13 +402,13 @@ module Samagotchi
399
402
  @stderr.puts e.message
400
403
  next false
401
404
  end
402
- # Remotely no chi --resume (remote attach is its own phase).
405
+ # chi --resume ID, or chi --remote NAME --resume ID.
403
406
  resume = @access.invocation.resume(id)
404
407
  if released
405
- @stdout.puts "Stopped session #{id}.#{" #{resume} starts a fresh worker." if resume}"
408
+ @stdout.puts "Stopped session #{id}. #{resume} starts a fresh worker."
406
409
  else
407
410
  @stdout.flush
408
- @stderr.puts "Stopped session #{id}, but its worker is still shutting down#{"; try #{resume} in a moment" if resume}."
411
+ @stderr.puts "Stopped session #{id}, but its worker is still shutting down; try #{resume} in a moment."
409
412
  end
410
413
  true
411
414
  end
@@ -0,0 +1,174 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "time"
5
+ require_relative "cli/command"
6
+ require_relative "cli/exit"
7
+ require_relative "cli/flags"
8
+ require_relative "session"
9
+ require_relative "session_access"
10
+ require_relative "session_access/local"
11
+ require_relative "web/grants"
12
+ require_relative "web/qr"
13
+
14
+ module Samagotchi
15
+ # `chi share`: invite a participant to review a session (the
16
+ # shared-sessions plan: a Web::Grants grant, one session, one person or
17
+ # agent, 7 days unless --expires says otherwise), list the participants,
18
+ # revoke them. Local or through chi --remote NAME (SessionAccess). The
19
+ # token is printed once, in the link; chi keeps only its digest. See
20
+ # docs/internals/participants.md.
21
+ class ShareCommand
22
+ include CLI::Command
23
+
24
+ USAGE = <<~TEXT
25
+ Usage: chi share ID --name NAME [--agent] [--expires 3d]
26
+ chi share ls [ID] [--format text|json]
27
+ chi share revoke ID [PARTICIPANT] | --all
28
+ ID --name NAME invite NAME to review session ID: prints the link they open (chi web on
29
+ the LAN, web.host: lan) and its QR code at a terminal. The link is shown
30
+ once; chi keeps only a digest of its token.
31
+ --agent the participant is an agent (the same link, used as a Bearer token)
32
+ --expires N Nh or Nd, 1h to 30d (default 7d)
33
+ ls [ID] the participants of ID, or of every session (never a token)
34
+ revoke ID [P] end participant P's link, or every link to ID; --all every session's
35
+ Archiving or deleting a session revokes its links. A participant sees the session's title
36
+ and status; comments come in a later chi.
37
+ TEXT
38
+
39
+ SUBCOMMANDS = %w[ls revoke].freeze
40
+
41
+ # The invite's flags (chi share ID …), and ls's and revoke's.
42
+ FLAGS = CLI::Flags.new(help: HELP_WORDS) do |f|
43
+ f.value "--name"
44
+ f.switch "--agent"
45
+ f.value "--expires"
46
+ end
47
+ SUB_FLAGS = {
48
+ "ls" => CLI::Flags.new(help: HELP_WORDS) { |f| f.value "--format" },
49
+ "revoke" => CLI::Flags.new(help: HELP_WORDS) { |f| f.switch "--all" }
50
+ }.freeze
51
+
52
+ EXPIRES_RE = /\A(\d+)([hd])\z/
53
+ TIME_FORMAT = "%m-%d %H:%M"
54
+
55
+ # @param access [SessionAccess::Local, SessionAccess::Remote, nil]
56
+ # where the sessions are; default this machine's, in +state_dir+
57
+ # @param tty [Boolean, nil] print the QR code (default: stdout is a TTY)
58
+ def initialize(argv, stdin: $stdin, stdout: $stdout, stderr: $stderr, state_dir: nil, access: nil, tty: nil)
59
+ @argv = argv.dup
60
+ @stdin = stdin
61
+ @stdout = stdout
62
+ @stderr = stderr
63
+ @access = access || SessionAccess::Local.new(state_dir: state_dir)
64
+ @tty = tty.nil? ? @stdout.respond_to?(:tty?) && @stdout.tty? : tty
65
+ end
66
+
67
+ # @return [Integer] exit status: 0 done, 1 refused or failed, 2 usage
68
+ def run
69
+ first = @argv[0]
70
+ if first.nil? || HELP_WORDS.include?(first)
71
+ @stdout.puts(USAGE)
72
+ return 0
73
+ end
74
+
75
+ @sub = SUBCOMMANDS.include?(first) ? first : nil
76
+ parsed = parse_flags(@sub ? SUB_FLAGS.fetch(@sub) : FLAGS, @sub ? @argv[1..] : @argv)
77
+ return parsed if parsed.is_a?(Integer)
78
+
79
+ send(:"run_#{@sub || "invite"}", parsed.options, parsed.args)
80
+ rescue SessionAccess::BadRequest => e
81
+ usage_error(e.message)
82
+ rescue ArgumentError => e
83
+ error_line("#{command_name}: #{e.is_a?(Session::AmbiguousId) ? e.message : e.message.sub(/\ASession not found: /, "no session ")}")
84
+ CLI::Exit::FAILED
85
+ end
86
+
87
+ private
88
+
89
+ def command_name = @sub ? "chi share #{@sub}" : "chi share"
90
+ def usage_on_error = nil
91
+
92
+ def run_invite(options, args)
93
+ return usage_error("give one session ID") unless args.size == 1
94
+ return usage_error("--name NAME: who the link is for") if options[:name].to_s.strip.empty?
95
+
96
+ ttl = expires(options[:expires])
97
+ return usage_error("--expires takes Nh or Nd, 1h to 30d (e.g. 3d)") if ttl == false
98
+
99
+ id = @access.resolve(args.first)
100
+ invitation = @access.invite(id, name: options[:name], kind: options[:agent] ? "agent" : "human", ttl: ttl)
101
+ print_invitation(id, invitation)
102
+ 0
103
+ end
104
+
105
+ def run_ls(options, args)
106
+ return usage_error("ls takes at most one session ID") if args.size > 1
107
+
108
+ format = options[:format]
109
+ return usage_error("unknown format #{format.inspect}: use --format text|json") unless [nil, "text", "json"].include?(format)
110
+
111
+ id = args.first && @access.resolve(args.first)
112
+ rows = @access.participants(id)
113
+ if format == "json"
114
+ @stdout.puts(JSON.generate(rows.map(&:to_h)))
115
+ elsif rows.empty?
116
+ @stdout.puts(id ? "no participants in #{id[0, 8]} (chi share #{id[0, 8]} --name NAME invites one)" : "no participants")
117
+ else
118
+ print_table(rows)
119
+ end
120
+ 0
121
+ end
122
+
123
+ def run_revoke(options, args)
124
+ if options[:all]
125
+ return usage_error("--all takes no session ID") unless args.empty?
126
+ elsif !args.size.between?(1, 2)
127
+ return usage_error("give a session ID (and a participant), or --all")
128
+ end
129
+ participant = args[1]
130
+ return usage_error("not a participant: #{participant} (chi share ls lists them)") if participant && !Web::Grants::ID_RE.match?(participant)
131
+
132
+ id = args.first && @access.resolve(args.first)
133
+ revoked = @access.revoke_participants(id, participant: participant)
134
+ revoked.each { |row| @stdout.puts("#{row.id} #{row.name} revoked") }
135
+ @stdout.puts("nothing to revoke") if revoked.empty?
136
+ 0
137
+ end
138
+
139
+ # Seconds, nil for the default, false for words that aren't Nh/Nd.
140
+ def expires(words)
141
+ return nil if words.nil?
142
+
143
+ match = EXPIRES_RE.match(words.to_s.strip) or return false
144
+ seconds = match[1].to_i * (match[2] == "h" ? 3600 : Web::Grants::DAY)
145
+ seconds.between?(3600, Web::Grants::MAX_TTL) ? seconds : false
146
+ end
147
+
148
+ def print_invitation(id, invitation)
149
+ row = invitation.participant
150
+ @stdout.puts("#{row.id} #{row.name} (#{row.kind}, #{row.role}) · session #{id[0, 8]} · expires #{local(row.expires_at, "%Y-%m-%d %H:%M")}")
151
+ @stdout.puts(invitation.link)
152
+ @stdout.puts("(shown once: chi keeps only its digest; chi share revoke #{id[0, 8]} #{row.id} ends it)")
153
+ unless invitation.lan
154
+ @stdout.puts("chi web isn't on the LAN there (web.host: lan): this link opens on that machine only")
155
+ end
156
+ @stdout.puts(Web::QR.lines(invitation.link)) if @tty
157
+ end
158
+
159
+ COLUMNS = ["SESSION", "PARTICIPANT", "NAME", "KIND", "ROLE", "CREATED", "EXPIRES", "LAST USED", "STATE"].freeze
160
+
161
+ def print_table(rows)
162
+ cells = rows.map do |row|
163
+ [row.session_id[0, 8], row.id, row.name, row.kind, row.role, local(row.created_at), local(row.expires_at),
164
+ row.last_used_at ? local(row.last_used_at) : "-", row.state]
165
+ end
166
+ widths = COLUMNS.each_index.map { |i| ([COLUMNS[i]] + cells.map { |c| c[i] }).map(&:length).max }
167
+ [COLUMNS, *cells].each do |line|
168
+ @stdout.puts(line.each_with_index.map { |cell, i| i == line.size - 1 ? cell : cell.ljust(widths[i]) }.join(" "))
169
+ end
170
+ end
171
+
172
+ def local(stamp, format = TIME_FORMAT) = Time.iso8601(stamp.to_s).localtime.strftime(format)
173
+ end
174
+ end