samagotchi 0.152.0 → 0.154.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 (229) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +45 -1
  3. data/README.md +2 -3
  4. data/bin/chi +23 -24
  5. data/docs/architecture.md +30 -33
  6. data/docs/broadcast.md +5 -5
  7. data/docs/bundle-authoring.md +1 -1
  8. data/docs/bundles.md +93 -12
  9. data/docs/cli.md +83 -99
  10. data/docs/configuration.md +46 -40
  11. data/docs/context.md +3 -3
  12. data/docs/desktop.md +1 -1
  13. data/docs/guardrails.md +15 -15
  14. data/docs/hooks.md +13 -12
  15. data/docs/internals/anthropic.md +3 -3
  16. data/docs/internals/attached-context.md +5 -2
  17. data/docs/internals/background-tasks.md +3 -4
  18. data/docs/internals/block-renderers.md +4 -0
  19. data/docs/internals/bridge.md +3 -3
  20. data/docs/internals/context-telemetry.md +1 -1
  21. data/docs/internals/engine.md +42 -12
  22. data/docs/internals/objectives.md +6 -5
  23. data/docs/internals/participants.md +8 -8
  24. data/docs/internals/prompt-caching.md +8 -4
  25. data/docs/internals/remote-sessions.md +7 -7
  26. data/docs/internals/web-frontend.md +20 -17
  27. data/docs/internals/web-push.md +1 -1
  28. data/docs/memory.md +2 -2
  29. data/docs/plugins.md +52 -43
  30. data/docs/releasing.md +1 -1
  31. data/docs/sessions.md +25 -25
  32. data/docs/sub-agent.md +2 -2
  33. data/docs/system-one.md +1 -1
  34. data/docs/testing.md +18 -8
  35. data/lib/samagotchi/answer_text.rb +34 -0
  36. data/lib/samagotchi/bridge.rb +43 -32
  37. data/lib/samagotchi/bridge_client.rb +10 -9
  38. data/lib/samagotchi/broadcast/recipients.rb +2 -2
  39. data/lib/samagotchi/broadcast/triage.rb +1 -1
  40. data/lib/samagotchi/broadcast_command.rb +5 -5
  41. data/lib/samagotchi/bundle_command/try/command.rb +1 -1
  42. data/lib/samagotchi/bundle_command/try/run.rb +1 -1
  43. data/lib/samagotchi/bundle_command.rb +2 -2
  44. data/lib/samagotchi/bundles/decision-card/manifest.yml +4 -4
  45. data/lib/samagotchi/bundles/decision-card/plugin.rb +1 -9
  46. data/lib/samagotchi/bundles/dev/manifest.yml +2 -2
  47. data/lib/samagotchi/bundles/system/config_modification_protocol.md +3 -4
  48. data/lib/samagotchi/bundles/system/manifest.yml +3 -3
  49. data/lib/samagotchi/bundles/system/self_map.md +1 -1
  50. data/lib/samagotchi/bundles/web-search/manifest.yml +13 -0
  51. data/lib/samagotchi/bundles/web-search/plugin.rb +174 -0
  52. data/lib/samagotchi/child_move.rb +3 -3
  53. data/lib/samagotchi/commands/registry.rb +6 -6
  54. data/lib/samagotchi/composer_route.rb +4 -5
  55. data/lib/samagotchi/config.rb +14 -12
  56. data/lib/samagotchi/context_candidate_choice.rb +1 -1
  57. data/lib/samagotchi/context_candidates.rb +4 -3
  58. data/lib/samagotchi/continue_offer.rb +1 -1
  59. data/lib/samagotchi/debug_log.rb +1 -1
  60. data/lib/samagotchi/degrade.rb +75 -0
  61. data/lib/samagotchi/desktop/macos/ChiRunner.swift +2 -2
  62. data/lib/samagotchi/engine/cards.rb +87 -0
  63. data/lib/samagotchi/engine/context_judge.rb +138 -0
  64. data/lib/samagotchi/engine/events.rb +131 -0
  65. data/lib/samagotchi/engine/guardrails.rb +87 -0
  66. data/lib/samagotchi/engine/hooks.rb +119 -0
  67. data/lib/samagotchi/engine/messages.rb +124 -0
  68. data/lib/samagotchi/engine/model_settings.rb +127 -0
  69. data/lib/samagotchi/engine/models.rb +261 -0
  70. data/lib/samagotchi/engine/objectives.rb +116 -0
  71. data/lib/samagotchi/engine/participants.rb +86 -0
  72. data/lib/samagotchi/engine/plugins.rb +208 -0
  73. data/lib/samagotchi/engine/questions.rb +98 -0
  74. data/lib/samagotchi/engine/recap.rb +130 -0
  75. data/lib/samagotchi/engine/session.rb +131 -0
  76. data/lib/samagotchi/engine/side_requests.rb +135 -0
  77. data/lib/samagotchi/engine/steering.rb +134 -0
  78. data/lib/samagotchi/engine/toolkit.rb +269 -0
  79. data/lib/samagotchi/engine/turn_end.rb +180 -0
  80. data/lib/samagotchi/engine/turn_helpers.rb +160 -0
  81. data/lib/samagotchi/engine/verify.rb +153 -0
  82. data/lib/samagotchi/engine.rb +63 -2642
  83. data/lib/samagotchi/grants.rb +575 -0
  84. data/lib/samagotchi/guardrail_wiring.rb +2 -2
  85. data/lib/samagotchi/guardrails/context.rb +2 -3
  86. data/lib/samagotchi/guardrails/parent_approvals.rb +2 -2
  87. data/lib/samagotchi/guardrails/parent_policy.rb +2 -2
  88. data/lib/samagotchi/guardrails/review_boundary.rb +4 -3
  89. data/lib/samagotchi/hook_routes.rb +3 -3
  90. data/lib/samagotchi/idle_recap.rb +4 -5
  91. data/lib/samagotchi/idle_reminders.rb +3 -4
  92. data/lib/samagotchi/iteration_limit.rb +2 -2
  93. data/lib/samagotchi/kernel_loop.rb +10 -11
  94. data/lib/samagotchi/launch_mode.rb +13 -32
  95. data/lib/samagotchi/level_setting_command.rb +3 -2
  96. data/lib/samagotchi/llm_context_command.rb +3 -2
  97. data/lib/samagotchi/llm_context_stale.rb +2 -2
  98. data/lib/samagotchi/log.rb +7 -7
  99. data/lib/samagotchi/log_line.rb +1 -1
  100. data/lib/samagotchi/log_path.rb +1 -1
  101. data/lib/samagotchi/message_parts.rb +3 -2
  102. data/lib/samagotchi/note_command.rb +1 -1
  103. data/lib/samagotchi/note_delivery.rb +18 -8
  104. data/lib/samagotchi/objectives.rb +24 -3
  105. data/lib/samagotchi/objectives_check.rb +8 -27
  106. data/lib/samagotchi/objectives_command.rb +4 -69
  107. data/lib/samagotchi/objectives_edit.rb +87 -0
  108. data/lib/samagotchi/output_formatter.rb +7 -1
  109. data/lib/samagotchi/owner_lock.rb +7 -1
  110. data/lib/samagotchi/parent_answers_command.rb +5 -4
  111. data/lib/samagotchi/participant_exposure.rb +42 -0
  112. data/lib/samagotchi/participant_mode.rb +1 -1
  113. data/lib/samagotchi/participant_name.rb +1 -1
  114. data/lib/samagotchi/participants.rb +17 -17
  115. data/lib/samagotchi/participants_watch.rb +1 -1
  116. data/lib/samagotchi/pending_input_queue.rb +2 -2
  117. data/lib/samagotchi/plugin/api.rb +3 -2
  118. data/lib/samagotchi/plugin/context.rb +21 -7
  119. data/lib/samagotchi/plugin/peer.rb +2 -1
  120. data/lib/samagotchi/plugin/sessions.rb +6 -4
  121. data/lib/samagotchi/plugin_tasks.rb +1 -2
  122. data/lib/samagotchi/private_files.rb +29 -0
  123. data/lib/samagotchi/project_env.rb +2 -2
  124. data/lib/samagotchi/question_desk.rb +8 -7
  125. data/lib/samagotchi/recap_command.rb +2 -1
  126. data/lib/samagotchi/recap_setup.rb +1 -1
  127. data/lib/samagotchi/recap_store.rb +1 -1
  128. data/lib/samagotchi/redact.rb +86 -0
  129. data/lib/samagotchi/reminder_queue.rb +2 -2
  130. data/lib/samagotchi/remote_attach_command.rb +1 -1
  131. data/lib/samagotchi/reply_wait/poll.rb +2 -2
  132. data/lib/samagotchi/review_start.rb +1 -1
  133. data/lib/samagotchi/run_options.rb +6 -6
  134. data/lib/samagotchi/self_report.rb +42 -39
  135. data/lib/samagotchi/send_command/busy.rb +1 -1
  136. data/lib/samagotchi/send_command.rb +4 -1
  137. data/lib/samagotchi/session.rb +11 -2
  138. data/lib/samagotchi/session_access/contract.rb +3 -3
  139. data/lib/samagotchi/session_access/local.rb +9 -9
  140. data/lib/samagotchi/session_access/remote.rb +17 -7
  141. data/lib/samagotchi/session_access.rb +4 -4
  142. data/lib/samagotchi/session_archive_command.rb +2 -2
  143. data/lib/samagotchi/session_chain.rb +8 -6
  144. data/lib/samagotchi/session_commands.rb +45 -48
  145. data/lib/samagotchi/session_continue.rb +1 -1
  146. data/lib/samagotchi/session_delete_command.rb +4 -4
  147. data/lib/samagotchi/session_manager.rb +59 -36
  148. data/lib/samagotchi/session_metrics.rb +2 -2
  149. data/lib/samagotchi/session_model_settings.rb +3 -2
  150. data/lib/samagotchi/session_participants.rb +1 -1
  151. data/lib/samagotchi/session_retention.rb +3 -3
  152. data/lib/samagotchi/sessions_command.rb +4 -4
  153. data/lib/samagotchi/share_command.rb +10 -10
  154. data/lib/samagotchi/steer.rb +1 -1
  155. data/lib/samagotchi/system_prompt.rb +102 -109
  156. data/lib/samagotchi/task_watch.rb +1 -1
  157. data/lib/samagotchi/terminal_ui/attach_launcher.rb +72 -10
  158. data/lib/samagotchi/terminal_ui/attached_loop/composer.rb +12 -9
  159. data/lib/samagotchi/terminal_ui/attached_loop/exit_commands.rb +16 -0
  160. data/lib/samagotchi/terminal_ui/attached_loop/questions.rb +2 -3
  161. data/lib/samagotchi/terminal_ui/attached_loop.rb +17 -8
  162. data/lib/samagotchi/terminal_ui/attached_view.rb +2 -3
  163. data/lib/samagotchi/terminal_ui/event_renderer.rb +3 -5
  164. data/lib/samagotchi/terminal_ui/formatting.rb +7 -7
  165. data/lib/samagotchi/terminal_ui/input_support.rb +1 -7
  166. data/lib/samagotchi/terminal_ui/line_reader.rb +2 -2
  167. data/lib/samagotchi/terminal_ui/live_region.rb +2 -2
  168. data/lib/samagotchi/terminal_ui/log_tail.rb +135 -0
  169. data/lib/samagotchi/terminal_ui/one_shot_sink.rb +2 -2
  170. data/lib/samagotchi/terminal_ui/plain_surface.rb +1 -1
  171. data/lib/samagotchi/terminal_ui/question_prompt.rb +4 -5
  172. data/lib/samagotchi/terminal_ui/reline_seam.rb +3 -3
  173. data/lib/samagotchi/terminal_ui/status_row.rb +7 -8
  174. data/lib/samagotchi/terminal_ui.rb +142 -1387
  175. data/lib/samagotchi/title_command.rb +2 -2
  176. data/lib/samagotchi/tool_runner.rb +14 -16
  177. data/lib/samagotchi/toolkit.rb +8 -5
  178. data/lib/samagotchi/toolkit_binding.rb +30 -5
  179. data/lib/samagotchi/toolkit_command.rb +2 -1
  180. data/lib/samagotchi/toolkit_report_command.rb +9 -5
  181. data/lib/samagotchi/toolkit_suggest.rb +12 -6
  182. data/lib/samagotchi/tools/builtin_calls.rb +14 -10
  183. data/lib/samagotchi/tools/deferred_tools.rb +2 -2
  184. data/lib/samagotchi/tools/delegate.rb +3 -1
  185. data/lib/samagotchi/tools/delegate_cursor.rb +3 -3
  186. data/lib/samagotchi/tools/delegate_wait.rb +1 -1
  187. data/lib/samagotchi/tools/objectives_tool.rb +2 -2
  188. data/lib/samagotchi/tools/registry.rb +44 -20
  189. data/lib/samagotchi/tools/send_note.rb +2 -1
  190. data/lib/samagotchi/tools/tool_index.rb +27 -21
  191. data/lib/samagotchi/turn_endings.rb +2 -2
  192. data/lib/samagotchi/turn_flow.rb +4 -5
  193. data/lib/samagotchi/update_hint.rb +2 -2
  194. data/lib/samagotchi/version.rb +1 -1
  195. data/lib/samagotchi/web/app.rb +39 -50
  196. data/lib/samagotchi/web/participant_access.rb +2 -2
  197. data/lib/samagotchi/web/participant_review.rb +5 -5
  198. data/lib/samagotchi/web/participant_stream_filter.rb +8 -8
  199. data/lib/samagotchi/web/participant_view.rb +2 -82
  200. data/lib/samagotchi/web/participants_admin.rb +1 -1
  201. data/lib/samagotchi/web/public/app.js +61 -357
  202. data/lib/samagotchi/web/public/block_host.js +35 -8
  203. data/lib/samagotchi/web/public/block_renderers.css +9 -5
  204. data/lib/samagotchi/web/public/chains.js +2 -2
  205. data/lib/samagotchi/web/public/claim_link.js +4 -4
  206. data/lib/samagotchi/web/public/composer_mode.js +110 -0
  207. data/lib/samagotchi/web/public/format.js +3 -3
  208. data/lib/samagotchi/web/public/owner_claim.js +81 -0
  209. data/lib/samagotchi/web/public/owner_verify.js +112 -0
  210. data/lib/samagotchi/web/public/session_fields.js +3 -3
  211. data/lib/samagotchi/web/public/strip_grip.js +3 -3
  212. data/lib/samagotchi/web/public/strip_hidden.js +65 -0
  213. data/lib/samagotchi/web/public/version_notices.js +132 -0
  214. data/lib/samagotchi/web/push/store.rb +1 -1
  215. data/lib/samagotchi/web/session_access_routes.rb +5 -4
  216. data/lib/samagotchi/web/session_control.rb +12 -8
  217. data/lib/samagotchi/web/session_create.rb +1 -1
  218. data/lib/samagotchi/web/session_hub.rb +2 -2
  219. data/lib/samagotchi/web/session_show.rb +2 -2
  220. data/lib/samagotchi/web/session_summary.rb +1 -1
  221. data/lib/samagotchi/worker.rb +106 -9
  222. data/lib/samagotchi/worker_link.rb +3 -3
  223. data/lib/samagotchi/working_set_command.rb +3 -2
  224. metadata +36 -6
  225. data/lib/samagotchi/terminal_ui/between_turns.rb +0 -138
  226. data/lib/samagotchi/terminal_ui/repl_input.rb +0 -152
  227. data/lib/samagotchi/web/grants.rb +0 -575
  228. data/lib/samagotchi/web/participant_exposure.rb +0 -44
  229. data/lib/samagotchi/web/private_files.rb +0 -31
@@ -12,7 +12,7 @@ require_relative "../bridge_client"
12
12
  require_relative "../session_continue"
13
13
  require_relative "../guardrails/parent_policy"
14
14
  require_relative "../model_profile"
15
- require_relative "../web/grants"
15
+ require_relative "../grants"
16
16
  require_relative "http_transport"
17
17
  require_relative "../chi_invocation"
18
18
 
@@ -59,7 +59,7 @@ module Samagotchi
59
59
  def remote? = true
60
60
  def name = @entry.name
61
61
 
62
- # Web::Grants::PREFIX, without loading chi web.
62
+ # Grants::PREFIX, without loading chi web.
63
63
  PARTICIPANT_PREFIX = "chp_"
64
64
 
65
65
  # Whether the token is a participant's (chi share's link), not the
@@ -229,7 +229,12 @@ module Samagotchi
229
229
  SessionManager::DeliverResult.new(status: :accepted, via: via, ack: ack.except("via"))
230
230
  when 504 then SessionManager::DeliverResult.new(status: :timeout, ack: { "detail" => ack["detail"] })
231
231
  when 409
232
- raise SessionManager::OwnedByTUI, ack["session_id"] || id if ack["error"] == "owned_by_tui"
232
+ if ack["error"] == "owned_by_tui"
233
+ # A chi scratch session (scratch: true; an older chi web sends none).
234
+ raise SessionManager::ScratchSession, ack["session_id"] || id if ack["scratch"] == true
235
+
236
+ raise SessionManager::OwnedByTUI, ack["session_id"] || id
237
+ end
233
238
 
234
239
  SessionManager::DeliverResult.new(status: :refused, code: 409, ack: ack)
235
240
  when 404 then raise_refusal(reply)
@@ -316,7 +321,9 @@ module Samagotchi
316
321
 
317
322
  NoteDelivery::Result.new(id: id, status: json["status"].to_sym, queued: json["queued"].to_i)
318
323
  when 409
319
- return NoteDelivery::Result.new(id: id, status: :refused, queued: 0) if json["error"] == "owned_by_tui"
324
+ if json["error"] == "owned_by_tui"
325
+ return NoteDelivery::Result.new(id: id, status: :refused, queued: 0, scratch: json["scratch"] == true)
326
+ end
320
327
 
321
328
  raise_refusal(reply)
322
329
  when 400
@@ -584,7 +591,7 @@ module Samagotchi
584
591
  # when none runs, waited for up to +wait+ seconds there (a leaving
585
592
  # worker first: +read_timeout+ covers that wait too).
586
593
  # @return [WakeReply]
587
- # @raise [SessionManager::OwnedByTUI] a chi REPL has it there
594
+ # @raise [SessionManager::OwnedByTUI] a one-turn chi run has it there
588
595
  # @raise [Refused] a leftover scratch session (code scratch), or its
589
596
  # Bridge didn't come in time (code worker_timeout): chi web's words,
590
597
  # LaunchMode's constants
@@ -709,7 +716,10 @@ module Samagotchi
709
716
  case error
710
717
  when "not_found" then raise ArgumentError, message.to_s
711
718
  when "ambiguous_id" then raise Session::AmbiguousId.new(message, ids: Array(json["ids"]))
712
- when "owned_by_tui" then raise SessionManager::OwnedByTUI, sid
719
+ when "owned_by_tui"
720
+ raise SessionManager::ScratchSession, sid if json["scratch"] == true
721
+
722
+ raise SessionManager::OwnedByTUI, sid
713
723
  when "worker_running", "still_stopping" then raise SessionManager::DeleteRefused.new(sid, reason)
714
724
  when "busy", "scratch"
715
725
  raise SessionManager::ArchiveRefused.new(sid, reason, busy_id: json["busy_id"]) if reason
@@ -728,7 +738,7 @@ module Samagotchi
728
738
  # A toolkit whose config kit's groups: is invalid there: the same.
729
739
  when "toolkit_refused" then raise Toolkit::Refused, message.to_s
730
740
  # The grants store doesn't read there: chi share says what it says here.
731
- when "grants_unwritable" then raise Web::Grants::Unwritable, message.to_s
741
+ when "grants_unwritable" then raise Grants::Unwritable, message.to_s
732
742
  when /\Ainvalid_/, "missing_fields", "bad_images"
733
743
  # invalid_participant: chi share's name, kind or ttl, an archived session
734
744
  raise BadRequest.new(message.to_s, code: error)
@@ -98,7 +98,7 @@ module Samagotchi
98
98
  class BadRequest < Refused; end
99
99
 
100
100
  # One participant's grant as chi share ls shows it
101
- # (Web::Grants::Grant#public_h): never its token or digest. +state+
101
+ # (Grants::Grant#public_h): never its token or digest. +state+
102
102
  # "active", "revoked" or "expired".
103
103
  # +cap+ dollars and +queue+ turns (nil from a chi web before them).
104
104
  # +identity+ the email the grant is bound to, nil for any.
@@ -163,7 +163,7 @@ module Samagotchi
163
163
  # A session as a session command reads it (Local: the Session itself;
164
164
  # Remote: chi web's brief read). The fields the commands use, and
165
165
  # Session's #display_title.
166
- # @!attribute owner [OwnerLock::Owner, nil] its worker or chi REPL
166
+ # @!attribute owner [OwnerLock::Owner, nil] its worker or one-turn chi run
167
167
  # @!attribute pending_question [PendingQuestion, nil] the file's
168
168
  SessionView = Data.define(:id, :status, :title, :display_title, :project_root, :working_directory, :model_name,
169
169
  :model_typed, :parent_answers, :owner, :pending_question)
@@ -189,7 +189,7 @@ module Samagotchi
189
189
  # features, epoch; nil: none answers)
190
190
  def sidecar = link.live? ? link.sidecar : nil
191
191
 
192
- # @return [OwnerLock::Owner, nil] a worker, a chi REPL, nil
192
+ # @return [OwnerLock::Owner, nil] a worker, a one-turn chi run, nil
193
193
  def owner = link.owner
194
194
 
195
195
  def live? = link.live?
@@ -238,7 +238,7 @@ module Samagotchi
238
238
  # @!attribute known_turn [Boolean, nil] with a turn asked for
239
239
  # (--turn), ReplyWait.known_turn?; nil without one
240
240
  # @!attribute running [Boolean] the session's status was running
241
- # @!attribute live [Boolean] running, or a worker or a REPL owns it
241
+ # @!attribute live [Boolean] running, or a worker or a one-shot owns it
242
242
  # @!attribute settled [Boolean] idle, a turn ended and no question
243
243
  # stands (a continue offer waits for its answer)
244
244
  # @!attribute last_turn_id [String, nil] the last ended turn's
@@ -18,7 +18,7 @@ module Samagotchi
18
18
  Usage: chi sessions archive (ID|PREFIX)...
19
19
  Hides each session (and its delegates) from every list and keeps it
20
20
  for good: the retention sweep never deletes it. A live worker is
21
- stopped first; a session running a turn, or open in a chi REPL, is
21
+ stopped first; a session running a turn, or in a one-turn chi run, is
22
22
  refused. chi sessions list --archived shows them; a message you send
23
23
  to one brings it back.
24
24
  TEXT
@@ -76,7 +76,7 @@ module Samagotchi
76
76
  true
77
77
  rescue SessionManager::OwnedByTUI => e
78
78
  where = e.session_id == id ? "it is" : "a delegate of it is"
79
- @stdout.puts("#{short} refused: #{where} open in a chi REPL; close it there first")
79
+ @stdout.puts("#{short} refused: #{where} #{OwnerLock::TUI_BUSY}; let it finish first")
80
80
  false
81
81
  rescue SessionManager::ArchiveRefused => e
82
82
  @stdout.puts("#{short} refused: #{e.message}")
@@ -2,9 +2,9 @@
2
2
 
3
3
  require "time"
4
4
  require_relative "answer_tail"
5
+ require_relative "answer_text"
5
6
  require_relative "config"
6
7
  require_relative "log"
7
- require_relative "output_formatter"
8
8
  require_relative "session"
9
9
  require_relative "session_inbox"
10
10
  require_relative "steer"
@@ -109,10 +109,11 @@ module Samagotchi
109
109
  nil
110
110
  end
111
111
 
112
- # The session's last word: the text of AnswerTail's newest bubble, with
113
- # the wire-format markup stripped, unless that bubble is a step of a
114
- # turn (it asked for tools) or old text a later turn never replaced
115
- # (one that failed or was cut before it answered). A cut reply keeps
112
+ # The session's last word: the text of AnswerTail's newest bubble as the
113
+ # user saw it (AnswerText.visible: no thought, a cut-off one neither),
114
+ # unless that bubble is a step of a turn (it asked for tools), old text a
115
+ # later turn never replaced (one that failed or was cut before it
116
+ # answered) or a thought alone. A cut reply keeps
116
117
  # its "[interrupted]": that is what happened.
117
118
  # @param messages [Array<Hash>]
118
119
  # @return [String, nil]
@@ -123,7 +124,8 @@ module Samagotchi
123
124
  at = messages.rindex { |m| m.equal?(found) }
124
125
  return nil if messages[(at + 1)..].any? { |m| m[:role].to_s == "tool_response" || later_turn?(m) }
125
126
 
126
- OutputFormatter.strip_markup(found[:content].scrub)
127
+ text = AnswerText.visible(found[:content])
128
+ text.empty? ? nil : text
127
129
  end
128
130
 
129
131
  # A turn's opening, not a `!shell` line: a command the user ran after
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "config"
4
+ require_relative "degrade"
4
5
  require_relative "model_profile"
5
6
  require_relative "served_model"
6
7
  require_relative "prompt_note"
@@ -31,7 +32,7 @@ module Samagotchi
31
32
  # load budget (spec/samagotchi/testing/sandbox_spec.rb).
32
33
  autoload :ObjectivesCommand, File.expand_path("objectives_command", __dir__)
33
34
 
34
- # The session commands a REPL and a session worker both run: /model,
35
+ # The session commands a session worker runs (a -p one-shot too): /model,
35
36
  # /models, /llm-context, /guardrails, /context, !rollback, !cmd and the answer to a continue offer. They act on
36
37
  # the Engine and its TurnFlow; the host prints the result's output and
37
38
  # runs a continue turn when asked to (#run never runs a turn).
@@ -159,24 +160,24 @@ module Samagotchi
159
160
  registry.register("/quit", "leave, like /exit", id: :exit, local: true, match: exit_match("/quit"))
160
161
  registry.register("/archive", "leave and archive the session: hidden from the lists, kept for good", local: true,
161
162
  match: ->(text) { text.casecmp?("/archive") })
162
- # The REPL owns its session: it answers /detach with a note, and doesn't offer it.
163
+ # The terminal's: the web has no worker to leave running.
163
164
  registry.register("/detach", "leave and keep the worker running", local: true, uis: [:attached],
164
165
  match: ->(text) { text.casecmp?("/detach") })
165
166
  # /cut TEXT and /queue TEXT while a turn runs: a delivery the UIs parse
166
167
  # themselves (ComposerRoute.steer_choice) before any command check; the
167
168
  # entries exist for Tab completion and /help. The web composer sends
168
- # /cut too (its queue is Alt+Enter). Both terminals queue: the REPL in
169
- # its own list, the attached TUI's worker in its input files.
169
+ # /cut too (its queue is Alt+Enter). The attached TUI queues in its
170
+ # worker's input files.
170
171
  registry.register("/cut", "cut the running generation and go in now (/cut TEXT)",
171
- local: true, uis: %i[repl attached web], match: ->(text) { text.casecmp?("/cut") || text.match?(%r{\A/cut\s+\S}) })
172
+ local: true, uis: %i[attached web], match: ->(text) { text.casecmp?("/cut") || text.match?(%r{\A/cut\s+\S}) })
172
173
  registry.register("/queue", "run as the next turn after this one (/queue TEXT)",
173
- local: true, uis: %i[repl attached], match: ->(text) { text.casecmp?("/queue") || text.match?(%r{\A/queue\s+\S}) })
174
+ local: true, uis: [:attached], match: ->(text) { text.casecmp?("/queue") || text.match?(%r{\A/queue\s+\S}) })
174
175
  # Takes back the newest message this attached TUI queued, before it
175
176
  # runs (the worker's POST turn/dequeue); the web has × on the bubble.
176
177
  registry.register("/unqueue", "remove your newest queued message before it runs", local: true, uis: [:attached],
177
178
  match: ->(text) { text.casecmp?("/unqueue") })
178
179
  # chi share for the attached session (TerminalUI::ParticipantCommands):
179
- # a participant's turns run on its worker, so the REPL points there.
180
+ # a participant's turns run on its worker.
180
181
  registry.register("/share", "invite someone to review this session (/share NAME [--agent] [--expires 3d] [--cap 2])",
181
182
  local: true, uis: [:attached], match: ->(text) { text.match?(%r{\A/share(?:\s.*)?\z}i) })
182
183
  registry.register("/participants", "list this session's participants (revoke [P], cap P DOLLARS)",
@@ -286,15 +287,15 @@ module Samagotchi
286
287
  "commands:\n#{lines.join("\n")}"
287
288
  end
288
289
 
289
- # Where a UI's own entry runs, for /help: both terminals are
290
- # "terminal" (also for no uis: every terminal), then "web".
290
+ # Where a UI's own entry runs, for /help: the attached TUI is
291
+ # "terminal" (also for no uis: the terminal), then "web". An older
292
+ # worker's :repl is no UI any more.
291
293
  # @param uis [Array<Symbol>, nil]
292
- # @return [String] "terminal", "repl", "terminal and web" …
294
+ # @return [String] "terminal", "web", "terminal and web"
293
295
  def ui_words(uis)
294
296
  return "terminal" if uis.nil?
295
297
 
296
- terminals = uis & %i[repl attached]
297
- words = terminals.size == 2 ? ["terminal"] : terminals.map(&:to_s)
298
+ words = uis.include?(:attached) ? ["terminal"] : []
298
299
  words << "web" if uis.include?(:web)
299
300
  words.join(" and ")
300
301
  end
@@ -309,8 +310,8 @@ module Samagotchi
309
310
  # @param as_parent [Boolean] a parent agent sent it (client cli:answer):
310
311
  # a /continue yes is held to guardrails.parent_answers, as chi
311
312
  # answer --option Continue is (Guardrails::ParentPolicy)
312
- # @param user_ui [Boolean] a user at a UI sent it (the REPL, an attached
313
- # TUI, the web; not a piped or marked one): only such a line loosens
313
+ # @param user_ui [Boolean] a user at a UI sent it (an attached TUI, the
314
+ # web; not a piped or marked one): only such a line loosens
314
315
  # /parent-answers
315
316
  # @return [Result, nil] nil when +line+ is not one of these commands
316
317
  def run(line, confirm: true, as_parent: false, user_ui: false)
@@ -427,7 +428,7 @@ module Samagotchi
427
428
  end
428
429
 
429
430
  # The output goes into the conversation for the next turn. Not saved
430
- # here (as in the REPL, the next turn saves it). No parent-session
431
+ # here (the next turn saves it). No parent-session
431
432
  # marker: the user runs this, not the model. A worker's `chi --model X`
432
433
  # default is unset, though: it was that run's, not the default of a chi
433
434
  # run here (Tools::Builtins.cli_model_unset). It runs in the session's
@@ -726,7 +727,7 @@ module Samagotchi
726
727
 
727
728
  # /recap on|off|default (RecapCommand); :recap in changed when this
728
729
  # session's own recap.enabled moved. It doesn't save: its host does on
729
- # a changed result (Worker#run_command, the REPL).
730
+ # a changed result (Worker#run_command).
730
731
  def recap_setting(text)
731
732
  output, changed, status = RecapCommand.new(engine: @engine).run(text.delete_prefix(RECAP_COMMAND))
732
733
  reply(output, status: status, changed: changed ? [:recap] : [])
@@ -884,65 +885,61 @@ module Samagotchi
884
885
  return unless session
885
886
 
886
887
  @engine.store_model!(session)
887
- begin
888
- @save.call(session)
889
- rescue StandardError
890
- nil
891
- end
888
+ Degrade.call(:model, "model_switch_save_failed") { @save.call(session) }
892
889
  end
893
890
 
894
891
  # "; served: <name>" when the server serves another model than asked
895
892
  # and its host's served: doesn't expect it.
896
893
  def served_note
897
- served, asked = @engine.served_model
898
- ServedModel.differs?(asked, served) && !@engine.served_expected?(served) ? "; served: #{served}" : ""
899
- rescue StandardError
900
- ""
894
+ Degrade.call(:model, "served_note_failed", fallback: "") do
895
+ served, asked = @engine.served_model
896
+ ServedModel.differs?(asked, served) && !@engine.served_expected?(served) ? "; served: #{served}" : ""
897
+ end
901
898
  end
902
899
 
903
900
  # "; sampling: temperature=0.6 (hosts.work)" when the model has any.
904
901
  def sampling_note
905
- summary = @engine.sampling_summary
906
- summary ? "; sampling: #{summary}" : ""
907
- rescue StandardError
908
- ""
902
+ Degrade.call(:model, "sampling_note_failed", fallback: "") do
903
+ summary = @engine.sampling_summary
904
+ summary ? "; sampling: #{summary}" : ""
905
+ end
909
906
  end
910
907
 
911
908
  # "; steer cut: after 60 s (models: qwen)", or "; steer cut: never
912
909
  # (hosts.splash)" for 0 or less, when the model has one configured;
913
910
  # "" for the default.
914
911
  def steer_cut_after_note
915
- summary = @engine.steer_cut_summary
916
- summary ? "; steer cut: #{summary}" : ""
917
- rescue StandardError
918
- ""
912
+ Degrade.call(:model, "steer_cut_note_failed", fallback: "") do
913
+ summary = @engine.steer_cut_summary
914
+ summary ? "; steer cut: #{summary}" : ""
915
+ end
919
916
  end
920
917
 
921
918
  # "; notes: model_notes_deepseek (system, 612 chars)": the model notes
922
919
  # the session's prompt carries (after a switch, the ones the new
923
920
  # model's prompt loads); "" without any.
924
921
  def notes_note
925
- text = PromptNote.text(@engine.prompt_notes)
926
- text.empty? ? "" : "; notes: #{text}"
927
- rescue StandardError
928
- ""
922
+ Degrade.call(:model, "notes_note_failed", fallback: "") do
923
+ text = PromptNote.text(@engine.prompt_notes)
924
+ text.empty? ? "" : "; notes: #{text}"
925
+ end
929
926
  end
930
927
 
931
928
  # "; thinking: off (models: qwen)" when the model has a level set.
932
929
  def thinking_note
933
- summary = @engine.thinking_summary
934
- summary ? "; thinking: #{summary}" : ""
935
- rescue StandardError
936
- ""
930
+ Degrade.call(:model, "thinking_note_failed", fallback: "") do
931
+ summary = @engine.thinking_summary
932
+ summary ? "; thinking: #{summary}" : ""
933
+ end
937
934
  end
938
935
 
939
936
  # "; reasoning history: drop (models: qwen)" when the chat model has a
940
937
  # level set.
941
938
  def reasoning_history_note
942
- summary = @engine.reasoning_history_summary
943
- summary ? "; reasoning history: #{summary}" : ""
944
- rescue StandardError
945
- ""
939
+ Degrade.call(:model, "reasoning_history_note_failed", fallback: "") do
940
+ summary = @engine.reasoning_history_summary
941
+ summary ? "; reasoning history: #{summary}" : ""
942
+ end
946
943
  end
947
944
 
948
945
  # " (default: x, profile=qwen36, name)": +extra+ and the prompt profile,
@@ -954,9 +951,9 @@ module Samagotchi
954
951
  end
955
952
 
956
953
  def chat_model?
957
- @engine.chat_model?
958
- rescue StandardError
959
- false
954
+ Degrade.call(:model, "chat_model_unresolved", fallback: false) do
955
+ @engine.chat_model?
956
+ end
960
957
  end
961
958
 
962
959
  # "profile=qwen36, server (chat_template)": the profile and where it came from.
@@ -24,7 +24,7 @@ module Samagotchi
24
24
  # A continue that can't happen (#run). reason: :continued
25
25
  # (another session continues it already: +ids+ names it), :open_children
26
26
  # (open children it can't move to the new link: on an older chi's
27
- # worker or open in a chi REPL; +ids+ names them) or :folder_gone (its
27
+ # worker or open in a one-turn chi run; +ids+ names them) or :folder_gone (its
28
28
  # folder isn't there).
29
29
  class Refused < StandardError
30
30
  attr_reader :session_id, :reason, :ids
@@ -9,7 +9,7 @@ require_relative "session_access/local"
9
9
  module Samagotchi
10
10
  # `chi sessions delete`: remove sessions for good (SessionManager.delete_session),
11
11
  # one line per id. A live worker's session is refused unless --force stops
12
- # the worker first; a chi REPL's never is.
12
+ # the worker first; a one-turn chi run's never is.
13
13
  class SessionDeleteCommand
14
14
  include CLI::Command
15
15
 
@@ -18,8 +18,8 @@ module Samagotchi
18
18
  Deletes each session: its history, notes, images and queued input.
19
19
  This can't be undone.
20
20
  -f, --force stop a session's running worker first (without it,
21
- a live session is refused); a session open in a
22
- chi REPL is always refused
21
+ a live session is refused); a session in a one-turn
22
+ chi run (chi --non-interactive) is always refused
23
23
  TEXT
24
24
  PREVIEW_LIMIT = 60
25
25
  FLAGS = CLI::Flags.new(help: CLI::Command::HELP_WORDS) { |f| f.switch "-f", "--force" }
@@ -67,7 +67,7 @@ module Samagotchi
67
67
  @stdout.puts(["#{short} deleted#{" (stopped its worker)" if result[:stopped]}", preview].compact.join(" "))
68
68
  true
69
69
  rescue SessionManager::OwnedByTUI
70
- @stdout.puts("#{short} refused: it is open in a chi REPL; close it there first")
70
+ @stdout.puts("#{short} refused: it is #{OwnerLock::TUI_BUSY}; let it finish first")
71
71
  false
72
72
  rescue SessionManager::DeleteRefused => e
73
73
  line = if e.reason == :still_stopping then "its worker is still shutting down; try again in a moment"
@@ -81,23 +81,32 @@ module Samagotchi
81
81
  autoload :SessionRetention, File.expand_path("session_retention", __dir__)
82
82
  # session_continue requires this file.
83
83
  autoload :SessionContinue, File.expand_path("session_continue", __dir__)
84
- # Needed only on an archive or a delete (it loads Web::Grants).
84
+ # Needed only on an archive or a delete (it loads Grants).
85
85
  autoload :Participants, File.expand_path("participants", __dir__)
86
86
 
87
87
  class SessionManager
88
- # Raised when the interactive TUI owns the session: it runs its own Engine
89
- # and reads no input files, so a worker must not be spawned or signalled.
88
+ # Raised when a terminal's own chi owns the session (OwnerLock kind
89
+ # "tui": a -p --non-interactive one-shot, TerminalUI): it runs its own
90
+ # Engine and reads no input files, so a worker must not be spawned or
91
+ # signalled.
90
92
  class OwnedByTUI < StandardError
91
- # @return [String] the session the REPL owns (a delegate's, when an
92
- # archive met one)
93
+ # @return [String] the session it owns (a delegate's, when an archive
94
+ # met one)
93
95
  attr_reader :session_id
94
96
 
95
- def initialize(session_id)
97
+ def initialize(session_id, words = nil)
96
98
  @session_id = session_id
97
- super("session #{session_id} is open in a chi REPL; close it there first")
99
+ super(words || "session #{session_id} is #{OwnerLock::TUI_BUSY}; let it finish first")
98
100
  end
99
101
  end
100
102
 
103
+ # A `chi scratch` session: its terminal's alone (Session::SCRATCH_REFUSED).
104
+ # An OwnedByTUI, so every caller that refuses a terminal's session
105
+ # (chi send, chi web's 409 owned_by_tui) refuses it the same way.
106
+ class ScratchSession < OwnedByTUI
107
+ def initialize(session_id) = super(session_id, Session::SCRATCH_REFUSED)
108
+ end
109
+
101
110
  # A delete that would pull the session from under its live worker.
102
111
  # reason: :worker_running (not asked to stop it) or :still_stopping
103
112
  # (stopped, but the worker outlived the wait).
@@ -173,12 +182,15 @@ module Samagotchi
173
182
  # requester, rules and claim (VerifyFresh.start)
174
183
  # @param before_spawn [#call, nil] (session) runs once the session and
175
184
  # its note are saved, before its worker spawns
185
+ # @param scratch [Boolean] a `chi scratch` session (its terminal's
186
+ # AttachLauncher): its worker runs a scratch Engine and deletes it
187
+ # once its last client is gone
176
188
  # @return [Session] with #seed_images_dropped: refs whose file was gone,
177
189
  # and #model_warning: an id its host's saved list doesn't have
178
190
  def self.spawn_session(prompt:, mode: "assist", working_directory: nil, model_name: nil, state_dir: nil,
179
191
  memories: [], muted_memories: [], parent_id: nil, messages: [], images_from: nil,
180
192
  preview: nil, title: nil, delegate: false, setup: SessionSetup.new, continues: nil, note: nil,
181
- note_source: "chi", before_spawn: nil, review: nil, verify_fresh: nil)
193
+ note_source: "chi", before_spawn: nil, review: nil, verify_fresh: nil, scratch: false)
182
194
  sd = state_dir || Session.default_state_dir
183
195
  # The resolved ref is stored (a resumed session keeps its model when an
184
196
  # alias is retargeted), with the name as typed beside it. A wrong host
@@ -201,7 +213,8 @@ module Samagotchi
201
213
  delegate: delegate,
202
214
  setup: setup,
203
215
  continues: continues,
204
- review: review
216
+ review: review,
217
+ scratch: scratch
205
218
  )
206
219
  session.toolkit = Toolkit.configured(delegate: delegate) if session.toolkit.nil?
207
220
  session.toolkit_origin = setup.toolkit_origin || "default"
@@ -316,7 +329,8 @@ module Samagotchi
316
329
  # +project_root+ unless asked for every project.
317
330
  # @param live [Boolean] only sessions a worker owns now (the owner lock,
318
331
  # not the saved status, which a dead worker leaves at "running"); a
319
- # REPL-owned session is left out: it can't take notes
332
+ # session a one-shot (or an older chi's REPL) owns is left out: it
333
+ # can't take notes
320
334
  # @param cwd [String, nil] only sessions in this folder or below it
321
335
  # @param project_root [String, nil] only this project's sessions
322
336
  # (Session#project_root)
@@ -405,7 +419,7 @@ module Samagotchi
405
419
  # stopped first; the marker is written even while it shuts down.
406
420
  # @return [ArchiveResult]
407
421
  # @raise [ArgumentError] unknown id (Session::AmbiguousId for a prefix of several)
408
- # @raise [OwnedByTUI] a chi REPL owns it or one of its children
422
+ # @raise [OwnedByTUI] a one-turn chi run owns it or one of its children
409
423
  # @raise [ArchiveRefused] a turn runs in it or in a child, a prompt is
410
424
  # queued in one (a later resume would run it), a live one offers to
411
425
  # continue past the step limit, or it is a scratch session
@@ -447,7 +461,7 @@ module Samagotchi
447
461
  # turn runs), :queued (input/ holds a prompt, which outlives the stop and
448
462
  # runs on a resume, live worker or not), :offer (a live worker's continue
449
463
  # offer, which the stop would drop), or nil.
450
- # @raise [OwnedByTUI] a chi REPL owns it
464
+ # @raise [OwnedByTUI] a one-turn chi run owns it
451
465
  def self.archive_refusal(sid, owner, state_dir)
452
466
  raise OwnedByTUI, sid if owner&.tui?
453
467
 
@@ -555,6 +569,8 @@ module Samagotchi
555
569
  def self.resume_session(session_id, state_dir: nil)
556
570
  sd = state_dir || Session.default_state_dir
557
571
  session = Session.load(session_id, state_dir: sd)
572
+ # Its terminal's worker came from spawn_session; nothing wakes another.
573
+ raise ScratchSession, session.id if session.scratch
558
574
  return session if refuse_tui!(session.id, state_dir: sd)
559
575
 
560
576
  # status is turn state, not liveness: a new worker runs no turn yet,
@@ -576,7 +592,7 @@ module Samagotchi
576
592
 
577
593
  # A delegate child rang a parent that has no worker (ChildRing): start
578
594
  # one, which runs a turn for the report. Not for a parent that is gone,
579
- # stopped, archived, a scratch session, open in a chi REPL, or a
595
+ # stopped, archived, a scratch session, a one-turn run's, or a
580
596
  # continue's new link still taking its delegates (its own spawn comes):
581
597
  # the ring waits on disk for whoever runs it next (#resume_session
582
598
  # itself would clear the stop). A stop landing between the check and
@@ -686,7 +702,8 @@ module Samagotchi
686
702
  reminders: "it has reminders, which its worker's older chi can't carry over a restart",
687
703
  command_running: "a /btw (or another command) is still running",
688
704
  relays_open: "a delegate's approval relayed here is open or just answered",
689
- background_tasks: "background tasks it started are still running"
705
+ background_tasks: "background tasks it started are still running",
706
+ scratch: "a chi scratch session's worker ends with its terminal"
690
707
  }.merge(WorkerIdleExit::LEAVING_WORDS).freeze
691
708
 
692
709
  # What the CLI and the web say.
@@ -726,7 +743,7 @@ module Samagotchi
726
743
  # be lost and starts its successor. If the worker cannot be asked, stop
727
744
  # it without a stop marker and resume only when disk shows no work to lose.
728
745
  # @return [Restarted]
729
- # @raise [OwnedByTUI] a chi REPL owns the session
746
+ # @raise [OwnedByTUI] a one-turn chi run owns the session
730
747
  # @raise [RestartRefused]
731
748
  # @raise [ArgumentError] no such session
732
749
  def self.restart_session(session_id, state_dir: nil, wait: RESTART_WAIT, client_id: ClientId::CLI_RESTART)
@@ -917,7 +934,7 @@ module Samagotchi
917
934
  EMPTY_SKELETON_DIRS = (EMPTY_DIRS + [SessionInbox::OUTPUT_DIR]).freeze
918
935
 
919
936
  # session.keep_empty off: sessions left with nothing in them are deleted
920
- # (the worker as it exits, the TUI at /exit, the retention sweep).
937
+ # (the worker as it exits, the retention sweep).
921
938
  def self.discard_empty?
922
939
  Samagotchi::Config.get("session.keep_empty") != true
923
940
  rescue StandardError
@@ -926,25 +943,14 @@ module Samagotchi
926
943
 
927
944
  # Whether a session being left (or found left) is deleted: session.keep_empty
928
945
  # is off and nothing happened in it (#empty_session?). The worker as it
929
- # leaves, the REPL at /exit and the retention sweep all ask this.
946
+ # leaves and the retention sweep both ask this.
930
947
  # @param default_model [String, nil] what a new session starts on
931
- # @param model_name [String, nil] the model its owner runs now (the REPL
932
- # keeps /model in its Engine); the saved one is checked either way
933
948
  # @param used_memory_names [Array<String>] memory its owner's Engine used
934
949
  # before any save
935
- # @param unsaved [Session, nil] the REPL's working copy, judged instead
936
- # when the session was never saved
937
- def self.discardable?(session_id, default_model:, state_dir: nil, model_name: default_model, used_memory_names: [],
938
- unsaved: nil)
939
- return false unless discard_empty? && same_model?(model_name, default_model) && Array(used_memory_names).empty?
940
-
941
- sd = state_dir || Session.default_state_dir
942
- if unsaved && !File.exist?(Session.session_file(session_id, state_dir: sd))
943
- return no_conversation?(unsaved.messages) && unsaved.last_prompt.to_s.strip.empty? &&
944
- empty_session_dir?(Session.session_dir(session_id, state_dir: sd))
945
- end
950
+ def self.discardable?(session_id, default_model:, state_dir: nil, used_memory_names: [])
951
+ return false unless discard_empty? && Array(used_memory_names).empty?
946
952
 
947
- empty_session?(session_id, state_dir: sd, default_model: default_model)
953
+ empty_session?(session_id, state_dir: state_dir || Session.default_state_dir, default_model: default_model)
948
954
  end
949
955
 
950
956
  # A session nothing happened in: no conversation, no turn tried (a failed
@@ -976,7 +982,7 @@ module Samagotchi
976
982
  false
977
983
  end
978
984
 
979
- # Only the system prompt (the REPL seeds one, a saved session keeps
985
+ # Only the system prompt (an Engine seeds one, a saved session keeps
980
986
  # it); a context note is a system message too, but with its kind.
981
987
  # A turn note (the tail system message after a failed, cancelled or empty
982
988
  # turn) is not a conversation either: a session whose only turn failed
@@ -1049,7 +1055,7 @@ module Samagotchi
1049
1055
  # @param stop [Boolean] stop a live worker first (waits +wait+ seconds)
1050
1056
  # @return [Hash] {id:, removed: [paths], stopped: whether a worker was stopped}
1051
1057
  # @raise [ArgumentError] unknown id (Session::AmbiguousId for a prefix of several)
1052
- # @raise [OwnedByTUI] a chi REPL owns it (never stopped from here)
1058
+ # @raise [OwnedByTUI] a one-turn chi run owns it (never stopped from here)
1053
1059
  # @raise [DeleteRefused] a worker owns it and +stop+ is false, or it
1054
1060
  # outlived the wait
1055
1061
  # @param purge_runs [Boolean] also delete System 1's saved runs of it and
@@ -1130,7 +1136,7 @@ module Samagotchi
1130
1136
  # ticks (nil: Worker::FALLBACK_TICK_SECONDS); queued turns wake it at once
1131
1137
  # A stopped session's worker exits 0 (a signalled one too) and a
1132
1138
  # crashed one 1, there.
1133
- # @return [Symbol] :idle_exit, :exit_requested or :restart
1139
+ # @return [Symbol] :idle_exit, :exit_requested, :restart or :scratch_left
1134
1140
  def self.run_session_loop(session_id, state_dir: nil, owner_wait: OwnerLock::DEFAULT_WAIT,
1135
1141
  idle_exit_minutes: nil, poll_interval: nil)
1136
1142
  # A worker inherits chi's locale (LC_ALL=C too): read files as UTF-8.
@@ -1175,6 +1181,13 @@ module Samagotchi
1175
1181
  signals.restore!
1176
1182
  end
1177
1183
  Log.info(:worker, "stop", reason: result)
1184
+ # A scratch session goes with its worker however it leaves (its
1185
+ # terminal deletes it too, whichever comes first); nothing resumes it.
1186
+ if worker.scratch?
1187
+ delete_left_scratch(session_id, state_dir: sd)
1188
+ exit(result == :crashed ? 1 : 0) if %i[stopped signal crashed].include?(result)
1189
+ return result
1190
+ end
1178
1191
  # A stopped session is left as it is: no resume for input that came
1179
1192
  # in, no discard. One whose worker a signal stopped isn't discarded
1180
1193
  # either; input that came in (queued behind the turn it stopped)
@@ -1231,6 +1244,16 @@ module Samagotchi
1231
1244
  Process.exit!(0)
1232
1245
  end
1233
1246
 
1247
+ # Delete a scratch session its worker left (its terminal gone:
1248
+ # Worker#leave_scratch, or any other way out), now the lock is free.
1249
+ # Its terminal may have deleted it first.
1250
+ private_class_method def self.delete_left_scratch(session_id, state_dir:)
1251
+ delete_session(session_id, state_dir: state_dir)
1252
+ Log.info(:worker, "scratch_deleted", sid: session_id)
1253
+ rescue DeleteRefused, ArgumentError, SystemCallError => e
1254
+ Log.info(:worker, "scratch_kept", sid: session_id, error: e.class.name, msg: e.message)
1255
+ end
1256
+
1234
1257
  # Delete a session its worker left empty. Checked again now the lock is
1235
1258
  # free: a note or input may have come in since the worker looked, and a
1236
1259
  # worker woken meanwhile owns it (delete_session refuses). A `chi send`
@@ -1477,7 +1500,7 @@ module Samagotchi
1477
1500
  # a worker that doesn't queue (no file then: it would steer), :timeout
1478
1501
  # when it took the request but never answered (no file then), or
1479
1502
  # :failed when the input file couldn't be written
1480
- # @raise [OwnedByTUI] a chi REPL owns the session
1503
+ # @raise [OwnedByTUI] a one-turn chi run owns the session
1481
1504
  # @raise [ArgumentError] no such session
1482
1505
  def self.deliver_turn(session_id, prompt:, client_id: nil, images: [], delivery: nil, no_interrupt: false,
1483
1506
  author: nil, pending_limit: nil, anchors: nil, state_dir: nil, manager: self, bridge: nil)
@@ -1532,7 +1555,7 @@ module Samagotchi
1532
1555
  # A participant's file only for a worker started after it: a worker
1533
1556
  # still holding the session with its Bridge gone (exiting) may be an
1534
1557
  # older one, which would run it without the review boundary.
1535
- # (A chi REPL holding it is refused as for anyone, below.)
1558
+ # (A one-turn chi run holding it is refused as for anyone, below.)
1536
1559
  if Steer.participant_author?(author) && (holder = delivery_owner(manager, session_id, state_dir)) && !holder.tui?
1537
1560
  return DeliverResult.new(status: :timeout, via: :file,
1538
1561
  ack: { "error" => "worker_timeout",