samagotchi 0.119.0 → 0.121.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 (91) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +100 -1
  3. data/bin/chi +6 -1
  4. data/docs/architecture.md +35 -5
  5. data/docs/cli.md +33 -8
  6. data/docs/configuration.md +74 -20
  7. data/docs/internals/participants.md +111 -12
  8. data/docs/internals/prompt-caching.md +7 -6
  9. data/docs/internals/remote-sessions.md +6 -1
  10. data/docs/internals/web-push.md +4 -2
  11. data/docs/plugins.md +24 -8
  12. data/docs/sessions.md +4 -3
  13. data/docs/testing.md +13 -2
  14. data/lib/samagotchi/answer_command.rb +2 -0
  15. data/lib/samagotchi/bridge.rb +56 -13
  16. data/lib/samagotchi/bridge_client/event_stream.rb +34 -3
  17. data/lib/samagotchi/bridge_client.rb +58 -19
  18. data/lib/samagotchi/bridge_secret.rb +35 -0
  19. data/lib/samagotchi/bundles/claude-peer/manifest.yml +2 -2
  20. data/lib/samagotchi/bundles/claude-peer/scripts/pair +1 -1
  21. data/lib/samagotchi/bundles/dev/manifest.yml +2 -2
  22. data/lib/samagotchi/bundles/mcp/manifest.yml +3 -3
  23. data/lib/samagotchi/bundles/mcp/plugin.rb +5 -1
  24. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  25. data/lib/samagotchi/config.rb +3 -0
  26. data/lib/samagotchi/engine.rb +1 -0
  27. data/lib/samagotchi/kernel_loop.rb +5 -0
  28. data/lib/samagotchi/participant_messages.rb +5 -0
  29. data/lib/samagotchi/participants.rb +16 -6
  30. data/lib/samagotchi/plugin/api.rb +6 -3
  31. data/lib/samagotchi/review_command.rb +3 -1
  32. data/lib/samagotchi/run_options.rb +3 -1
  33. data/lib/samagotchi/session_access/contract.rb +4 -1
  34. data/lib/samagotchi/session_access/local.rb +15 -4
  35. data/lib/samagotchi/session_access/remote.rb +43 -7
  36. data/lib/samagotchi/session_access.rb +11 -1
  37. data/lib/samagotchi/session_manager.rb +1 -1
  38. data/lib/samagotchi/sessions_command.rb +1 -2
  39. data/lib/samagotchi/share_command.rb +29 -8
  40. data/lib/samagotchi/terminal_ui/attached_loop.rb +3 -0
  41. data/lib/samagotchi/terminal_ui/local_link.rb +18 -3
  42. data/lib/samagotchi/terminal_ui/question_slot.rb +19 -8
  43. data/lib/samagotchi/tool_declarations.rb +1 -1
  44. data/lib/samagotchi/tool_runner.rb +37 -1
  45. data/lib/samagotchi/toolkit.rb +50 -26
  46. data/lib/samagotchi/toolkit_binding.rb +32 -0
  47. data/lib/samagotchi/toolkit_command.rb +23 -0
  48. data/lib/samagotchi/tools/deferred_tools.rb +10 -2
  49. data/lib/samagotchi/tools/delegate_relay.rb +4 -0
  50. data/lib/samagotchi/tools/registry.rb +20 -6
  51. data/lib/samagotchi/tools/tool_index.rb +103 -20
  52. data/lib/samagotchi/version.rb +1 -1
  53. data/lib/samagotchi/web/app.rb +117 -43
  54. data/lib/samagotchi/web/grants.rb +127 -16
  55. data/lib/samagotchi/web/link_codes.rb +24 -15
  56. data/lib/samagotchi/web/participant_access.rb +69 -13
  57. data/lib/samagotchi/web/participant_exposure.rb +44 -0
  58. data/lib/samagotchi/web/participant_review.rb +71 -9
  59. data/lib/samagotchi/web/participants_admin.rb +12 -6
  60. data/lib/samagotchi/web/proxy_gate.rb +18 -3
  61. data/lib/samagotchi/web/public/annotate_bar.css +18 -0
  62. data/lib/samagotchi/web/public/annotate_bar.js +10 -3
  63. data/lib/samagotchi/web/public/app.js +66 -155
  64. data/lib/samagotchi/web/public/composer.css +73 -0
  65. data/lib/samagotchi/web/public/composer_grip.js +21 -15
  66. data/lib/samagotchi/web/public/composer_mount.js +196 -0
  67. data/lib/samagotchi/web/public/forms.css +12 -0
  68. data/lib/samagotchi/web/public/index.html +14 -88
  69. data/lib/samagotchi/web/public/level_chip.js +2 -2
  70. data/lib/samagotchi/web/public/llm_context_chip.js +4 -4
  71. data/lib/samagotchi/web/public/notifications.js +58 -8
  72. data/lib/samagotchi/web/public/participant.html +46 -10
  73. data/lib/samagotchi/web/public/participant.js +101 -18
  74. data/lib/samagotchi/web/public/participant_annotate.js +75 -0
  75. data/lib/samagotchi/web/public/participant_model.js +53 -1
  76. data/lib/samagotchi/web/public/participants_panel.js +37 -13
  77. data/lib/samagotchi/web/public/question_card.js +15 -1
  78. data/lib/samagotchi/web/public/question_cards.js +11 -2
  79. data/lib/samagotchi/web/public/review_history.js +60 -0
  80. data/lib/samagotchi/web/public/scope.js +12 -8
  81. data/lib/samagotchi/web/public/stage_frame.js +9 -4
  82. data/lib/samagotchi/web/public/stage_view.js +4 -4
  83. data/lib/samagotchi/web/public/toolkit_chip.js +3 -0
  84. data/lib/samagotchi/web/push/notifier.rb +17 -3
  85. data/lib/samagotchi/web/running.rb +7 -4
  86. data/lib/samagotchi/web/server.rb +34 -11
  87. data/lib/samagotchi/web/session_create.rb +7 -7
  88. data/lib/samagotchi/web/start_dir.rb +26 -0
  89. data/lib/samagotchi/web/tunnel.rb +10 -1
  90. data/lib/samagotchi/worker_sidecar.rb +22 -11
  91. metadata +10 -1
@@ -110,7 +110,9 @@ module Samagotchi
110
110
  @stdout.puts("#{id} #{what}#{subjects} · idle")
111
111
  @stdout.puts("warning: #{created.model_warning}") if created.model_warning
112
112
  @stdout.puts("open it: #{@access.invocation.open_it(id[0, 8])}")
113
- @stdout.puts("invite someone: #{@access.invocation.command("share #{id[0, 8]} --name NAME")}")
113
+ # Behind a proxy that names the user, a link can be bound to them.
114
+ identity = " --identity EMAIL" if @access.identity_header
115
+ @stdout.puts("invite someone: #{@access.invocation.command("share #{id[0, 8]} --name NAME#{identity}")}")
114
116
  end
115
117
 
116
118
  def refused(message)
@@ -14,7 +14,7 @@ module Samagotchi
14
14
  module RunOptions
15
15
  # The flags an underscored spelling is refused for (--recap_base_url),
16
16
  # with the config registry's.
17
- KNOWN = %w[--prompt --resume --attach --shared --no-shared --model --thinking --toolkit --tool --no-tool --set --parent-answers --memory --mute --append-system-prompt --dir --llm-context --llm-context-apply --llm-context-budget --no-interrupt --non-interactive --verbose --no-default-input --port --open --new-token --tunnel].freeze
17
+ KNOWN = %w[--prompt --resume --attach --shared --no-shared --model --thinking --toolkit --tool --no-tool --set --parent-answers --memory --mute --append-system-prompt --dir --llm-context --llm-context-apply --llm-context-budget --no-interrupt --non-interactive --verbose --no-default-input --port --open --new-token --tunnel --start-dir].freeze
18
18
 
19
19
  module_function
20
20
 
@@ -223,6 +223,8 @@ module Samagotchi
223
223
  opts.on("--tunnel PROVIDER", %w[tailscale],
224
224
  "`chi web`: reach it over https from your tailnet through Tailscale Serve (never Funnel), for this run; " \
225
225
  "turned off on exit") { |v| options[:tunnel] = v }
226
+ opts.on("--start-dir DIR", "`chi web`: the folder it starts in (web.start_dir; default: this one): its page opens on that " \
227
+ "folder's project, and a new session from the all view starts there") { |v| options[:start_dir] = v }
226
228
  opts.on("--scope VALUE", %w[project all],
227
229
  "Which sessions `chi web` and `chi sessions list` show: project (the default: this folder's git project; " \
228
230
  "every session outside a repo) or all") { |v| options[:scope] = v }
@@ -123,6 +123,9 @@ module Samagotchi
123
123
  "share --agent" => SUPPORTED,
124
124
  "share --expires" => SUPPORTED,
125
125
  "share --cap" => SUPPORTED,
126
+ # A chi web without bound grants (Remote#invite checks its
127
+ # bound_grants feature) is refused, never sent the identity.
128
+ "share --identity" => SUPPORTED,
126
129
  "share cap" => SUPPORTED,
127
130
  "share ls" => SUPPORTED,
128
131
  "share ls --format" => SUPPORTED,
@@ -150,7 +153,7 @@ module Samagotchi
150
153
  --llm-context-budget --toolkit --tool --no-tool --set --parent-answers --no-interrupt
151
154
  --no-default-input --dir --version -h --help],
152
155
  LOCAL_ONLY => %w[--no-shared --non-interactive -v --verbose --append-system-prompt --memory --mute --profile
153
- --port --open --new-token --tunnel --scope --web-markdown --no-web-markdown]
156
+ --port --open --new-token --tunnel --start-dir --scope --web-markdown --no-web-markdown]
154
157
  }.freeze
155
158
  # A short run flag's name in a refusal.
156
159
  RUN_WORDS = { "-v" => "--verbose" }.freeze
@@ -370,12 +370,16 @@ module Samagotchi
370
370
  # and port when one runs on the LAN (the port it recorded, else
371
371
  # web.port), else 127.0.0.1 (+lan+ false: the link opens on this
372
372
  # machine only).
373
+ # A grant bound to +identity+ needs that public_url with an identity
374
+ # header (Participants.invite).
373
375
  # @return [Invitation]
374
376
  # @raise [ArgumentError] no such session
375
- # @raise [BadRequest] a bad name, kind or ttl, an archived session
376
- def invite(id, name:, kind:, ttl: nil, cap: nil)
377
- issued = Participants.invite(grants, session_id: id, name: name, kind: kind, ttl: ttl, cap: cap, state_dir: state_dir!)
377
+ # @raise [BadRequest] a bad name, kind, ttl or identity, an archived
378
+ # session, an identity with no proxy to check it
379
+ def invite(id, name:, kind:, ttl: nil, cap: nil, identity: nil)
378
380
  public_url = configured_public_url
381
+ issued = Participants.invite(grants, session_id: id, name: name, kind: kind, ttl: ttl, cap: cap,
382
+ identity: identity, public_url: public_url, state_dir: state_dir!)
379
383
  web = public_url ? nil : local_web
380
384
  link = if public_url then public_url.link(link_code(issued), "s=#{id}")
381
385
  else "http://#{web.ip || "127.0.0.1"}:#{web.port}/?token=#{issued.token}#s=#{id}"
@@ -387,6 +391,13 @@ module Samagotchi
387
391
  raise BadRequest.new(e.message, code: "invalid_participant")
388
392
  end
389
393
 
394
+ # The header this machine's chi web names the signed-in user in (a
395
+ # link can be bound to an email: chi share --identity), nil when it
396
+ # has no such proxy: config's web.public_url with
397
+ # web.proxy.identity_header, or a running chi web --tunnel's.
398
+ # @return [String, nil]
399
+ def identity_header = configured_public_url&.identity_header
400
+
390
401
  # @return [Array<ParticipantView>] the ones it revoked
391
402
  def revoke_participants(id = nil, participant: nil)
392
403
  Participants.revoke(grants, session_id: id, id: participant, state_dir: state_dir!)
@@ -440,7 +451,7 @@ module Samagotchi
440
451
  configured = Config.get("web.public_url")
441
452
  return Web::Running.live_public_url(path: Web::Running.path(state_dir: state_dir!)) if configured.to_s.strip.empty?
442
453
 
443
- Web::PublicUrl.from_config(url: configured)
454
+ Web::PublicUrl.from_config(url: configured, identity_header: Config.get("web.proxy.identity_header"))
444
455
  rescue Web::PublicUrl::Invalid
445
456
  nil
446
457
  end
@@ -122,7 +122,7 @@ module Samagotchi
122
122
  end
123
123
  end
124
124
 
125
- ACCESS_ERRORS = [TokenRevoked, TokenExpired, NotPermitted, TooManyTurns, CapReached].freeze
125
+ ACCESS_ERRORS = [TokenRevoked, TokenExpired, TokenBound, NotPermitted, TooManyTurns, CapReached].freeze
126
126
 
127
127
  # --- ids and sessions
128
128
 
@@ -252,15 +252,37 @@ module Samagotchi
252
252
  end
253
253
 
254
254
  # @param ttl [Integer, nil] seconds; nil: the grant's default
255
+ # @param identity [String, nil] bind the grant to this email: only a
256
+ # chi web with bound grants (BOUND_FEATURE) is asked; an older one
257
+ # would ignore it and issue a grant anyone could open
255
258
  # @return [Invitation]
256
- # @raise [BadRequest] a bad name, kind or ttl, an archived session
257
- def invite(id, name:, kind:, ttl: nil, cap: nil)
258
- reply = request(:post, "#{session_path(id)}/participants", body: { name: name, kind: kind, ttl: ttl, cap: cap }.compact)
259
+ # @raise [BadRequest] a bad name, kind, ttl or identity, an archived
260
+ # session, an identity that chi web has no proxy to check
261
+ # @raise [RemoteTooOld] an identity, and a chi web without bound grants
262
+ def invite(id, name:, kind:, ttl: nil, cap: nil, identity: nil)
263
+ require_feature!(BOUND_FEATURE) unless identity.nil?
264
+ body = { name: name, kind: kind, ttl: ttl, cap: cap, identity: identity }.compact
265
+ reply = request(:post, "#{session_path(id)}/participants", body: body)
259
266
  raise_refusal(reply) unless reply.status == 201
260
267
 
261
268
  json = reply.json
262
- Invitation.new(participant: ParticipantView.from_h(json.fetch("participant")), token: json.fetch("token"),
263
- link: json.fetch("link"), lan: json["lan"] == true, warning: json["warning"])
269
+ participant = ParticipantView.from_h(json.fetch("participant"))
270
+ check_bound!(id, participant, identity)
271
+ Invitation.new(participant: participant, token: json.fetch("token"), link: json.fetch("link"),
272
+ lan: json["lan"] == true, warning: json["warning"])
273
+ end
274
+
275
+ # Web::App::BOUND_FEATURE: POST …/participants takes an identity.
276
+ BOUND_FEATURE = "bound_grants"
277
+
278
+ # The header that chi web names the signed-in user in (GET
279
+ # /api/info's proxy), nil when it has no such proxy, or is a chi
280
+ # web from before it said.
281
+ # @return [String, nil]
282
+ def identity_header
283
+ check_server!
284
+ header = @proxy.is_a?(Hash) ? @proxy["identity_header"] : nil
285
+ header.is_a?(String) && !header.empty? ? header : nil
264
286
  end
265
287
 
266
288
  # Revokes one participant (+participant+), a session's, or with no
@@ -602,6 +624,7 @@ module Samagotchi
602
624
  if reply.json.is_a?(Hash)
603
625
  @version = reply.json["version"]
604
626
  @features = Array(reply.json["features"])
627
+ @proxy = reply.json["proxy"]
605
628
  end
606
629
  problem = Remote.info_problem(@entry, reply)
607
630
  raise problem if problem
@@ -611,6 +634,18 @@ module Samagotchi
611
634
 
612
635
  def too_old(version) = Remote.too_old_message(@entry, version)
613
636
 
637
+ # A grant asked bound to +identity+ came back bound to it, or is
638
+ # revoked at once (its link never printed) and refused: belt and
639
+ # braces behind BOUND_FEATURE.
640
+ # @raise [RemoteFailed]
641
+ def check_bound!(id, participant, identity)
642
+ return if identity.nil? || participant.identity == identity.to_s.strip.downcase
643
+
644
+ revoke_participants(id, participant: participant.id)
645
+ raise RemoteFailed, "#{@entry.label}: its chi web issued #{participant.id} unbound, so chi revoked it: " \
646
+ "update chi there (chi update)"
647
+ end
648
+
614
649
  def strategy_or_hard(json) = strategy_of(json) || Guardrails::ParentPolicy.strategy(Guardrails::ParentPolicy::HARD)
615
650
 
616
651
  # chi web's 404 for a path no route takes (#serve_static's), not a
@@ -730,7 +765,7 @@ module Samagotchi
730
765
  # chi web's answer/dismiss reply as the worker's Bridge would have
731
766
  # given it: 503 not_live is no worker (nil), 504 the Bridge's 408, 501
732
767
  # not_supported its 404 for a route it lacks, 409 question_not_pending
733
- # its 409; the rest as it came.
768
+ # its 409, 502 bridge_secret its 403 (BridgeSecret); the rest as it came.
734
769
  def bridge_reply(reply)
735
770
  json = reply.json.is_a?(Hash) ? reply.json : {}
736
771
  case [reply.status, json["error"]]
@@ -738,6 +773,7 @@ module Samagotchi
738
773
  in [404, "not_found"] then nil
739
774
  in [504, _] then BridgeClient::Response.new(status: 408, body: reply.body)
740
775
  in [501, "not_supported"] then BridgeClient::Response.new(status: 404, body: JSON.generate(json.merge("error" => "not_found")))
776
+ in [502, BridgeSecret::ERROR] then BridgeClient::Response.new(status: 403, body: reply.body)
741
777
  else BridgeClient::Response.new(status: reply.status, body: reply.body)
742
778
  end
743
779
  end
@@ -51,6 +51,13 @@ module Samagotchi
51
51
  def initialize(label) = super("#{label}: #{ParticipantMessages::TOKEN_EXPIRED}")
52
52
  end
53
53
 
54
+ # 401 token_bound: a link bound to one account, used without that
55
+ # account's sign-in (chi --remote doesn't go through the proxy).
56
+ class TokenBound < AuthRefused
57
+ CODE = "token_bound"
58
+ def initialize(label) = super("#{label}: #{ParticipantMessages::TOKEN_BOUND}")
59
+ end
60
+
54
61
  # 403 not_permitted: a participant's token on a route only the session's
55
62
  # owner may use.
56
63
  class NotPermitted < RemoteFailed
@@ -94,8 +101,11 @@ module Samagotchi
94
101
  # (Web::Grants::Grant#public_h): never its token or digest. +state+
95
102
  # "active", "revoked" or "expired".
96
103
  # +cap+ dollars and +queue+ turns (nil from a chi web before them).
104
+ # +identity+ the email the grant is bound to, nil for any.
97
105
  ParticipantView = Data.define(:id, :session_id, :kind, :name, :role, :created_at, :expires_at, :revoked_at,
98
- :revoked_by, :last_used_at, :state, :cap, :queue) do
106
+ :revoked_by, :last_used_at, :state, :cap, :queue, :identity) do
107
+ def initialize(identity: nil, **) = super
108
+
99
109
  def self.from_h(hash) = new(**members.to_h { |key| [key, hash[key] || hash[key.to_s]] })
100
110
  end
101
111
 
@@ -689,7 +689,7 @@ module Samagotchi
689
689
  raise RestartRefused.new(session_id, :unsupported, version: sidecar.version)
690
690
  end
691
691
 
692
- reply = BridgeClient.new(session_id: session_id, port: sidecar.port).request_exit(client_id: client_id, restart: true)
692
+ reply = BridgeClient.for_sidecar(session_id, sidecar).request_exit(client_id: client_id, restart: true)
693
693
  raise RestartRefused.new(session_id, reply.json&.fetch("reason", nil) || :failed) if reply.status == 409
694
694
  raise RestartRefused.new(session_id, :failed) unless reply.status == 200
695
695
 
@@ -371,8 +371,7 @@ module Samagotchi
371
371
  # @return [Array(Hash, nil, Boolean, String, nil)]
372
372
  def stats_snapshot_for(session)
373
373
  dir = Samagotchi::Session.session_dir(session.id)
374
- if (port = Samagotchi::BridgeClient.sidecar_port(dir))
375
- client = Samagotchi::BridgeClient.new(session_id: session.id, port: port, read_timeout: STATS_TIMEOUT)
374
+ if (client = Samagotchi::BridgeClient.discover(session.id, session_dir: dir, read_timeout: STATS_TIMEOUT))
376
375
  body = client.get_json("stats")&.dig("metrics")
377
376
  if body.is_a?(Hash)
378
377
  return [TerminalUI::EventRenderer.deep_symbolize_keys(body), true, nil]
@@ -22,13 +22,16 @@ module Samagotchi
22
22
  include CLI::Command
23
23
 
24
24
  USAGE = <<~TEXT
25
- Usage: chi share ID --name NAME [--agent] [--expires 3d] [--cap 2]
25
+ Usage: chi share ID --name NAME [--identity EMAIL] [--agent] [--expires 3d] [--cap 2]
26
26
  chi share ls [ID] [--format text|json]
27
27
  chi share cap ID PARTICIPANT DOLLARS
28
28
  chi share revoke ID [PARTICIPANT] | --all
29
29
  ID --name NAME invite NAME to review session ID: prints the link they open (chi web on
30
30
  the LAN, web.host: lan) and its QR code at a terminal. The link is shown
31
31
  once; chi keeps only a digest of its token.
32
+ --identity EMAIL bind the link to that account: it opens only through chi web's auth proxy
33
+ signed in as EMAIL (web.public_url with web.proxy.identity_header, or
34
+ chi web --tunnel tailscale); a forwarded link opens for nobody else
32
35
  --agent the participant is an agent (the same link, used as a Bearer token)
33
36
  --expires N Nh or Nd, 1h to 30d (default 7d, web.participants.expires)
34
37
  --cap DOLLARS what their turns may cost you, $0 to $1000 (default $2, web.participants.cap)
@@ -50,6 +53,7 @@ module Samagotchi
50
53
  # The invite's flags (chi share ID …), and ls's and revoke's.
51
54
  FLAGS = CLI::Flags.new(help: HELP_WORDS) do |f|
52
55
  f.value "--name"
56
+ f.value "--identity"
53
57
  f.switch "--agent"
54
58
  f.value "--expires"
55
59
  f.value "--cap"
@@ -91,9 +95,9 @@ module Samagotchi
91
95
  return parsed if parsed.is_a?(Integer)
92
96
 
93
97
  send(:"run_#{@sub || "invite"}", parsed.options, parsed.args)
94
- rescue SessionAccess::BadRequest => e
98
+ rescue SessionAccess::BadRequest, Web::Grants::BadIdentity => e
95
99
  usage_error(e.message)
96
- rescue Web::Grants::Corrupt => e
100
+ rescue Web::Grants::Unwritable => e
97
101
  error_line("#{command_name}: #{e.message}")
98
102
  CLI::Exit::FAILED
99
103
  rescue ArgumentError => e
@@ -116,9 +120,13 @@ module Samagotchi
116
120
  cap = options[:cap] && dollars(options[:cap])
117
121
  return usage_error(CAP_USAGE) if cap == false
118
122
 
123
+ identity = options[:identity] && Web::Grants.clean_identity(options[:identity])
124
+
119
125
  id = @access.resolve(args.first)
120
- invitation = @access.invite(id, name: options[:name], kind: options[:agent] ? "agent" : "human", ttl: ttl, cap: cap)
126
+ invitation = @access.invite(id, name: options[:name], kind: options[:agent] ? "agent" : "human", ttl: ttl, cap: cap,
127
+ identity: identity)
121
128
  print_invitation(id, invitation)
129
+ warn_unbound(invitation.participant)
122
130
  @changes = [[:joined, invitation.participant.id]]
123
131
  0
124
132
  end
@@ -186,10 +194,21 @@ module Samagotchi
186
194
  0
187
195
  end
188
196
 
197
+ # Decision B: an unbound link behind a proxy that could bind it opens
198
+ # for anyone the proxy lets in who holds it.
199
+ def warn_unbound(row)
200
+ return unless row.identity.nil? && (header = @access.identity_header)
201
+
202
+ error_line("#{command_name}: warning: #{format(UNBOUND_WARNING, header: header)}")
203
+ end
204
+
205
+ UNBOUND_WARNING = "this link isn't bound to an account: anyone the proxy lets in who gets it can open it " \
206
+ "(--identity EMAIL binds it to the email %<header>s carries)"
207
+
189
208
  def print_invitation(id, invitation)
190
209
  row = invitation.participant
191
210
  @stdout.puts("#{row.id} #{row.name} (#{row.kind}, #{row.role}) · session #{id[0, 8]} · expires " \
192
- "#{local(row.expires_at, "%Y-%m-%d %H:%M")} · cap #{money(row.cap)}")
211
+ "#{local(row.expires_at, "%Y-%m-%d %H:%M")} · cap #{money(row.cap)}#{" · bound to #{row.identity}" if row.identity}")
193
212
  @stdout.puts(invitation.link)
194
213
  @stdout.puts("(shown once: chi keeps only its digest; chi share revoke #{id[0, 8]} #{row.id} ends it)")
195
214
  if invitation.warning
@@ -201,12 +220,14 @@ module Samagotchi
201
220
  @stdout.puts(Web::QR.lines(invitation.link)) if @tty
202
221
  end
203
222
 
204
- COLUMNS = ["SESSION", "PARTICIPANT", "NAME", "KIND", "ROLE", "CREATED", "EXPIRES", "LAST USED", "CAP", "STATE"].freeze
223
+ COLUMNS = ["SESSION", "PARTICIPANT", "NAME", "IDENTITY", "KIND", "ROLE", "CREATED", "EXPIRES", "LAST USED", "CAP",
224
+ "STATE"].freeze
205
225
 
206
226
  def print_table(rows)
207
227
  cells = rows.map do |row|
208
- [row.session_id[0, 8], row.id, row.name, row.kind, row.role, local(row.created_at), local(row.expires_at),
209
- row.last_used_at ? local(row.last_used_at) : "-", money(row.cap), row.state]
228
+ [row.session_id[0, 8], row.id, row.name, row.identity ? "#{row.identity} · bound" : "-", row.kind, row.role,
229
+ local(row.created_at), local(row.expires_at), row.last_used_at ? local(row.last_used_at) : "-", money(row.cap),
230
+ row.state]
210
231
  end
211
232
  widths = COLUMNS.each_index.map { |i| ([COLUMNS[i]] + cells.map { |c| c[i] }).map(&:length).max }
212
233
  [COLUMNS, *cells].each do |line|
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "../client_id"
4
+ require_relative "../bridge_secret"
4
5
  require_relative "../composer_route"
5
6
  require_relative "event_renderer"
6
7
  require_relative "formatting"
@@ -450,6 +451,8 @@ module Samagotchi
450
451
  # The stream ended for good: why, and what brings the session back.
451
452
  def closed_line(reason)
452
453
  id = @client.session_id
454
+ # The worker asked for a secret this terminal's chi doesn't send.
455
+ return "Lost the session's worker: #{BridgeSecret::REFUSED}" if reason == BridgeSecret::ERROR
453
456
  return remote_closed_line(reason, id) if via.remote?
454
457
 
455
458
  # Here a stopped session has always read as unreachable.
@@ -55,12 +55,27 @@ module Samagotchi
55
55
  id = @client.session_id
56
56
  return [:gone, "stopped"] if Session.stopped_marker?(id, state_dir: @state_dir)
57
57
 
58
- [BridgeClient.discover(id, session_dir: session_dir, host: @client.host), nil]
58
+ # Kept: the stream moves to this very client (EventStream), and
59
+ # #switched takes it, port and secret, rather than reading again.
60
+ @found = BridgeClient.discover(id, session_dir: session_dir, host: @client.host)
61
+ [@found, nil]
59
62
  end
60
63
 
61
- # A new worker's Bridge, on the port its event names.
64
+ # A new worker's Bridge: the client #live_worker found for the stream
65
+ # (the event names its port), with that worker's secret. One the event
66
+ # doesn't name (another worker since) is read from the sidecar for that
67
+ # port, none when the sidecar names another one by now: that worker is
68
+ # gone too, and the stream finds the next.
62
69
  def switched(event)
63
- @client = BridgeClient.new(session_id: @client.session_id, port: AttachLink.event_port(event), host: @client.host)
70
+ port = AttachLink.event_port(event)
71
+ found = @found
72
+ @client = if found&.port == port
73
+ found
74
+ else
75
+ sidecar = WorkerSidecar.read(session_dir)
76
+ sidecar = WorkerSidecar.new(port: port) unless sidecar&.port == port
77
+ BridgeClient.for_sidecar(@client.session_id, sidecar, host: @client.host)
78
+ end
64
79
  end
65
80
 
66
81
  # No sidecar to read: true, its Bridge answers (a P0-P2 worker refuses
@@ -15,6 +15,12 @@ module Samagotchi
15
15
  # A numbered option shows as "1) Apple", a word as "yes: continue".
16
16
  Option = Struct.new(:key, :label) do
17
17
  def text = key.match?(/\A\d+\z/) ? "#{key}) #{label}" : "#{key}: #{label}"
18
+
19
+ # The model's pick (a label ending in "(Recommended)"): painted bold,
20
+ # its label kept as is so a typed label still matches.
21
+ def recommended? = label.to_s.rstrip.end_with?("(Recommended)")
22
+
23
+ def code = recommended? ? "1;92" : 92
18
24
  end
19
25
 
20
26
  CONTINUE_QUESTION = "The turn ran out of iterations. Continue it?"
@@ -83,7 +89,7 @@ module Samagotchi
83
89
  question_rows = question_rows.first(1) unless details
84
90
  rows.concat(question_rows.map { |row| @paint.call(row, @question_code) })
85
91
  rows.concat(@details.map { |row| @paint.call(row, @question_code) }) if details
86
- rows.concat(folded ? folded_options(width) : @options.map { |o| @paint.call(" #{o.text}", 92) })
92
+ rows.concat(folded ? folded_options(width) : @options.map { |o| @paint.call(" #{o.text}", o.code) })
87
93
  rows << @paint.call(" [#{@hint}]", 90) if hint && @hint
88
94
  rows
89
95
  end
@@ -91,19 +97,24 @@ module Samagotchi
91
97
  # The options packed into rows of +width+: " 1) Allow once · 2) Deny".
92
98
  def folded_options(width)
93
99
  rows = []
94
- line = +""
100
+ line = []
95
101
  @options.each do |option|
96
- item = option.text
97
- candidate = line.empty? ? " #{item}" : "#{line} · #{item}"
98
- if !line.empty? && display_width(candidate) > width
102
+ if !line.empty? && display_width(" #{(line + [option]).map(&:text).join(" · ")}") > width
99
103
  rows << line
100
- line = " #{item}"
104
+ line = [option]
101
105
  else
102
- line = +candidate
106
+ line << option
103
107
  end
104
108
  end
105
109
  rows << line unless line.empty?
106
- rows.map { |row| @paint.call(row, 92) }
110
+ rows.map { |row| paint_folded(row) }
111
+ end
112
+
113
+ # One folded row; a recommended option in it is painted on its own.
114
+ def paint_folded(options)
115
+ return @paint.call(" #{options.map(&:text).join(" · ")}", 92) if options.none?(&:recommended?)
116
+
117
+ @paint.call(" ", 92) + options.map { |o| @paint.call(o.text, o.code) }.join(@paint.call(" · ", 92))
107
118
  end
108
119
 
109
120
  # Word wrap at +width+ columns, the first row after +first+, the others
@@ -412,7 +412,7 @@ module Samagotchi
412
412
  },
413
413
  {
414
414
  name: "ask_user_question",
415
- description: "Ask the user a structured qualification question. Supports single/multi selection plus optional freeform/Other input. Prefer this over plain numbered lists when you need a clear choice. The harness renders it natively and returns {selected, freeform}.",
415
+ description: "Ask the user a structured qualification question. Supports single/multi selection plus optional freeform/Other input. Prefer this over plain numbered lists when you need a clear choice. The harness renders it natively and returns {selected, freeform}. If you recommend one option, put it first and end its label with ' (Recommended)'.",
416
416
  parameters: {
417
417
  type: "object",
418
418
  properties: {
@@ -77,7 +77,16 @@ module Samagotchi
77
77
  # hook, no approval card for a call that can't run (KernelLoop#dispatch
78
78
  # answers it with the not-loaded error).
79
79
  original = call.dup
80
- verdict = left_out?(call) ? Guardrails::Verdict.new(call: call) : evaluate(call, iteration, params)
80
+ refusal = unreached_group_refusal(call)
81
+ verdict = if refusal
82
+ Guardrails::Verdict.new(call: call).deny!(refusal.message,
83
+ rule: Tools::ToolIndex::ToolkitGroupRefusal::RULE,
84
+ source: "core", decided_by: "core")
85
+ elsif left_out?(call)
86
+ Guardrails::Verdict.new(call: call)
87
+ else
88
+ evaluate(call, iteration, params)
89
+ end
81
90
  call = verdict.call
82
91
  params = ToolActivity.tool_activity_params(call[:name], call, registry: tools)
83
92
  label = plugin_label(call[:name])
@@ -378,6 +387,27 @@ module Samagotchi
378
387
  !registry.left_out(call[:name], layers: layers).nil?
379
388
  end
380
389
 
390
+ def unreached_group_refusal(call)
391
+ return if participant_turn?
392
+
393
+ registry = tools
394
+ index = registry&.index if registry.respond_to?(:index)
395
+ return unless index.respond_to?(:unreached_tool)
396
+
397
+ name = if call[:name] == index.call_name
398
+ args = call[:args].is_a?(Hash) ? call[:args] : {}
399
+ args["tool"] || args[:tool]
400
+ else
401
+ call[:name]
402
+ end
403
+ tool = index.unreached_tool(name)
404
+ tool && Tools::ToolIndex::ToolkitGroupRefusal.for(tool, registry)
405
+ end
406
+
407
+ def participant_turn?
408
+ @kernel.respond_to?(:participant_turn?) && @kernel.participant_turn?
409
+ end
410
+
381
411
  # Whether the turn's host loads tools (TurnSettings#loads_tools).
382
412
  def loads_tools? = @kernel.respond_to?(:turn_settings) && @kernel.turn_settings&.loads_tools == true
383
413
 
@@ -431,6 +461,12 @@ module Samagotchi
431
461
  # A legacy veto keeps its old text; a verdict's deny tells the model
432
462
  # who decided and not to route around it.
433
463
  def denied(call, verdict)
464
+ if verdict.rule == Tools::ToolIndex::ToolkitGroupRefusal::RULE
465
+ output = "[#{call[:name]}] #{verdict.reason}"
466
+ activity = ToolActivity.tool_activity_event(call[:name], call, output, registry: tools)
467
+ return { output: output, activity: activity.merge(status: "blocked", guardrail: verdict.to_activity) }
468
+ end
469
+
434
470
  output = if verdict.legacy?
435
471
  reason = verdict.reason.to_s.strip
436
472
  "[#{call[:name]}] Error: blocked by guardrail: #{reason.empty? ? "blocked by hook" : reason}"