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
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../config"
4
+
3
5
  module Samagotchi
4
6
  module SessionAccess
5
7
  # Which session commands and flags run against another machine's
@@ -7,13 +9,16 @@ module Samagotchi
7
9
  # and which run on this machine only (refused under a remote, exit 2:
8
10
  # "chi <cmd> runs on this machine only: run it on <remote>"). The v1 scope is the remote-sessions plan's
9
11
  # D3: send, answer, note, a session's context (add, push, ls, show, rm)
10
- # and sessions list/stop/restart/archive/unarchive/delete; the rest
12
+ # and sessions list/stop/restart/archive/unarchive/delete, and chi share
13
+ # (the shared-sessions plan's P2); the rest
11
14
  # stays local. bin/chi's subcommands that act on the machine, not on
12
- # sessions, are MACHINE. The run flags (--attach, -p, --resume) are not
13
- # subcommands and not in this table (remote attach is its own phase).
15
+ # sessions, are MACHINE. The run flags (chi --attach, -p, --resume: a
16
+ # session in this terminal, a remote attach there) are RUN's.
14
17
  #
15
18
  # spec/session_access_contract_spec.rb fails when a subcommand, one of
16
- # its subcommands or one of a supported one's flags is in neither.
19
+ # its subcommands or one of a supported one's flags is in neither, or
20
+ # a run flag (RunOptions.switches) is in neither RUN list and isn't a
21
+ # config registry flag.
17
22
  module Contract
18
23
  SUPPORTED = :supported
19
24
  LOCAL_ONLY = :local_only
@@ -111,11 +116,44 @@ module Samagotchi
111
116
  "sessions clean" => LOCAL_ONLY,
112
117
  "sessions stats" => LOCAL_ONLY,
113
118
 
119
+ # Participants (docs/internals/participants.md): the remote's link,
120
+ # through its chi web's participants routes.
121
+ "share" => SUPPORTED,
122
+ "share --name" => SUPPORTED,
123
+ "share --agent" => SUPPORTED,
124
+ "share --expires" => SUPPORTED,
125
+ "share ls" => SUPPORTED,
126
+ "share ls --format" => SUPPORTED,
127
+ "share revoke" => SUPPORTED,
128
+ "share revoke --all" => SUPPORTED,
129
+
114
130
  "broadcast" => LOCAL_ONLY
115
131
  }.freeze
116
132
 
133
+ # bin/chi's run flags under a remote (chi --remote NAME --attach ID,
134
+ # RemoteAttachCommand): what a remote attach takes, and what runs
135
+ # this machine's REPL or reads this machine's files (P4-D2..D4). A
136
+ # config registry flag (--recap-base-url …) is this machine's
137
+ # config: LOCAL_ONLY without being listed.
138
+ RUN = {
139
+ SUPPORTED => %w[--attach --resume -p --prompt --shared --model --thinking --llm-context --llm-context-apply
140
+ --llm-context-budget --toolkit --tool --no-tool --set --parent-answers --no-interrupt
141
+ --no-default-input --dir --version -h --help],
142
+ LOCAL_ONLY => %w[--no-shared --non-interactive -v --verbose --append-system-prompt --memory --mute --profile
143
+ --port --open --new-token --scope --web-markdown --no-web-markdown]
144
+ }.freeze
145
+ # A short run flag's name in a refusal.
146
+ RUN_WORDS = { "-v" => "--verbose" }.freeze
147
+
117
148
  module_function
118
149
 
150
+ # @param flag [String] a run flag as RunOptions.switches lists it
151
+ # @return [Symbol, nil] SUPPORTED or LOCAL_ONLY; nil: not a run flag
152
+ def run_status(flag)
153
+ RUN.each { |status, flags| return status if flags.include?(flag) }
154
+ LOCAL_ONLY if Config.cli_entries.any? { |entry| [entry.cli_flag, entry.cli_flag.sub("--", "--no-")].include?(flag) }
155
+ end
156
+
119
157
  # @param words [Array<String>] a command, maybe its subcommand, maybe
120
158
  # a flag's long name: status("context", "add", "--cmd")
121
159
  # @return [Symbol, nil] SUPPORTED, LOCAL_ONLY, or nil when not listed
@@ -21,10 +21,8 @@ module Samagotchi
21
21
  # bin/chi's own words besides its SUBCOMMANDS (a refusal names them).
22
22
  OWN_WORDS = %w[web scratch self].freeze
23
23
  HELP = %w[--version -h --help].freeze
24
- # The run flags (not subcommands) a refusal names: remote attach is
25
- # its own phase (P4).
26
- RUN_FLAGS = { "--attach" => "--attach", "--resume" => "--resume", "-p" => "-p", "--prompt" => "-p",
27
- "--shared" => "--shared", "--no-shared" => "--no-shared" }.freeze
24
+ # --non-interactive's refusal: what runs one turn there instead.
25
+ ONE_TURN = "(%s send --new --dir D -m TEXT --wait runs one turn there)"
28
26
 
29
27
  # A usage error in the flag itself (--remote with no name, a bad one).
30
28
  class Usage < StandardError; end
@@ -54,6 +52,14 @@ module Samagotchi
54
52
 
55
53
  def remote? = !@remote.nil?
56
54
 
55
+ # Whether the arguments are run flags, no subcommand: a session in
56
+ # this terminal (bin/chi's RunOptions parse them; under a remote,
57
+ # RemoteAttachCommand runs them).
58
+ def run_flags?
59
+ command = @argv.first
60
+ command.nil? || command.start_with?("-")
61
+ end
62
+
57
63
  # Whether the arguments run here as they would without a remote: chi
58
64
  # remote, --version, --help.
59
65
  def here?
@@ -67,7 +73,8 @@ module Samagotchi
67
73
  def refusal
68
74
  words = local_words or return nil
69
75
 
70
- "chi #{words} runs on this machine only: run it on #{@remote}"
76
+ line = "chi #{words} runs on this machine only: run it on #{@remote}"
77
+ words == "--non-interactive" ? "#{line} #{format(ONE_TURN, "chi --remote #{@remote}")}" : line
71
78
  end
72
79
 
73
80
  # The session command against the remote: refused (exit 2), or run
@@ -101,11 +108,11 @@ module Samagotchi
101
108
  private
102
109
 
103
110
  # The words that run here only: the command (and its subcommand, a
104
- # flag) the Contract lists local_only, a machine command, or the run
105
- # flags (a session in this terminal, attach). nil: supported.
111
+ # flag) the Contract lists local_only, a machine command, or a run
112
+ # flag Contract::RUN lists local_only. nil: supported.
106
113
  def local_words
107
114
  command = @argv.first
108
- return run_words if command.nil? || command.start_with?("-")
115
+ return run_words if run_flags?
109
116
  return command if Contract::MACHINE.include?(command) || Contract.status(command).nil?
110
117
  return command if Contract.status(command) == Contract::LOCAL_ONLY
111
118
 
@@ -124,9 +131,21 @@ module Samagotchi
124
131
  @argv.drop(1).take_while { |arg| arg != "--" }.select { |arg| arg.start_with?("--") }.map { |arg| arg.split("=", 2).first }
125
132
  end
126
133
 
134
+ # The first local-only run flag, a value (-p TEXT, --model NAME)
135
+ # never read as one. An unknown flag is the parser's to refuse.
127
136
  def run_words
128
- flag = @argv.take_while { |arg| arg != "--" }.map { |arg| arg.split("=", 2).first }.find { |arg| RUN_FLAGS.key?(arg) }
129
- flag ? RUN_FLAGS[flag] : "(a session in this terminal)"
137
+ require_relative "../run_options"
138
+ values = RunOptions.value_switches
139
+ words = @argv.take_while { |arg| arg != "--" }
140
+ index = 0
141
+ while index < words.size
142
+ arg = words[index]
143
+ flag = arg.start_with?("--") ? arg.split("=", 2).first : arg[0, 2]
144
+ return Contract::RUN_WORDS.fetch(flag, flag) if arg.start_with?("-") && Contract.run_status(flag) == Contract::LOCAL_ONLY
145
+
146
+ index += values.include?(arg) ? 2 : 1
147
+ end
148
+ nil
130
149
  end
131
150
  end
132
151
  end
@@ -4,6 +4,7 @@ require_relative "../session"
4
4
  require_relative "../session_access"
5
5
  require_relative "../reply_wait"
6
6
  require_relative "../bridge_client"
7
+ require_relative "../owner_lock"
7
8
  require_relative "../parent_report"
8
9
  require_relative "../note_delivery"
9
10
  require_relative "../image_store"
@@ -12,6 +13,10 @@ require_relative "../send_command/busy"
12
13
  require_relative "../chi_invocation"
13
14
  require_relative "../worker_sidecar"
14
15
  require_relative "../launch_mode"
16
+ require_relative "../participants"
17
+ require_relative "../config"
18
+ require_relative "../web/grants"
19
+ require_relative "../web/info_probe"
15
20
 
16
21
  module Samagotchi
17
22
  # Loaded on first use: SessionManager and its ~80 files only when a
@@ -69,7 +74,10 @@ module Samagotchi
69
74
 
70
75
  # The session's worker for an attach: the live one as is, else
71
76
  # resumed and waited for up to +wait+ seconds (chi --attach on this
72
- # machine; chi web's POST …/wake for a remote one).
77
+ # machine; chi web's POST …/wake for a remote one). A worker still
78
+ # leaving (its Bridge closed, its recap on leave being written) is
79
+ # waited for first (OwnerLock.await_release: its recap's timeout and
80
+ # a margin), so the resume finds the session free.
73
81
  # @return [Woken] its sidecar nil when the Bridge didn't come in time
74
82
  # @raise [ArgumentError] no such session
75
83
  # @raise [Refused] code scratch: a leftover chi scratch session
@@ -81,6 +89,7 @@ module Samagotchi
81
89
  live = WorkerSidecar.live(session_dir(id), unlink: true)
82
90
  return Woken.new(woke: false, sidecar: live) if live
83
91
 
92
+ OwnerLock.await_release(session_dir(id))
84
93
  SessionManager.resume_session(id, **dir)
85
94
  found = BridgeClient.wait_for(id, session_dir: session_dir(id), timeout: wait)
86
95
  Woken.new(woke: true, sidecar: found && WorkerSidecar.read(session_dir(id)))
@@ -322,8 +331,50 @@ module Samagotchi
322
331
  end
323
332
  end
324
333
 
334
+ # --- participants (chi share)
335
+
336
+ # Remote's: through this machine's grants store, as chi web would.
337
+ # @return [Array<ParticipantView>]
338
+ def participants(id = nil)
339
+ grants.list(session_id: id).map { |grant| ParticipantView.from_h(grant.public_h(grants.now)) }
340
+ end
341
+
342
+ # The link names this machine's chi web: its LAN address when one
343
+ # runs on the LAN (web.port), else 127.0.0.1 (+lan+ false).
344
+ # @return [Invitation]
345
+ # @raise [ArgumentError] no such session
346
+ # @raise [BadRequest] a bad name, kind or ttl, an archived session
347
+ def invite(id, name:, kind:, ttl: nil)
348
+ issued = Participants.invite(grants, session_id: id, name: name, kind: kind, ttl: ttl, state_dir: state_dir!)
349
+ ip, port = local_web
350
+ Invitation.new(participant: ParticipantView.from_h(issued.grant.public_h(grants.now)), token: issued.token,
351
+ link: "http://#{ip || "127.0.0.1"}:#{port}/?token=#{issued.token}#s=#{id}", lan: !ip.nil?)
352
+ rescue Web::Grants::Invalid => e
353
+ raise BadRequest.new(e.message, code: "invalid_participant")
354
+ end
355
+
356
+ # @return [Array<ParticipantView>] the ones it revoked
357
+ def revoke_participants(id = nil, participant: nil)
358
+ Participants.revoke(grants, session_id: id, id: participant, state_dir: state_dir!)
359
+ .map { |grant| ParticipantView.from_h(grant.public_h(grants.now)) }
360
+ end
361
+
325
362
  private
326
363
 
364
+ def grants = @grants ||= Web::Grants.new(Web::Grants.path(state_dir: state_dir!))
365
+
366
+ # Web::Server::DEFAULT_PORT, without loading chi web.
367
+ WEB_PORT = 4567
368
+ LOCAL_PROBE_TIMEOUT = 1.0
369
+
370
+ # [LAN ip or nil, port] of this machine's chi web (GET /api/info).
371
+ def local_web
372
+ port = Config.get("web.port").to_s.to_i
373
+ port = WEB_PORT unless port.positive?
374
+ info = Web::InfoProbe.call("127.0.0.1", port, timeout: LOCAL_PROBE_TIMEOUT)
375
+ [info.is_a?(Hash) ? info["lan"] : nil, port]
376
+ end
377
+
327
378
  def dir = @state_dir ? { state_dir: @state_dir } : {}
328
379
  def state_dir! = @state_dir || Session.default_state_dir
329
380
  def session_dir(id) = Session.session_dir(id, state_dir: state_dir!)
@@ -74,8 +74,8 @@ module Samagotchi
74
74
  # add checks the same).
75
75
  # @return [RemoteFailed, nil]
76
76
  def self.info_problem(entry, reply)
77
- failure = auth_failure(entry, reply)
78
- return RemoteFailed.new(failure) if failure
77
+ refused = access_error(entry, reply)
78
+ return refused if refused
79
79
  unless reply.status == 200 && reply.json.is_a?(Hash) && reply.json["app"] == "chi-web"
80
80
  return RemoteFailed.new("#{entry.label}: not chi web (GET /api/info answered #{reply.status})")
81
81
  end
@@ -91,20 +91,31 @@ module Samagotchi
91
91
  "update chi there (chi update)"
92
92
  end
93
93
 
94
- # The words for a 401 or a 403 forbidden from +entry+'s chi web, nil
95
- # for any other answer (chi remote add says the same).
96
- def self.auth_failure(entry, reply)
94
+ # The errors any route of +entry+'s chi web can answer, each its one
95
+ # message: the token refused (401 unauthorized) or ended (401
96
+ # token_revoked / token_expired), the LAN IP only (403 forbidden), a
97
+ # participant's token on an owner's route (403 not_permitted) or over
98
+ # its limits (429 too_many_turns / cap_reached). nil for any other
99
+ # answer (chi remote add says the same).
100
+ # @return [RemoteFailed, nil]
101
+ def self.access_error(entry, reply)
102
+ error = reply.error
103
+ known = ACCESS_ERRORS.find { |klass| error == klass::CODE }
104
+ return known.new(entry.label) if known
105
+
97
106
  case reply.status
98
- when 401 then "#{entry.label}: the token was refused (chi web's LAN link carries the current one: " \
99
- "chi remote add #{entry.name} #{entry.url} --token-file FILE)"
107
+ when 401 then AuthRefused.new("#{entry.label}: the token was refused (chi web's LAN link carries the current one: " \
108
+ "chi remote add #{entry.name} #{entry.url} --token-file FILE)")
100
109
  when 403
101
- return nil unless reply.error == "forbidden"
110
+ return nil unless error == "forbidden"
102
111
 
103
112
  ip = reply.json["detail"].to_s[/ and (\S+) are answered/, 1]
104
- "#{entry.label}: chi web answers its LAN IP only: use the LAN IP#{" #{ip}" if ip}, not a hostname"
113
+ AuthRefused.new("#{entry.label}: chi web answers its LAN IP only: use the LAN IP#{" #{ip}" if ip}, not a hostname")
105
114
  end
106
115
  end
107
116
 
117
+ ACCESS_ERRORS = [TokenRevoked, TokenExpired, NotPermitted, TooManyTurns, CapReached].freeze
118
+
108
119
  # --- ids and sessions
109
120
 
110
121
  # @raise [Session::AmbiguousId]
@@ -220,6 +231,40 @@ module Samagotchi
220
231
 
221
232
  # @return [NoteDelivery::Result]
222
233
  # @raise [SessionInbox::NoteRejected]
234
+ # --- participants (chi share)
235
+
236
+ # @param id [String, nil] one session's, or every session's
237
+ # @return [Array<ParticipantView>]
238
+ def participants(id = nil)
239
+ reply = request(:get, id ? "#{session_path(id)}/participants" : "/api/participants")
240
+ raise_refusal(reply) unless reply.status == 200
241
+
242
+ Array(reply.json["participants"]).map { |row| ParticipantView.from_h(row) }
243
+ end
244
+
245
+ # @param ttl [Integer, nil] seconds; nil: the grant's default
246
+ # @return [Invitation]
247
+ # @raise [BadRequest] a bad name, kind or ttl, an archived session
248
+ def invite(id, name:, kind:, ttl: nil)
249
+ reply = request(:post, "#{session_path(id)}/participants", body: { name: name, kind: kind, ttl: ttl }.compact)
250
+ raise_refusal(reply) unless reply.status == 201
251
+
252
+ json = reply.json
253
+ Invitation.new(participant: ParticipantView.from_h(json.fetch("participant")), token: json.fetch("token"),
254
+ link: json.fetch("link"), lan: json["lan"] == true)
255
+ end
256
+
257
+ # Revokes one participant (+participant+), a session's, or with no
258
+ # +id+ every session's.
259
+ # @return [Array<ParticipantView>] the ones it revoked
260
+ def revoke_participants(id = nil, participant: nil)
261
+ reply = request(:delete, id ? "#{session_path(id)}/participants" : "/api/participants",
262
+ query: { id: participant }.compact)
263
+ raise_refusal(reply) unless reply.status == 200
264
+
265
+ Array(reply.json["revoked"]).map { |row| ParticipantView.from_h(row) }
266
+ end
267
+
223
268
  def note(id, text:, source:)
224
269
  reply = request(:post, "#{session_path(id)}/notes", body: { text: text, source: source })
225
270
  json = reply.json.is_a?(Hash) ? reply.json : {}
@@ -317,13 +362,13 @@ module Samagotchi
317
362
 
318
363
  # --- sessions
319
364
 
320
- # chi web stops it and waits its own bounded time (Web::App::
321
- # STOP_WAIT_SECONDS, not +wait+).
365
+ # chi web stops it and waits up to +wait+ seconds (capped there at
366
+ # SessionManager::STOP_WAIT; a chi web before the field waits its
367
+ # Web::App::STOP_WAIT_SECONDS).
322
368
  # @return [Boolean] whether the worker had released it by then, as
323
369
  # chi web's released says (absent: true)
324
370
  def stop(id, wait:)
325
- _ = wait
326
- reply = request(:post, "#{session_path(id)}/stop")
371
+ reply = request(:post, "#{session_path(id)}/stop", body: { wait: wait })
327
372
  raise_refusal(reply) unless reply.status == 200
328
373
 
329
374
  reply.json["released"] != false
@@ -454,6 +499,50 @@ module Samagotchi
454
499
  WebBridgeClient.new(remote: self, session_id: id, worker_key: worker["epoch"] || worker["started_at"])
455
500
  end
456
501
 
502
+ # This chi web has +feature+ (GET /api/info's), else RemoteTooOld in
503
+ # Remote.too_old_message's words.
504
+ # @raise [RemoteFailed]
505
+ def require_feature!(feature)
506
+ check_server!
507
+ raise RemoteTooOld, too_old(@version) unless Array(@features).include?(feature)
508
+ end
509
+
510
+ # POST …/wake: the session's live worker for a remote attach, started
511
+ # when none runs, waited for up to +wait+ seconds there (a leaving
512
+ # worker first: +read_timeout+ covers that wait too).
513
+ # @return [WakeReply]
514
+ # @raise [SessionManager::OwnedByTUI] a chi REPL has it there
515
+ # @raise [Refused] a leftover scratch session (code scratch), or its
516
+ # Bridge didn't come in time (code worker_timeout): chi web's words,
517
+ # LaunchMode's constants
518
+ # @raise [ArgumentError] no such session
519
+ def attach_wake(id, wait:, read_timeout: nil)
520
+ reply = request(:post, "#{session_path(id)}/wake", body: { wait: wait }, read_timeout: read_timeout)
521
+ if reply.status != 200 && %w[scratch worker_timeout].include?(reply.error)
522
+ raise Refused.new((reply.json["message"] || reply.json["detail"]).to_s, code: reply.error)
523
+ end
524
+
525
+ raise_refusal(reply) unless reply.status == 200
526
+
527
+ json = reply.json
528
+ WakeReply.new(woke: json["woke"] == true, worker: json["worker"], default_model: json["default_model"],
529
+ strategy: strategy_or_hard(json["parent_strategy"]), installed: json["installed"])
530
+ end
531
+
532
+ # GET …/worker: the session's worker now (never started).
533
+ # @return [WorkerReply]
534
+ # @raise [ArgumentError] no such session
535
+ def attach_worker(id)
536
+ reply = request(:get, "#{session_path(id)}/worker")
537
+ raise_refusal(reply) unless reply.status == 200
538
+
539
+ json = reply.json
540
+ seconds = json["leave_seconds"]
541
+ WorkerReply.new(live: json["live"] == true, stopped: json["stopped"] == true, worker: json["worker"],
542
+ strategy: strategy_or_hard(json["parent_strategy"]), default_model: json["default_model"],
543
+ leaving: json["leaving"] == true, leave_seconds: (seconds.to_f if seconds.is_a?(Numeric)))
544
+ end
545
+
457
546
  # One request to a route, as the session commands make them (the
458
547
  # server check first; a refused token or a missing route raise
459
548
  # RemoteFailed): WebBridgeClient's, which maps the reply itself.
@@ -476,7 +565,8 @@ module Samagotchi
476
565
  def request(method, path, **)
477
566
  check_server!
478
567
  reply = @transport.call(method, path, **)
479
- raise RemoteFailed, Remote.auth_failure(@entry, reply) if Remote.auth_failure(@entry, reply)
568
+ refused = Remote.access_error(@entry, reply)
569
+ raise refused if refused
480
570
  raise RemoteTooOld, too_old(@version) if missing_route?(reply)
481
571
 
482
572
  reply
@@ -486,7 +576,10 @@ module Samagotchi
486
576
  return if @checked
487
577
 
488
578
  reply = @transport.call(:get, "/api/info")
489
- @version = reply.json["version"] if reply.json.is_a?(Hash)
579
+ if reply.json.is_a?(Hash)
580
+ @version = reply.json["version"]
581
+ @features = Array(reply.json["features"])
582
+ end
490
583
  problem = Remote.info_problem(@entry, reply)
491
584
  raise problem if problem
492
585
 
@@ -495,6 +588,8 @@ module Samagotchi
495
588
 
496
589
  def too_old(version) = Remote.too_old_message(@entry, version)
497
590
 
591
+ def strategy_or_hard(json) = strategy_of(json) || Guardrails::ParentPolicy.strategy(Guardrails::ParentPolicy::HARD)
592
+
498
593
  # chi web's 404 for a path no route takes (#serve_static's), not a
499
594
  # route's own "no such session".
500
595
  def missing_route?(reply)
@@ -527,6 +622,7 @@ module Samagotchi
527
622
  # spawn_session raises here (chi send: could not start a session).
528
623
  when "invalid_model" then raise ModelProfile::MissingModel, message.to_s
529
624
  when /\Ainvalid_/, "missing_fields", "bad_images"
625
+ # invalid_participant: chi share's name, kind or ttl, an archived session
530
626
  raise BadRequest.new(message.to_s, code: error)
531
627
  end
532
628
  raise RemoteFailed, "#{@entry.label} answered #{reply.status}#{" #{error}" unless error.empty?}" \
@@ -535,7 +631,7 @@ module Samagotchi
535
631
 
536
632
  def create_body(spec)
537
633
  body = { model: spec.model_name, client_id: spec.client_id, history: false }
538
- if spec.preview
634
+ if spec.preview || spec.idle
539
635
  body.merge!(idle: true, preview: spec.preview)
540
636
  else
541
637
  body[:prompt] = spec.prompt
@@ -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
@@ -89,9 +92,11 @@ module Samagotchi
89
92
  def port = nil
90
93
 
91
94
  # POST …/turn. history: false (chi web's prompt history is that
92
- # machine's page's).
95
+ # machine's page's). +author+ is never sent: the remote chi web stamps
96
+ # a turn's author from the token this client holds (Web::Principal),
97
+ # never from the body.
93
98
  # @return [BridgeClient::Response]
94
- def post_turn(prompt:, client_id: nil, no_interrupt: false, images: nil, delivery: nil)
99
+ def post_turn(prompt:, client_id: nil, no_interrupt: false, images: nil, delivery: nil, author: nil)
95
100
  body = { prompt: prompt, client_id: client_id, history: false }
96
101
  body[:no_interrupt] = true if no_interrupt
97
102
  body[:images] = images if images && !images.empty?
@@ -133,6 +138,51 @@ module Samagotchi
133
138
  post(:cancel, "cancel", { reason: reason })
134
139
  end
135
140
 
141
+ # POST …/exit: the worker asked to exit (chi web passes client_id on
142
+ # only when it is an attached TUI's).
143
+ # @return [BridgeClient::Response] 200 {status: exiting, discard}, 409
144
+ # held {reason, tasks}, 408 late, 404 an older worker's
145
+ def request_exit(client_id:, delete: false)
146
+ body = { client_id: client_id }
147
+ body[:delete] = true if delete
148
+ post(:exit, "exit", body)
149
+ end
150
+
151
+ # POST …/recap: the saved recap, and a new one asked for.
152
+ # @return [BridgeClient::Response] 200 {enabled, saved, request, min_user_turns, session_off}
153
+ def request_recap = post(:recap, "recap", {})
154
+
155
+ # GET …/stats: the worker's metrics (its state's, for an older
156
+ # worker), nil when it didn't give them.
157
+ # @return [Hash, nil]
158
+ # @raise [Unreachable]
159
+ def stats
160
+ response = get(:stats, "stats", read_timeout: READ_TIMEOUT)
161
+ response.status == 200 ? response.json&.dig("metrics") : nil
162
+ end
163
+
164
+ # GET …/children/counts: the session's delegates' counts there.
165
+ # @return [Hash] {running, waiting, unreported}
166
+ # @raise [Unreachable] chi web couldn't answer (or the session is gone)
167
+ def children_counts
168
+ response = get(:children, "children/counts", read_timeout: JOIN_READ_TIMEOUT)
169
+ raise Unreachable, "children counts: chi web answered #{response.status}" unless response.status == 200
170
+
171
+ response.json
172
+ end
173
+
174
+ # GET …/turns/:turn_id/records: a turn's saved tool records and turn
175
+ # record there.
176
+ # @return [Hash] {tool_records: [...], turn_record: {...} | nil}
177
+ # @raise [Unreachable]
178
+ def turn_records(turn_id)
179
+ response = get(:records, "turns/#{URI.encode_www_form_component(turn_id.to_s)}/records",
180
+ read_timeout: JOIN_READ_TIMEOUT)
181
+ raise Unreachable, "turn records: chi web answered #{response.status}" unless response.status == 200
182
+
183
+ response.json
184
+ end
185
+
136
186
  # Follow the session's events through chi web (see EventStream): with
137
187
  # epoch_watch, announce_retries and STREAM_IDLE_TIMEOUT.
138
188
  # @return [BridgeClient::EventStream] started
@@ -172,6 +222,13 @@ module Samagotchi
172
222
  raise Unreachable, e.message
173
223
  end
174
224
 
225
+ def get(route, path, read_timeout:)
226
+ reply = @remote.route_request(:get, "/api/sessions/#{@session_id}/#{path}", read_timeout: read_timeout)
227
+ Mapping.response(route, reply, session_id: @session_id, remote: @remote.name)
228
+ rescue RemoteFailed => e
229
+ raise Unreachable, e.message
230
+ end
231
+
175
232
  def refused(reason)
176
233
  Log.warn(:attached, "stream_refused", reason: reason, remote: @remote.name)
177
234
  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:).