samagotchi 0.103.0 → 0.104.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 (64) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +54 -1
  3. data/bin/chi +39 -2
  4. data/docs/architecture.md +36 -24
  5. data/docs/cli.md +86 -12
  6. data/docs/guardrails.md +2 -1
  7. data/docs/internals/context-telemetry.md +6 -2
  8. data/docs/internals/engine.md +4 -1
  9. data/docs/internals/participants.md +137 -0
  10. data/docs/internals/remote-sessions.md +153 -27
  11. data/docs/plugins.md +7 -1
  12. data/docs/sessions.md +4 -2
  13. data/docs/sub-agent.md +8 -0
  14. data/docs/testing.md +68 -4
  15. data/lib/samagotchi/bridge.rb +7 -3
  16. data/lib/samagotchi/bridge_client/event_stream.rb +9 -4
  17. data/lib/samagotchi/bridge_client.rb +4 -1
  18. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  19. data/lib/samagotchi/chi_invocation.rb +13 -16
  20. data/lib/samagotchi/context_status.rb +15 -2
  21. data/lib/samagotchi/engine.rb +16 -6
  22. data/lib/samagotchi/guardrail_wiring.rb +7 -1
  23. data/lib/samagotchi/idle_recap.rb +1 -1
  24. data/lib/samagotchi/idle_scheduler.rb +34 -9
  25. data/lib/samagotchi/kernel_loop.rb +8 -1
  26. data/lib/samagotchi/launch_mode.rb +4 -0
  27. data/lib/samagotchi/llm/chat_loop.rb +8 -1
  28. data/lib/samagotchi/owner_lock.rb +65 -5
  29. data/lib/samagotchi/participants.rb +122 -0
  30. data/lib/samagotchi/remote_attach_command.rb +119 -0
  31. data/lib/samagotchi/run_options.rb +14 -2
  32. data/lib/samagotchi/send_command.rb +1 -1
  33. data/lib/samagotchi/session.rb +8 -4
  34. data/lib/samagotchi/session_access/contract.rb +42 -4
  35. data/lib/samagotchi/session_access/dispatch.rb +29 -10
  36. data/lib/samagotchi/session_access/local.rb +52 -1
  37. data/lib/samagotchi/session_access/remote.rb +112 -16
  38. data/lib/samagotchi/session_access/web_bridge_client.rb +56 -1
  39. data/lib/samagotchi/session_access.rb +85 -3
  40. data/lib/samagotchi/session_continue.rb +1 -1
  41. data/lib/samagotchi/session_inbox.rb +18 -9
  42. data/lib/samagotchi/session_manager.rb +21 -5
  43. data/lib/samagotchi/sessions_command.rb +4 -4
  44. data/lib/samagotchi/share_command.rb +174 -0
  45. data/lib/samagotchi/terminal_ui/attach_launcher.rb +17 -4
  46. data/lib/samagotchi/terminal_ui/attach_link.rb +10 -0
  47. data/lib/samagotchi/terminal_ui/attached_loop.rb +83 -20
  48. data/lib/samagotchi/terminal_ui/formatting.rb +13 -1
  49. data/lib/samagotchi/terminal_ui/local_link.rb +5 -0
  50. data/lib/samagotchi/terminal_ui/remote_connector.rb +66 -0
  51. data/lib/samagotchi/terminal_ui/remote_link.rb +245 -0
  52. data/lib/samagotchi/terminal_ui/version_lines.rb +23 -0
  53. data/lib/samagotchi/terminal_ui.rb +4 -1
  54. data/lib/samagotchi/testing/waiting.rb +92 -9
  55. data/lib/samagotchi/tools/task_runtime.rb +17 -3
  56. data/lib/samagotchi/version.rb +1 -1
  57. data/lib/samagotchi/web/app.rb +187 -74
  58. data/lib/samagotchi/web/grants.rb +302 -0
  59. data/lib/samagotchi/web/participant_access.rb +286 -0
  60. data/lib/samagotchi/web/principal.rb +29 -0
  61. data/lib/samagotchi/web/server.rb +3 -1
  62. data/lib/samagotchi/web/session_access_routes.rb +120 -4
  63. data/lib/samagotchi/worker.rb +26 -5
  64. metadata +10 -1
@@ -36,6 +36,9 @@ module Samagotchi
36
36
  # No byte for this long on the stream (the Bridge pings every 15 s): a
37
37
  # half-open connection (Wi-Fi gone, the laptop asleep), reconnected.
38
38
  STREAM_IDLE_TIMEOUT = 40.0
39
+ # A read a join waits on (a turn's saved records, the children
40
+ # counts): a slow chi web can't hold the join up for long (L9).
41
+ JOIN_READ_TIMEOUT = 5
39
42
  # chi web answers no live worker for the session (Mapping).
40
43
  NO_WORKER = "no live worker for session %s on %s"
41
44
 
@@ -44,7 +47,7 @@ module Samagotchi
44
47
  module_function
45
48
 
46
49
  # @param route [Symbol] :turn, :command, :answer, :dismiss,
47
- # :dequeue, :cancel
50
+ # :dequeue, :cancel, :exit, :recap, :stats, :children, :records
48
51
  # @param reply [HTTPTransport::Reply]
49
52
  # @param session_id [String]
50
53
  # @param remote [String] the remote's name
@@ -133,6 +136,51 @@ module Samagotchi
133
136
  post(:cancel, "cancel", { reason: reason })
134
137
  end
135
138
 
139
+ # POST …/exit: the worker asked to exit (chi web passes client_id on
140
+ # only when it is an attached TUI's).
141
+ # @return [BridgeClient::Response] 200 {status: exiting, discard}, 409
142
+ # held {reason, tasks}, 408 late, 404 an older worker's
143
+ def request_exit(client_id:, delete: false)
144
+ body = { client_id: client_id }
145
+ body[:delete] = true if delete
146
+ post(:exit, "exit", body)
147
+ end
148
+
149
+ # POST …/recap: the saved recap, and a new one asked for.
150
+ # @return [BridgeClient::Response] 200 {enabled, saved, request, min_user_turns, session_off}
151
+ def request_recap = post(:recap, "recap", {})
152
+
153
+ # GET …/stats: the worker's metrics (its state's, for an older
154
+ # worker), nil when it didn't give them.
155
+ # @return [Hash, nil]
156
+ # @raise [Unreachable]
157
+ def stats
158
+ response = get(:stats, "stats", read_timeout: READ_TIMEOUT)
159
+ response.status == 200 ? response.json&.dig("metrics") : nil
160
+ end
161
+
162
+ # GET …/children/counts: the session's delegates' counts there.
163
+ # @return [Hash] {running, waiting, unreported}
164
+ # @raise [Unreachable] chi web couldn't answer (or the session is gone)
165
+ def children_counts
166
+ response = get(:children, "children/counts", read_timeout: JOIN_READ_TIMEOUT)
167
+ raise Unreachable, "children counts: chi web answered #{response.status}" unless response.status == 200
168
+
169
+ response.json
170
+ end
171
+
172
+ # GET …/turns/:turn_id/records: a turn's saved tool records and turn
173
+ # record there.
174
+ # @return [Hash] {tool_records: [...], turn_record: {...} | nil}
175
+ # @raise [Unreachable]
176
+ def turn_records(turn_id)
177
+ response = get(:records, "turns/#{URI.encode_www_form_component(turn_id.to_s)}/records",
178
+ read_timeout: JOIN_READ_TIMEOUT)
179
+ raise Unreachable, "turn records: chi web answered #{response.status}" unless response.status == 200
180
+
181
+ response.json
182
+ end
183
+
136
184
  # Follow the session's events through chi web (see EventStream): with
137
185
  # epoch_watch, announce_retries and STREAM_IDLE_TIMEOUT.
138
186
  # @return [BridgeClient::EventStream] started
@@ -172,6 +220,13 @@ module Samagotchi
172
220
  raise Unreachable, e.message
173
221
  end
174
222
 
223
+ def get(route, path, read_timeout:)
224
+ reply = @remote.route_request(:get, "/api/sessions/#{@session_id}/#{path}", read_timeout: read_timeout)
225
+ Mapping.response(route, reply, session_id: @session_id, remote: @remote.name)
226
+ rescue RemoteFailed => e
227
+ raise Unreachable, e.message
228
+ end
229
+
175
230
  def refused(reason)
176
231
  Log.warn(:attached, "stream_refused", reason: reason, remote: @remote.name)
177
232
  nil
@@ -28,6 +28,47 @@ module Samagotchi
28
28
  # --remote: update chi there (chi update)").
29
29
  class RemoteTooOld < RemoteFailed; end
30
30
 
31
+ # A remote's chi web refused this machine's token (401), or answers its
32
+ # LAN IP only (403 forbidden): Remote.access_error's words.
33
+ class AuthRefused < RemoteFailed; end
34
+
35
+ # The errors a participant's token meets (docs/internals/participants.md),
36
+ # one message each, built from the remote's label ("remote studio
37
+ # (http://…)"). chi web's code for each is its CODE.
38
+
39
+ # 401 token_revoked: the owner revoked the grant, or archived or
40
+ # deleted its session.
41
+ class TokenRevoked < AuthRefused
42
+ CODE = "token_revoked"
43
+ def initialize(label) = super("#{label}: this review link was revoked by the session's owner")
44
+ end
45
+
46
+ # 401 token_expired: the grant's time ran out.
47
+ class TokenExpired < AuthRefused
48
+ CODE = "token_expired"
49
+ def initialize(label) = super("#{label}: this review link expired: ask the session's owner for a new one")
50
+ end
51
+
52
+ # 403 not_permitted: a participant's token on a route only the session's
53
+ # owner may use.
54
+ class NotPermitted < RemoteFailed
55
+ CODE = "not_permitted"
56
+ def initialize(label) = super("#{label}: this token is a participant's: only the session's owner can do that")
57
+ end
58
+
59
+ # 429 too_many_turns (P3b): the participant's turns already wait in the
60
+ # queue up to their limit.
61
+ class TooManyTurns < RemoteFailed
62
+ CODE = "too_many_turns"
63
+ def initialize(label) = super("#{label}: this participant has too many turns waiting: wait for one to finish")
64
+ end
65
+
66
+ # 429 cap_reached (P3b): the participant's grant spent its cap.
67
+ class CapReached < RemoteFailed
68
+ CODE = "cap_reached"
69
+ def initialize(label) = super("#{label}: this participant's spending cap is reached: ask the session's owner to raise it")
70
+ end
71
+
31
72
  # A remote's chi web refused a request for a reason this machine checks
32
73
  # itself before sending (a parent's /continue yes, a loosening
33
74
  # /parent-answers, images to a model that can't see them): +message+
@@ -47,11 +88,30 @@ module Samagotchi
47
88
  # exits 2 with its message.
48
89
  class BadRequest < Refused; end
49
90
 
91
+ # One participant's grant as chi share ls shows it
92
+ # (Web::Grants::Grant#public_h): never its token or digest. +state+
93
+ # "active", "revoked" or "expired".
94
+ ParticipantView = Data.define(:id, :session_id, :kind, :name, :role, :created_at, :expires_at, :revoked_at,
95
+ :revoked_by, :last_used_at, :state) do
96
+ def self.from_h(hash) = new(**members.to_h { |key| [key, hash[key] || hash[key.to_s]] })
97
+ end
98
+
99
+ # What chi share made: the participant, their token and the link that
100
+ # carries it (both shown this once: chi keeps only a digest), and
101
+ # whether that chi web listens on the LAN (+lan+ false: the link works
102
+ # on that machine only).
103
+ Invitation = Data.define(:participant, :token, :link, :lan) do
104
+ def inspect = "#<Invitation #{participant.id}>"
105
+ alias_method :to_s, :inspect
106
+ end
107
+
50
108
  # A session to create (chi send --new): a new one, or with +continues+
51
109
  # the next link of that session's chain (which brings its own folder,
52
110
  # LLM context, thinking, toolkit and settings: the rest is nil then).
53
111
  # +preview+ set: the session starts idle with it as its preview, and
54
- # +prompt+ (nil) goes in later as a turn (images, a session command).
112
+ # +prompt+ (nil) goes in later as a turn (images, a session command);
113
+ # +idle+ alone: it starts idle with no preview (a remote attach's new
114
+ # session, whose -p goes in once attached).
55
115
  # +setup+ is a SessionSetup for Local, a SetupWords for Remote (the
56
116
  # flags as typed: the server checks them against its own config and
57
117
  # bundles). +client_id+ (chi send's, or chi answer's for a parent's
@@ -59,9 +119,9 @@ module Samagotchi
59
119
  # which checks the first line and the model as chi send checks them
60
120
  # here (Local checks before it creates).
61
121
  NewSession = Data.define(:prompt, :preview, :continues, :working_directory, :model_name, :setup, :client_id,
62
- :images) do
122
+ :images, :idle) do
63
123
  def initialize(prompt:, preview: nil, continues: nil, working_directory: nil, model_name: nil, setup: nil,
64
- client_id: nil, images: false) = super
124
+ client_id: nil, images: false, idle: false) = super
65
125
  end
66
126
 
67
127
  # chi send --new's setup flags as typed, for a Remote: +llm_context+
@@ -94,6 +154,10 @@ module Samagotchi
94
154
  # its OwnerLock::Owner (a worker, a chi REPL, nil).
95
155
  WorkerState = Data.define(:sidecar, :stopped, :owner) do
96
156
  def live? = !sidecar.nil?
157
+
158
+ # No Bridge, and the owner is a worker leaving (writing its recap on
159
+ # leave): a wake waits for it (Local#wake).
160
+ def leaving? = !live? && !!owner&.leaving?
97
161
  end
98
162
 
99
163
  # What a wake for an attach did (Local#wake, chi web's POST …/wake):
@@ -101,6 +165,24 @@ module Samagotchi
101
165
  # worker's (nil: its Bridge didn't come within the wait).
102
166
  Woken = Data.define(:woke, :sidecar)
103
167
 
168
+ # A remote attach's wake (Remote#attach_wake, chi web's POST …/wake):
169
+ # +worker+ the live worker as chi web names it ({"epoch", "started_at",
170
+ # "version", "features"}, string keys: Remote#attach_client's), and
171
+ # what the TUI reads of that machine: its config's +default_model+,
172
+ # the session's parent +strategy+ (Guardrails::ParentPolicy::Strategy),
173
+ # the newest chi +installed+ there.
174
+ WakeReply = Data.define(:woke, :worker, :default_model, :strategy, :installed)
175
+
176
+ # A remote session's worker now (Remote#attach_worker, chi web's GET
177
+ # …/worker): +live+, +stopped+ (its stop marker), +worker+ as in
178
+ # WakeReply (nil: none runs), the parent +strategy+ read now, and that
179
+ # machine's +default_model+ (nil from a chi web before it sent one).
180
+ # +leaving+: its worker is leaving (WorkerState#leaving?), for up to
181
+ # +leave_seconds+ more there (nil from a chi web before it sent them).
182
+ WorkerReply = Data.define(:live, :stopped, :worker, :strategy, :default_model, :leaving, :leave_seconds) do
183
+ def initialize(live:, stopped:, worker:, strategy:, default_model: nil, leaving: false, leave_seconds: nil) = super
184
+ end
185
+
104
186
  # What chi send --wait and chi answer read before anything is sent, so
105
187
  # neither an older reply nor a turn that ends before the first look is
106
188
  # taken for the answer (ReplyWait's cursor:, baseline:, turn:).
@@ -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
 
@@ -389,7 +389,7 @@ module Samagotchi
389
389
  ok = ids.uniq.map do |given|
390
390
  begin
391
391
  id = @access.resolve_id(given)
392
- released = @access.stop(id, wait: 10)
392
+ released = @access.stop(id, wait: Samagotchi::SessionManager::STOP_WAIT)
393
393
  rescue Samagotchi::SessionManager::OwnedByTUI => e
394
394
  @stdout.flush
395
395
  @stderr.puts e.message
@@ -399,13 +399,13 @@ module Samagotchi
399
399
  @stderr.puts e.message
400
400
  next false
401
401
  end
402
- # Remotely no chi --resume (remote attach is its own phase).
402
+ # chi --resume ID, or chi --remote NAME --resume ID.
403
403
  resume = @access.invocation.resume(id)
404
404
  if released
405
- @stdout.puts "Stopped session #{id}.#{" #{resume} starts a fresh worker." if resume}"
405
+ @stdout.puts "Stopped session #{id}. #{resume} starts a fresh worker."
406
406
  else
407
407
  @stdout.flush
408
- @stderr.puts "Stopped session #{id}, but its worker is still shutting down#{"; try #{resume} in a moment" if resume}."
408
+ @stderr.puts "Stopped session #{id}, but its worker is still shutting down; try #{resume} in a moment."
409
409
  end
410
410
  true
411
411
  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
@@ -107,13 +107,15 @@ module Samagotchi
107
107
  close_surface(surface)
108
108
  end
109
109
  if ended == :unanswered && attached.unanswered
110
- # A link's strategy is read where the session is, its commands
111
- # spelled its way.
110
+ # A link's strategy is read where the session is, now (L4), its
111
+ # commands spelled its way.
112
112
  where = connector ? { strategy: link.parent_strategy, via: link.via } : {}
113
113
  report_unanswered(attached.unanswered, session_id: client.session_id, state_dir: state_dir, **where)
114
114
  end
115
115
  report_ended(ended)
116
116
  ended
117
+ ensure
118
+ link&.close
117
119
  end
118
120
 
119
121
  # This TUI's client id: tui:PID, or the connector's own (a remote one
@@ -193,6 +195,7 @@ module Samagotchi
193
195
  resume = attach
194
196
  end
195
197
 
198
+ await_leaving_worker(resume, sd, err) if resume
196
199
  session = if resume
197
200
  SessionManager.resume_session(resume, state_dir: sd)
198
201
  else
@@ -206,12 +209,22 @@ module Samagotchi
206
209
  end
207
210
  client = BridgeClient.wait_for(session.id, session_dir: Session.session_dir(session.id, state_dir: sd), timeout: wait)
208
211
  client || raise(Error, format(LaunchMode::BRIDGE_LATE, session.id))
209
- rescue SessionManager::OwnedByTUI
210
- raise Error, "session #{resume} is open in a chi REPL; close it there first"
212
+ rescue SessionManager::OwnedByTUI => e
213
+ raise Error, e.message
211
214
  rescue ArgumentError => e
212
215
  raise Error, e.message
213
216
  end
214
217
 
218
+ # The session's previous worker may still be leaving: its Bridge
219
+ # closed, the lock held while it writes its recap on leave (a resume
220
+ # now would start nothing, the lock taken). Wait for it to let go
221
+ # (OwnerLock.await_release), with one line saying so; any other owner
222
+ # is left to resume_session as before.
223
+ def await_leaving_worker(session_id, state_dir, err)
224
+ OwnerLock.await_release(Session.session_dir(session_id, state_dir: state_dir),
225
+ on_wait: ->(_owner) { err.puts(LaunchMode::RECAP_WAIT) })
226
+ end
227
+
215
228
  # --model as typed: spawn_session stores the resolved ref and this name.
216
229
  def model_ref(model)
217
230
  ModelProfile.required_model_name(model)