samagotchi 0.153.0 → 0.154.1

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 (226) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +42 -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/bundles.md +9 -12
  8. data/docs/cli.md +82 -96
  9. data/docs/configuration.md +27 -26
  10. data/docs/context.md +3 -3
  11. data/docs/desktop.md +1 -1
  12. data/docs/guardrails.md +8 -10
  13. data/docs/hooks.md +9 -9
  14. data/docs/internals/attached-context.md +2 -2
  15. data/docs/internals/background-tasks.md +3 -4
  16. data/docs/internals/block-renderers.md +4 -0
  17. data/docs/internals/bridge.md +21 -8
  18. data/docs/internals/context-telemetry.md +1 -1
  19. data/docs/internals/engine.md +33 -7
  20. data/docs/internals/objectives.md +6 -5
  21. data/docs/internals/participants.md +7 -7
  22. data/docs/internals/prompt-caching.md +4 -2
  23. data/docs/internals/remote-sessions.md +7 -7
  24. data/docs/internals/web-frontend.md +2 -2
  25. data/docs/internals/web-push.md +1 -1
  26. data/docs/memory.md +2 -2
  27. data/docs/plugins.md +23 -28
  28. data/docs/sessions.md +24 -24
  29. data/docs/sub-agent.md +2 -2
  30. data/docs/system-one.md +1 -1
  31. data/docs/testing.md +31 -6
  32. data/lib/samagotchi/answer_text.rb +33 -0
  33. data/lib/samagotchi/bridge/card_places.rb +56 -7
  34. data/lib/samagotchi/bridge/card_store.rb +24 -9
  35. data/lib/samagotchi/bridge.rb +52 -32
  36. data/lib/samagotchi/bridge_client.rb +19 -14
  37. data/lib/samagotchi/broadcast/recipients.rb +2 -2
  38. data/lib/samagotchi/broadcast/triage.rb +1 -1
  39. data/lib/samagotchi/broadcast_command.rb +5 -5
  40. data/lib/samagotchi/bundle_command/try/command.rb +1 -1
  41. data/lib/samagotchi/bundle_command/try/run.rb +1 -1
  42. data/lib/samagotchi/bundle_command/try/trace.rb +1 -1
  43. data/lib/samagotchi/bundle_command.rb +2 -2
  44. data/lib/samagotchi/bundles/btw/manifest.yml +2 -2
  45. data/lib/samagotchi/bundles/btw/plugin.rb +1 -1
  46. data/lib/samagotchi/bundles/coordinator/manifest.yml +2 -2
  47. data/lib/samagotchi/bundles/coordinator/plugin.rb +3 -2
  48. data/lib/samagotchi/bundles/decision-card/manifest.yml +4 -4
  49. data/lib/samagotchi/bundles/decision-card/plugin.rb +1 -9
  50. data/lib/samagotchi/bundles/dev/manifest.yml +2 -2
  51. data/lib/samagotchi/bundles/github-pr/manifest.yml +2 -2
  52. data/lib/samagotchi/bundles/github-pr/plugin.rb +3 -3
  53. data/lib/samagotchi/bundles/skills/manifest.yml +2 -2
  54. data/lib/samagotchi/bundles/skills/plugin.rb +3 -2
  55. data/lib/samagotchi/bundles/system/config_modification_protocol.md +3 -4
  56. data/lib/samagotchi/bundles/system/manifest.yml +3 -3
  57. data/lib/samagotchi/bundles/system/self_map.md +1 -1
  58. data/lib/samagotchi/child_move.rb +3 -3
  59. data/lib/samagotchi/commands/registry.rb +6 -6
  60. data/lib/samagotchi/composer_route.rb +4 -5
  61. data/lib/samagotchi/config.rb +14 -12
  62. data/lib/samagotchi/context_candidate_choice.rb +1 -1
  63. data/lib/samagotchi/context_candidates.rb +4 -3
  64. data/lib/samagotchi/continue_offer.rb +1 -1
  65. data/lib/samagotchi/debug_log.rb +1 -1
  66. data/lib/samagotchi/degrade.rb +75 -0
  67. data/lib/samagotchi/desktop/macos/ChiRunner.swift +3 -3
  68. data/lib/samagotchi/engine/cards.rb +87 -0
  69. data/lib/samagotchi/engine/context_judge.rb +138 -0
  70. data/lib/samagotchi/engine/events.rb +131 -0
  71. data/lib/samagotchi/engine/guardrails.rb +87 -0
  72. data/lib/samagotchi/engine/hooks.rb +119 -0
  73. data/lib/samagotchi/engine/messages.rb +124 -0
  74. data/lib/samagotchi/engine/model_settings.rb +127 -0
  75. data/lib/samagotchi/engine/models.rb +261 -0
  76. data/lib/samagotchi/engine/objectives.rb +116 -0
  77. data/lib/samagotchi/engine/participants.rb +86 -0
  78. data/lib/samagotchi/engine/plugins.rb +208 -0
  79. data/lib/samagotchi/engine/questions.rb +98 -0
  80. data/lib/samagotchi/engine/recap.rb +130 -0
  81. data/lib/samagotchi/engine/session.rb +131 -0
  82. data/lib/samagotchi/engine/side_requests.rb +135 -0
  83. data/lib/samagotchi/engine/steering.rb +134 -0
  84. data/lib/samagotchi/engine/toolkit.rb +269 -0
  85. data/lib/samagotchi/engine/turn_end.rb +180 -0
  86. data/lib/samagotchi/engine/turn_helpers.rb +160 -0
  87. data/lib/samagotchi/engine/verify.rb +153 -0
  88. data/lib/samagotchi/engine.rb +62 -2646
  89. data/lib/samagotchi/engine_snapshots.rb +9 -8
  90. data/lib/samagotchi/grants.rb +575 -0
  91. data/lib/samagotchi/guardrail_wiring.rb +2 -2
  92. data/lib/samagotchi/guardrails/context.rb +2 -3
  93. data/lib/samagotchi/guardrails/parent_approvals.rb +2 -2
  94. data/lib/samagotchi/guardrails/parent_policy.rb +2 -2
  95. data/lib/samagotchi/hook_routes.rb +3 -3
  96. data/lib/samagotchi/idle_recap.rb +7 -6
  97. data/lib/samagotchi/idle_reminders.rb +3 -4
  98. data/lib/samagotchi/iteration_limit.rb +2 -2
  99. data/lib/samagotchi/kernel_loop.rb +13 -13
  100. data/lib/samagotchi/launch_mode.rb +13 -32
  101. data/lib/samagotchi/level_setting_command.rb +3 -2
  102. data/lib/samagotchi/llm/chat_loop.rb +3 -1
  103. data/lib/samagotchi/llm_context_command.rb +3 -2
  104. data/lib/samagotchi/log.rb +7 -7
  105. data/lib/samagotchi/log_line.rb +1 -1
  106. data/lib/samagotchi/log_path.rb +1 -1
  107. data/lib/samagotchi/note_command.rb +1 -1
  108. data/lib/samagotchi/note_delivery.rb +18 -8
  109. data/lib/samagotchi/objectives.rb +24 -3
  110. data/lib/samagotchi/objectives_check.rb +8 -27
  111. data/lib/samagotchi/objectives_command.rb +4 -69
  112. data/lib/samagotchi/objectives_edit.rb +87 -0
  113. data/lib/samagotchi/output_formatter.rb +7 -1
  114. data/lib/samagotchi/owner_lock.rb +7 -1
  115. data/lib/samagotchi/parent_answers_command.rb +5 -4
  116. data/lib/samagotchi/participant_exposure.rb +42 -0
  117. data/lib/samagotchi/participant_mode.rb +1 -1
  118. data/lib/samagotchi/participant_name.rb +1 -1
  119. data/lib/samagotchi/participants.rb +17 -17
  120. data/lib/samagotchi/participants_watch.rb +1 -1
  121. data/lib/samagotchi/plugin/api.rb +3 -2
  122. data/lib/samagotchi/plugin/context.rb +21 -7
  123. data/lib/samagotchi/plugin/peer.rb +2 -1
  124. data/lib/samagotchi/plugin/sessions.rb +6 -4
  125. data/lib/samagotchi/plugin_tasks.rb +1 -2
  126. data/lib/samagotchi/private_files.rb +29 -0
  127. data/lib/samagotchi/project_env.rb +2 -2
  128. data/lib/samagotchi/question_desk.rb +8 -7
  129. data/lib/samagotchi/recap_command.rb +2 -1
  130. data/lib/samagotchi/recap_setup.rb +1 -1
  131. data/lib/samagotchi/recap_store.rb +1 -1
  132. data/lib/samagotchi/redact.rb +86 -0
  133. data/lib/samagotchi/reminder_queue.rb +2 -2
  134. data/lib/samagotchi/remote_attach_command.rb +1 -1
  135. data/lib/samagotchi/reply_wait/poll.rb +2 -2
  136. data/lib/samagotchi/review_start.rb +1 -1
  137. data/lib/samagotchi/run_options.rb +6 -6
  138. data/lib/samagotchi/self_report.rb +42 -39
  139. data/lib/samagotchi/send_command/busy.rb +1 -1
  140. data/lib/samagotchi/send_command.rb +4 -1
  141. data/lib/samagotchi/session.rb +11 -2
  142. data/lib/samagotchi/session_access/contract.rb +3 -3
  143. data/lib/samagotchi/session_access/local.rb +9 -9
  144. data/lib/samagotchi/session_access/remote.rb +17 -7
  145. data/lib/samagotchi/session_access.rb +4 -4
  146. data/lib/samagotchi/session_archive_command.rb +2 -2
  147. data/lib/samagotchi/session_chain.rb +8 -6
  148. data/lib/samagotchi/session_commands.rb +45 -48
  149. data/lib/samagotchi/session_continue.rb +1 -1
  150. data/lib/samagotchi/session_delete_command.rb +4 -4
  151. data/lib/samagotchi/session_manager.rb +67 -49
  152. data/lib/samagotchi/session_metrics.rb +2 -2
  153. data/lib/samagotchi/session_model_settings.rb +3 -2
  154. data/lib/samagotchi/session_participants.rb +1 -1
  155. data/lib/samagotchi/session_retention.rb +27 -7
  156. data/lib/samagotchi/sessions_command.rb +4 -4
  157. data/lib/samagotchi/share_command.rb +10 -10
  158. data/lib/samagotchi/steer.rb +13 -2
  159. data/lib/samagotchi/stream_event.rb +22 -0
  160. data/lib/samagotchi/system_prompt.rb +154 -145
  161. data/lib/samagotchi/task_watch.rb +1 -1
  162. data/lib/samagotchi/terminal_ui/attach_launcher.rb +72 -10
  163. data/lib/samagotchi/terminal_ui/attached_loop/composer.rb +12 -9
  164. data/lib/samagotchi/terminal_ui/attached_loop/exit_commands.rb +16 -0
  165. data/lib/samagotchi/terminal_ui/attached_loop/questions.rb +2 -3
  166. data/lib/samagotchi/terminal_ui/attached_loop.rb +17 -8
  167. data/lib/samagotchi/terminal_ui/attached_view.rb +1 -11
  168. data/lib/samagotchi/terminal_ui/event_renderer.rb +3 -5
  169. data/lib/samagotchi/terminal_ui/formatting.rb +7 -7
  170. data/lib/samagotchi/terminal_ui/input_support.rb +17 -22
  171. data/lib/samagotchi/terminal_ui/join_replay.rb +16 -12
  172. data/lib/samagotchi/terminal_ui/line_reader.rb +2 -2
  173. data/lib/samagotchi/terminal_ui/live_region.rb +2 -2
  174. data/lib/samagotchi/terminal_ui/log_tail.rb +135 -0
  175. data/lib/samagotchi/terminal_ui/one_shot_sink.rb +2 -2
  176. data/lib/samagotchi/terminal_ui/plain_surface.rb +1 -1
  177. data/lib/samagotchi/terminal_ui/question_prompt.rb +4 -5
  178. data/lib/samagotchi/terminal_ui/reline_seam.rb +2 -12
  179. data/lib/samagotchi/terminal_ui/status_row.rb +6 -14
  180. data/lib/samagotchi/terminal_ui.rb +142 -1387
  181. data/lib/samagotchi/title_command.rb +2 -2
  182. data/lib/samagotchi/tool_runner.rb +15 -16
  183. data/lib/samagotchi/toolkit_command.rb +2 -1
  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/send_note.rb +2 -1
  189. data/lib/samagotchi/turn_endings.rb +2 -2
  190. data/lib/samagotchi/turn_flow.rb +11 -7
  191. data/lib/samagotchi/update_hint.rb +2 -2
  192. data/lib/samagotchi/version.rb +1 -1
  193. data/lib/samagotchi/web/app.rb +42 -51
  194. data/lib/samagotchi/web/message_display.rb +7 -0
  195. data/lib/samagotchi/web/participant_access.rb +2 -2
  196. data/lib/samagotchi/web/participant_review.rb +5 -5
  197. data/lib/samagotchi/web/participant_stream_filter.rb +8 -8
  198. data/lib/samagotchi/web/participant_view.rb +2 -82
  199. data/lib/samagotchi/web/participants_admin.rb +1 -1
  200. data/lib/samagotchi/web/public/block_host.js +35 -8
  201. data/lib/samagotchi/web/public/block_renderers.css +9 -5
  202. data/lib/samagotchi/web/public/card_stamps.js +27 -0
  203. data/lib/samagotchi/web/public/cards_place.js +5 -3
  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 +1 -1
  207. data/lib/samagotchi/web/public/format.js +4 -3
  208. data/lib/samagotchi/web/public/turn_html.js +8 -4
  209. data/lib/samagotchi/web/push/store.rb +1 -1
  210. data/lib/samagotchi/web/session_access_routes.rb +13 -4
  211. data/lib/samagotchi/web/session_control.rb +12 -8
  212. data/lib/samagotchi/web/session_create.rb +1 -1
  213. data/lib/samagotchi/web/session_hub.rb +2 -2
  214. data/lib/samagotchi/web/session_show.rb +6 -2
  215. data/lib/samagotchi/web/session_summary.rb +1 -1
  216. data/lib/samagotchi/worker.rb +103 -9
  217. data/lib/samagotchi/worker_link.rb +3 -3
  218. data/lib/samagotchi/working_set_command.rb +3 -2
  219. data/lib/samagotchi.rb +0 -1
  220. metadata +31 -7
  221. data/lib/samagotchi/pending_input_queue.rb +0 -55
  222. data/lib/samagotchi/terminal_ui/between_turns.rb +0 -138
  223. data/lib/samagotchi/terminal_ui/repl_input.rb +0 -152
  224. data/lib/samagotchi/web/grants.rb +0 -575
  225. data/lib/samagotchi/web/participant_exposure.rb +0 -44
  226. data/lib/samagotchi/web/private_files.rb +0 -31
@@ -2,18 +2,13 @@
2
2
 
3
3
  require "json"
4
4
  require "fileutils"
5
- require "io/console"
6
- require "reline"
7
5
 
8
6
  require_relative "client_id"
9
7
  require_relative "iteration_limit"
10
8
  require_relative "model_profile"
11
- require_relative "turn_note"
12
9
  require_relative "turn_checkpoint"
13
10
  require_relative "config"
14
11
  require_relative "context_candidates"
15
- require_relative "context_note"
16
- require_relative "cancellation_controller"
17
12
  require_relative "llm/errors"
18
13
  require_relative "host_registry"
19
14
  require_relative "session"
@@ -21,26 +16,15 @@ require_relative "owner_lock"
21
16
  require_relative "bridge/card_store"
22
17
  require_relative "bridge/pending_card"
23
18
  require_relative "engine"
24
- require_relative "guardrails/parent_approvals"
25
- require_relative "tools/memory"
26
- require_relative "output_formatter"
27
19
  require_relative "turn_flow"
28
20
  require_relative "session_commands"
29
21
  require_relative "composer_route"
30
- require_relative "terminal_ui/attached_view"
31
- require_relative "terminal_ui/between_turns"
32
- require_relative "terminal_ui/event_renderer"
22
+ require_relative "degrade"
33
23
  require_relative "terminal_ui/formatting"
34
- require_relative "terminal_ui/gap_clock"
35
- require_relative "terminal_ui/input_support"
36
24
  require_relative "terminal_ui/image_input"
37
25
  require_relative "terminal_ui/plain_surface"
38
- require_relative "terminal_ui/live_region"
39
26
  require_relative "terminal_ui/one_shot_sink"
40
27
  require_relative "terminal_ui/question_prompt"
41
- require_relative "terminal_ui/repl_input"
42
- require_relative "terminal_ui/status_row"
43
- require_relative "children_status"
44
28
  require_relative "log"
45
29
 
46
30
  module Samagotchi
@@ -48,56 +32,34 @@ module Samagotchi
48
32
  # files unloaded until the delegate/session path needs them.
49
33
  autoload :SessionManager, File.expand_path("session_manager", __dir__)
50
34
 
51
- # The in-process terminal front end (`chi --no-shared`, `chi scratch`,
52
- # --verbose, --non-interactive): it owns one session and its Engine, runs
53
- # an optional -p prompt (--non-interactive: that one turn and out), then the REPL,
54
- # where the user types, the model answers and tools run inline. The
55
- # attached terminal (a worker's session) is TerminalUI::AttachedLoop.
35
+ # The one-shot host: `chi -p TEXT --non-interactive` (`chi scratch`'s and
36
+ # `--resume ID`'s too) runs that one turn in this process, its answer
37
+ # alone on stdout, and exits. It owns the session for the turn (an
38
+ # OwnerLock of kind "tui") and its Engine. Every interactive terminal is
39
+ # the attached one (TerminalUI::AttachLauncher, AttachedLoop), a client
40
+ # of the session's worker; the namespace keeps the terminal's parts.
56
41
  class TerminalUI
57
42
  include Formatting
58
- include InputSupport
59
43
 
60
- SCRATCH_ARCHIVE_REFUSED = "a scratch session is deleted when you leave; nothing to archive"
61
- # The prompt while a question waits for its answer (its choices are in
62
- # the notes slot).
63
- QUESTION_PROMPT = "? "
64
- REMINDER_PENDING_POLL_INTERVAL = 0.5
65
- # What a command sent during a turn gets, as from a worker (Worker::BUSY_OUTPUT).
66
- COMMAND_BUSY = "busy: wait for the turn to end"
67
- ROLLBACK_BUSY = "busy: Ctrl-C the turn first, then !rollback"
68
- # A command typed during a turn that runs after it (/model X, !cmd).
69
- COMMAND_QUEUED = "(queued: runs after this turn)"
70
- # /detach is the attached terminal's; the REPL owns its session.
71
- REPL_DETACH_NOTE = "(not attached: this session runs in this terminal; /exit ends it)"
72
- # /unqueue is the attached terminal's (its worker's queued input files).
73
- REPL_UNQUEUE_NOTE = "(/unqueue is the attached terminal's; here a line queued with /queue TEXT runs after the turn)"
74
- # A participant's turns run on the session's worker, which an attached
75
- # terminal has and this REPL doesn't.
76
- REPL_SHARE_NOTE = "(/share and /participants are the attached terminal's: a participant's turns run on the " \
77
- "session's worker; chi share does the same from a shell)"
78
- IMAGE_LINE_WAITS = "(a line with images runs as the next turn)"
79
-
80
- # Raised when another process (a `chi web` worker or another chi) owns the
81
- # session this TUI was asked to run.
44
+ # Raised when another process (a worker or another chi) owns the session
45
+ # this run was asked to use.
82
46
  class SessionBusy < StandardError; end
83
47
  # --resume with an id that has no saved session.
84
48
  class SessionNotFound < StandardError; end
85
49
 
86
- # What prints between turns (cards, notices, init lines, an idle recap):
87
- # announced on other threads, printed at the open prompt.
88
- # @return [BetweenTurns]
89
- attr_reader :between_turns
90
- # The session's Engine: the REPL drives it inline (#run_engine_turn).
50
+ EMPTY_ANSWER_ERROR = "chi: the model gave an empty answer"
51
+
52
+ # The session's Engine.
91
53
  # @return [Engine]
92
54
  attr_reader :engine
93
55
 
94
- # ── System prompts (delegated to Engine) ─────────────────────────────────
95
56
  def self.system_prompt_for(profile)
96
57
  Engine.system_prompt_for(profile)
97
58
  end
98
59
 
60
+ # @param prompt [String, nil] the turn's prompt (-p); nil: nothing runs
99
61
  # @param scratch [Boolean] `chi scratch`: a new session that is deleted
100
- # however the REPL ends, saves no memories and writes no recap
62
+ # however the run ends, saves no memories and writes no recap
101
63
  # @param llm_context [Hash{Symbol => String}] --llm-context* as typed
102
64
  # (LLMContextOverride.update): set on the new or resumed session
103
65
  # @param thinking [String, nil] --thinking as typed: the new or resumed
@@ -114,10 +76,10 @@ module Samagotchi
114
76
  # @param parent_answers [String, nil] --parent-answers: a new session's
115
77
  # own guardrails.parent_answers; a resumed one keeps its own (named in
116
78
  # +toolkit_flags+ then)
117
- def initialize(prompt: nil, client: nil, host_registry: nil, profile: nil, session_id: nil, no_interrupt: false, no_default_input: false, model_name: nil, memories: [], muted_memories: [], non_interactive: false, surface: nil,
118
- spinner_tick_interval: AttachedView::TICK_INTERVAL, spinner_clock: nil, scratch: false, llm_context: {}, thinking: nil,
119
- appended_prompt_files: [], toolkit: nil, toolkit_flags: [], settings: nil, parent_answers: nil,
120
- now: -> { MessageTime.now })
79
+ # @param surface [#commit, nil] where the answer goes (stdout by default)
80
+ def initialize(prompt: nil, client: nil, host_registry: nil, profile: nil, session_id: nil, model_name: nil,
81
+ memories: [], muted_memories: [], surface: nil, scratch: false, llm_context: {}, thinking: nil,
82
+ appended_prompt_files: [], toolkit: nil, toolkit_flags: [], settings: nil, parent_answers: nil)
121
83
  @scratch = scratch
122
84
  @parent_answers = parent_answers
123
85
  @toolkit = toolkit
@@ -135,7 +97,7 @@ module Samagotchi
135
97
  @host_registry.client_override = client if client
136
98
  @client = @host_registry.resolve(@effective_model_name).client
137
99
  # Own the session before loading it, so a worker can't write a turn
138
- # between the load and the lock that this TUI would later save over.
100
+ # between the load and the lock that this run would later save over.
139
101
  # An unknown id is refused before the claim, which would leave an
140
102
  # empty session dir behind.
141
103
  if session_id && !Session.exist?(session_id)
@@ -165,8 +127,6 @@ module Samagotchi
165
127
  # The model it starts on names a configured host (a bad default.model
166
128
  # doesn't matter when --model or the session picks another).
167
129
  ModelProfile.check_host!(@effective_model_name, hosts: @host_registry.entries)
168
- @no_default_input = no_default_input
169
- @non_interactive = non_interactive
170
130
  # --memory and --mute, with a resumed session's own lists first: a
171
131
  # session started attached with mutes keeps them here.
172
132
  @requested_memories = Array(memories)
@@ -177,36 +137,14 @@ module Samagotchi
177
137
  @muted_memory_names = (@resume_session.muted_memory_names + @muted_memory_names).uniq
178
138
  @appended_prompt_files = (@resume_session.appended_prompt_files + @appended_prompt_files).uniq
179
139
  end
180
- # Every terminal write goes through the surface. The REPL swaps in a
181
- # live region when the terminal can show one (#assist_loop), unless it
182
- # was given a surface to draw on.
183
140
  @surface = surface || PlainSurface.new
184
- @surface_given = !surface.nil?
185
- # The turn view is the attached TUI's, and so is the status row; both
186
- # move to the live region with the surface (#use_surface). A
187
- # spinner_tick_interval of nil: no ticker thread, and a spinner_clock
188
- # fixes the frames (specs that compare exact frames).
189
- view_options = { tick_interval: spinner_tick_interval }
190
- view_options[:clock] = spinner_clock if spinner_clock
191
- @view = AttachedView.new(@surface, **view_options)
192
- @status_row = StatusRow.new(@surface)
193
- @renderer = EventRenderer.new(@view)
194
- # Cards, notices and a recap announced between turns, printed by the
195
- # main thread at the open prompt (#poll_input_with_reminder_check).
196
- # The session's last activity, for the gap dividers (+now+: the wall
197
- # clock, a Time).
198
- @gaps = GapClock.new(now: now)
199
- @between_turns = BetweenTurns.new(surface: @surface, view: @view, renderer: @renderer,
200
- turn_running: -> { @engine.turn_running? }, quiet: @non_interactive,
201
- gaps: @gaps)
202
- @render_event = ->(event) { handle_stream_event(event) }
203
141
  @engine = Engine.new(
204
142
  client: client,
205
143
  host_registry: @host_registry,
206
144
  profile: profile,
207
145
  session_id: session_id,
208
- no_interrupt: no_interrupt,
209
- # nil: the config default, unchecked here (the model the REPL starts
146
+ no_interrupt: true,
147
+ # nil: the config default, unchecked here (the model the run starts
210
148
  # on is checked above, and --model may make the default moot). With
211
149
  # no default, the model it runs (the switch below is then skipped).
212
150
  model_name: no_default ? @effective_model_name : nil,
@@ -214,30 +152,13 @@ module Samagotchi
214
152
  memories: @requested_memories,
215
153
  muted_memories: @muted_memory_names,
216
154
  appended_prompt_files: @appended_prompt_files,
217
- scratch: scratch,
218
- reminders: {
219
- callback: lambda { |due_names|
220
- # When a reminder is due, queue a synthetic turn that
221
- # run_assist_loop checks before read_input. The synthetic turn
222
- # uses an empty prompt so the agent can see
223
- # [SYSTEM: REMINDERS DUE <local time>] and act on them.
224
- @engine.note_due_reminders(due_names)
225
- }
226
- }
155
+ scratch: scratch
227
156
  )
228
- # -p without --non-interactive drops into the REPL, which can answer
229
- # an approval; --non-interactive can't, so an approval there denies.
230
- @engine.interface = @non_interactive ? :non_interactive : :repl
231
- # A --non-interactive run exits after its turn: no turn-end warm-up.
232
- @engine.next_turn_waiting = -> { true } if @non_interactive
233
- # A warm-up never pays off while a line typed ahead at the open prompt
234
- # (or a left-over steer) already forms the next turn: only prefill when
235
- # the prompt is empty. The REPL-only check composes with the one above.
236
- next_waiting = @engine.next_turn_waiting
237
- @engine.next_turn_waiting = -> { @repl_input&.waiting_input? || next_waiting&.call }
157
+ # No one is there to answer: an approval denies.
158
+ @engine.interface = :non_interactive
159
+ # The run exits after its turn: no turn-end warm-up.
160
+ @engine.next_turn_waiting = -> { true }
238
161
  @turn_flow = TurnFlow.new(engine: @engine)
239
- # A recap written while a continue offer waits says the turn stopped
240
- # unfinished. (Answering at the prompt is typing: activity already.)
241
162
  @engine.recap_awaiting_continue = -> { @turn_flow.awaiting_continue? }
242
163
  @commands = SessionCommands.new(engine: @engine, turn_flow: @turn_flow, default_model: @default_model_name,
243
164
  registry: @engine.command_registry)
@@ -245,48 +166,26 @@ module Samagotchi
245
166
  # kernel profile) without persisting the default. Skipped when there's
246
167
  # no config default (the Engine was built on the session model).
247
168
  @engine.switch_model!(@effective_model_name, typed: @resumed_model_typed) if !no_default && @effective_model_name != @default_model_name
248
- # Render an idle session-recap via the cursor-safe background writer; the
249
- # detector itself is Engine-owned (see Engine#recap) and opt-in.
250
- @recap_handle = @engine.subscribe(observer: ->(event) { @between_turns.take_recap(event) })
251
- @question_handle = @engine.subscribe(observer: ->(event) { handle_question_event(event) })
252
- @card_handle = @engine.subscribe(observer: ->(event) { @between_turns.observe(event) })
253
- # The REPL renders events from here on: the load warnings and notices
254
- # now (at the first prompt), and the plugins' slow setup in the
255
- # background. A --non-interactive run leaves both to its turn.
256
- unless @non_interactive
257
- @engine.announce_load_events!
258
- @engine.start_init_tasks!
259
- end
260
- # Synchronous TUI handler for in-turn ask_user_question: the turn thread IS the
261
- # REPL thread (run_engine_turn runs Engine#run_turn inline), so we must render
262
- # and collect input on the SAME thread without parking on a second thread.
169
+ # ask_user_question in the turn: no one can answer it here.
263
170
  @engine.set_question_sync_handler do |pending|
264
- # render_question_widget records the choice via Engine#answer_question,
265
- # which Engine.request_question reads back once this handler returns.
266
- render_question_widget(pending)
171
+ dismiss_unaskable_question(QuestionPrompt.new(pending))
267
172
  nil
268
173
  end
269
174
  end
270
175
 
271
- # Single dispatch for all entrypoints (interactive REPL, --prompt,
272
- # --non-interactive, --resume). Builds the working session and its seed
273
- # messages once, runs a single prompt turn when -p/--prompt is given
274
- # (auto-executing it, then saving), then either exits when there is no
275
- # follow-up REPL (--non-interactive) or drops into the REPL carrying the
276
- # post-turn conversation.
277
- # @return [Symbol, nil] :turn_failed when lines came from a pipe and a
278
- # turn failed, :empty_answer when a -p --non-interactive turn ended
279
- # with no answer, :iteration_limit when the iteration limit cut that
280
- # turn short (bin/chi exits 1 on all three), :interrupted when Ctrl-C
281
- # cancelled that turn (bin/chi exits 130)
176
+ # Run the -p prompt as one turn (a session command as that command),
177
+ # then return; with no prompt, nothing (no session is made).
178
+ # @return [Symbol, nil] :turn_failed when the turn failed or the command
179
+ # was refused, :empty_answer when the turn ended with no answer,
180
+ # :iteration_limit when the iteration limit cut it short (bin/chi exits
181
+ # 1 on all three), :interrupted when Ctrl-C cancelled it (bin/chi
182
+ # exits 130)
282
183
  def run
283
- # --non-interactive with no --prompt is a harmless no-op exit: build
284
- # nothing and return (no transient session, no banner).
285
- return if @non_interactive && @prompt.nil?
286
- # A UI-only command (/stats, /exit …) isn't run without the REPL, and
287
- # isn't a prompt either: refused as chi send refuses it, before any
288
- # session is made (bin/chi exits 1).
289
- return refuse_one_shot_ui_command if @non_interactive && SessionCommands.ui_only_entry(@prompt)
184
+ return if @prompt.nil?
185
+ # A UI-only command (/stats, /exit …) isn't run without a terminal,
186
+ # and isn't a prompt either: refused as chi send refuses it, before
187
+ # any session is made (bin/chi exits 1).
188
+ return refuse_one_shot_ui_command if SessionCommands.ui_only_entry(@prompt)
290
189
 
291
190
  session = @resume_session || @engine.store_model!(Session.new_session(
292
191
  mode: "assist",
@@ -310,85 +209,20 @@ module Samagotchi
310
209
  warn_toolkit_flags_ignored(session) if @resume_session
311
210
  claim_session!(session.id) unless @owner_lock
312
211
  # A turn a worker died in (TurnCheckpoint): ended as canceled, with its
313
- # note, before this REPL's first turn appends after it.
212
+ # note, before this run's turn appends after it.
314
213
  session.save if @resume_session && TurnCheckpoint.settle(session, session_dir: Session.session_dir(session.id))
315
214
  keep_cards(session)
316
- # Saved at once, so a process killed before its first turn leaves a
317
- # file the sweep knows to delete.
215
+ # Saved at once, so a process killed before its turn leaves a file
216
+ # the sweep knows to delete.
318
217
  session.save if @scratch
319
218
  # Attach before building the prompt so it can name the session id.
320
219
  @engine.session = session
321
- messages = messages_for(session)
322
-
323
- # As typed at the prompt: a session command (/models, !ls) runs as
324
- # the command, not as a turn.
325
- return run_one_shot_command(session) if @prompt && @non_interactive && command_registry.command?(@prompt)
326
-
327
- if @prompt && @non_interactive
328
- # A /cut TEXT / /queue TEXT prompt runs its text as the turn's
329
- # prompt, the same as the idle REPL does (ComposerRoute.steer_choice);
330
- # a bare /cut / /queue (no text) is refused above as a UI command,
331
- # so only the text here. The images are the text's, not the words'.
332
- one_shot_prompt =
333
- if (choice = ComposerRoute.steer_choice(@prompt)) && !choice.last.empty?
334
- choice.last
335
- else
336
- @prompt
337
- end
338
- # Headless / CI mode: run directly without TTY rendering.
339
- ArchiveStore.user_input(session.id, state_dir: Session.default_state_dir)
340
- # Retry lines and hook notices on stderr; stdout gets the answer below.
341
- sink = OneShotSink.new
342
- save_before_first_turn(session)
343
- attach_mentioned_context(session, one_shot_prompt)
344
- max_iterations = IterationLimit.for(no_interrupt: true)
345
- begin
346
- result = @engine.run_turn(
347
- session,
348
- one_shot_prompt,
349
- on_event: sink,
350
- max_iterations: max_iterations,
351
- cancel_controller: nil,
352
- images: ImageInput.extract(one_shot_prompt)
353
- )
354
- rescue LLM::ProviderError, ImageStore::Error => e
355
- return one_shot_failed(session, e)
356
- rescue Interrupt
357
- return one_shot_interrupted(session)
358
- end
359
- # No answer (the empty-answer retries used up): no text, or the chat
360
- # loop's placeholder (LLM::ModelResult#empty_answer?).
361
- if result.empty_answer?
362
- # Nothing to print: say so on stderr and fail (bin/chi exits 1),
363
- # so a script that pipes the answer doesn't take the silence for one.
364
- session.save
365
- warn EMPTY_ANSWER_ERROR
366
- sink.flush
367
- return :empty_answer
368
- end
369
- @surface.commit(result.output)
370
- session.save
371
- # The after_turn hooks' notices, after the answer as the REPL shows them.
372
- sink.flush
373
- # The limit cut the turn short: what came so far is no answer, so say
374
- # so and fail (bin/chi exits 1), as the REPL's "iteration limit
375
- # reached" line does there.
376
- return one_shot_exhausted(session, max_iterations) if result.resumable?
220
+ banner_for(session)
377
221
 
378
- return
379
- end
222
+ # A session command (/models, !ls) runs as the command, not as a turn.
223
+ return run_one_shot_command(session) if command_registry.command?(@prompt)
380
224
 
381
- assist_loop(session: session, messages: messages)
382
- # After the idle layer has stopped: nothing writes the session now.
383
- case exit_action
384
- when :scratch then nil # deleted below, however the REPL ended
385
- when :delete then delete_after_exit(session)
386
- when :discard then discard_after_exit(session)
387
- when :archive then archive_after_exit(session)
388
- else keep_after_exit(session)
389
- end
390
- # Lines from a pipe (`chi -p X </dev/null`): a failed turn fails the run.
391
- :turn_failed if @piped_turn_failed
225
+ run_one_shot_turn(session)
392
226
  ensure
393
227
  # However it ends (the early returns too; the Engine was built in
394
228
  # #initialize): the anytime commands finish, the plugins' services
@@ -397,18 +231,64 @@ module Samagotchi
397
231
  # below anyway; the others stay).
398
232
  clear_pending_card
399
233
  @engine&.shutdown
400
- # A scratch session goes on every way out: /exit, Ctrl-D, an error,
234
+ # A scratch session goes on every way out: the turn's end, an error,
401
235
  # SIGTERM or SIGHUP (Ruby raises those here). Only kill -9 leaves it
402
236
  # to the sweep.
403
- delete_scratch(session, quiet: @non_interactive || !$!.nil?) if @scratch && session
237
+ delete_scratch(session) if @scratch && session
404
238
  end
405
239
 
406
- EMPTY_ANSWER_ERROR = "chi: the model gave an empty answer"
240
+ private
241
+
242
+ def command_registry = @commands.registry
243
+
244
+ # The turn: its answer on stdout, the retry lines and hook notices on
245
+ # stderr (OneShotSink).
246
+ def run_one_shot_turn(session)
247
+ # A /cut TEXT / /queue TEXT prompt runs its text as the turn's prompt,
248
+ # as an idle attached terminal does (ComposerRoute.steer_choice); a
249
+ # bare /cut / /queue (no text) is refused above as a UI command, so
250
+ # only the text here. The images are the text's, not the words'.
251
+ prompt =
252
+ if (choice = ComposerRoute.steer_choice(@prompt)) && !choice.last.empty?
253
+ choice.last
254
+ else
255
+ @prompt
256
+ end
257
+ ArchiveStore.user_input(session.id, state_dir: Session.default_state_dir)
258
+ sink = OneShotSink.new
259
+ save_before_first_turn(session)
260
+ attach_mentioned_context(session, prompt)
261
+ max_iterations = IterationLimit.for(no_interrupt: true)
262
+ begin
263
+ result = @engine.run_turn(session, prompt, on_event: sink, max_iterations: max_iterations,
264
+ cancel_controller: nil, images: ImageInput.extract(prompt))
265
+ rescue LLM::ProviderError, ImageStore::Error => e
266
+ return one_shot_failed(session, e)
267
+ rescue Interrupt
268
+ return one_shot_interrupted(session)
269
+ end
270
+ # No answer (the empty-answer retries used up): no text, or the chat
271
+ # loop's placeholder (LLM::ModelResult#empty_answer?).
272
+ if result.empty_answer?
273
+ # Nothing to print: say so on stderr and fail (bin/chi exits 1),
274
+ # so a script that pipes the answer doesn't take the silence for one.
275
+ session.save
276
+ warn EMPTY_ANSWER_ERROR
277
+ sink.flush
278
+ return :empty_answer
279
+ end
280
+ @surface.commit(result.output)
281
+ session.save
282
+ # The after_turn hooks' notices, after the answer.
283
+ sink.flush
284
+ # The limit cut the turn short: what came so far is no answer, so say
285
+ # so and fail (bin/chi exits 1).
286
+ one_shot_exhausted(session, max_iterations) if result.resumable?
287
+ end
407
288
 
408
- # The session is over for this process (its exits and the ensure below):
289
+ # The session is over for this process (its exits and #run's ensure):
409
290
  # nothing will resolve the open card any more, so its pending_card.json
410
- # goes (the Engine's TURN_END would clear it, but the REPL may end
411
- # mid-turn).
291
+ # goes.
412
292
  def clear_pending_card
413
293
  return unless @pending_card_handle
414
294
 
@@ -416,9 +296,9 @@ module Samagotchi
416
296
  @pending_card_handle = nil
417
297
  end
418
298
 
419
- # A -p --non-interactive turn that failed (bin/chi exits 1): the error,
420
- # then how to go on. The Engine saved the failed turn with its prompt,
421
- # so the session is kept and `chi --resume` takes it up again.
299
+ # A turn that failed (bin/chi exits 1): the error, then how to go on.
300
+ # The Engine saved the failed turn with its prompt, so the session is
301
+ # kept and `chi --resume` takes it up again.
422
302
  # @return [Symbol] :turn_failed
423
303
  def one_shot_failed(session, error)
424
304
  warn "Error: #{@engine.failure_summary(error)}"
@@ -428,8 +308,8 @@ module Samagotchi
428
308
  :turn_failed
429
309
  end
430
310
 
431
- # A -p --non-interactive turn the iteration limit cut short: a line on
432
- # stderr, with how to go on unless the session goes with this run.
311
+ # A turn the iteration limit cut short: a line on stderr, with how to go
312
+ # on unless the session goes with this run.
433
313
  # @return [Symbol] :iteration_limit
434
314
  def one_shot_exhausted(session, max_iterations)
435
315
  line = "chi: the iteration limit (#{max_iterations}) was reached before the turn finished"
@@ -447,7 +327,7 @@ module Samagotchi
447
327
  line = @prompt.strip
448
328
  result = begin
449
329
  @commands.run(line, confirm: false)
450
- rescue StandardError => e
330
+ rescue StandardError => e # degrade-ok: the command's error is its output, and the run fails
451
331
  SessionCommands::Result.new(status: :error, output: "#{line.split.first}: #{e.message}", changed: [])
452
332
  end
453
333
  @surface.commit(result.output) unless result.output.to_s.empty?
@@ -463,10 +343,10 @@ module Samagotchi
463
343
  :turn_failed
464
344
  end
465
345
 
466
- # Ctrl-C in a -p --non-interactive turn: the Engine kept the prompt and
467
- # a cancel note in the session (it saves only a failed turn), so save
468
- # it here: `chi --resume` takes it up again. A scratch session goes in
469
- # #run's ensure, so there is nothing to keep.
346
+ # Ctrl-C in the turn: the Engine kept the prompt and a cancel note in
347
+ # the session (it saves only a failed turn), so save it here: `chi
348
+ # --resume` takes it up again. A scratch session goes in #run's ensure,
349
+ # so there is nothing to keep.
470
350
  # @return [Symbol] :interrupted
471
351
  def one_shot_interrupted(session)
472
352
  return :interrupted if @scratch
@@ -476,77 +356,20 @@ module Samagotchi
476
356
  :interrupted
477
357
  end
478
358
 
479
- # Delete the scratch session, the last thing the REPL does. Quiet after
480
- # a one-shot or on the way out of an error or a signal.
481
- def delete_scratch(session, quiet:)
359
+ # Delete the scratch session, the last thing the run does.
360
+ def delete_scratch(session)
482
361
  @owner_lock&.release
483
362
  @owner_lock = nil
484
363
  SessionManager.delete_session(session.id)
485
- @surface.commit("Scratch session deleted.") unless quiet
486
- rescue StandardError => e
364
+ rescue StandardError => e # degrade-ok: the line says how to delete it by hand
487
365
  warn "Scratch session #{session.id} was not deleted (#{e.message}): chi sessions delete #{session.id}"
488
366
  end
489
367
 
490
- # The session stays: the recap first, then the resume line, last.
491
- def keep_after_exit(session)
492
- recap_after_exit
493
- @surface.commit("\nContinue session: chi --resume #{session.id}")
494
- end
495
-
496
- # The recap for coming back: written before the REPL ends (it has to
497
- # wait, same process), when there is something new to recap. Ctrl-C
498
- # gives up on it.
499
- def recap_after_exit
500
- @engine.write_recap_now(on_start: -> { @surface.commit("writing a recap…") })
501
- rescue Interrupt
502
- nil
503
- end
504
-
505
- # Nothing happened in the session (SessionManager.discardable?). The
506
- # REPL saves only after a turn and keeps /model in its Engine, so a
507
- # session never saved is judged from memory, and one on another model
508
- # than the default (/model, --model) is kept.
509
- def discard_on_exit?(session)
510
- SessionManager.discardable?(session.id, default_model: @default_model_name, model_name: @effective_model_name,
511
- used_memory_names: @engine.used_memory_names, unsaved: session)
512
- end
513
-
514
- # A session left empty: give it up and delete it, one quiet line.
515
- def discard_after_exit(session)
516
- @owner_lock&.release
517
- @owner_lock = nil
518
- SessionManager.delete_session(session.id)
519
- @surface.commit("The session was empty, so it is discarded.")
520
- rescue SessionManager::DeleteRefused, SessionManager::OwnedByTUI, ArgumentError, SystemCallError => e
521
- @surface.commit("Continue session: chi --resume #{session.id} (the empty session was not discarded: #{e.message})")
522
- end
523
-
524
- # /exit --delete: give up the session, then delete it for good.
525
- def delete_after_exit(session)
526
- @owner_lock&.release
527
- @owner_lock = nil
528
- SessionManager.delete_session(session.id)
529
- @surface.commit("Deleted session #{session.id}.")
530
- rescue SessionManager::DeleteRefused, SessionManager::OwnedByTUI, ArgumentError, SystemCallError => e
531
- @surface.commit("Session #{session.id} was not deleted (#{e.message}): chi sessions delete #{session.id}")
532
- end
533
-
534
- # /archive: give up the session, then archive it (hidden from the lists,
535
- # kept for good). An empty one is discarded instead (#discard_on_exit?).
536
- def archive_after_exit(session)
537
- @owner_lock&.release
538
- @owner_lock = nil
539
- SessionManager.archive_session(session.id)
540
- @surface.commit("Archived session #{session.id}. chi sessions list --archived finds it.")
541
- rescue SessionManager::ArchiveRefused, SessionManager::OwnedByTUI, ArgumentError, SystemCallError => e
542
- @surface.commit("Session #{session.id} was not archived (#{e.message}): chi sessions archive #{session.id}")
543
- end
544
-
545
368
  # The session's cards.json and pending_card.json, kept as a worker's
546
- # Bridge keeps them (it owns the session as a worker would): this REPL's
547
- # turns go on the same turn log, so a card an earlier worker showed keeps its
548
- # place above the turns after it, and its own cards and notices are saved
549
- # for the next; an open card with actions is on disk, where
369
+ # Bridge keeps them (it owns the session as a worker would): this run's
370
+ # turn goes on the same turn log, so a card an earlier worker showed keeps
371
+ # its place above the turns after it, and its own cards and notices are
372
+ # saved for the next; an open card with actions is on disk, where
550
373
  # `chi web`'s session hub reads it to mark the session as waiting.
551
374
  def keep_cards(session)
552
375
  path = File.join(Session.session_dir(session.id), Bridge::CardStore::FILE)
@@ -555,9 +378,7 @@ module Samagotchi
555
378
  end
556
379
 
557
380
  # A card a worker that died left isn't open: the file goes before the
558
- # observer takes over, as Bridge#start does. Subscribed once: the REPL
559
- # keeps one session, and a second observer would fold every card twice
560
- # and clear the file at the first turn's end.
381
+ # observer takes over, as Bridge#start does.
561
382
  def keep_pending_card(session)
562
383
  return if @pending_card_handle
563
384
 
@@ -566,20 +387,20 @@ module Samagotchi
566
387
  @pending_card_handle = @engine.subscribe(observer: pending)
567
388
  end
568
389
 
569
- # A question the session's last owner saved and died with (a worker's,
570
- # or another REPL's): this TUI owns the session now, so nobody waits on
571
- # it. Dropped and saved, as the worker does (Worker#drop_dead_question).
390
+ # A question the session's last owner saved and died with (a worker's):
391
+ # this run owns the session now, so nobody waits on it. Dropped and
392
+ # saved, as the worker does (Worker#drop_dead_question).
572
393
  def drop_dead_question(session)
573
394
  id = session.drop_dead_question
574
395
  return unless id
575
396
 
576
- Log.info(:repl, "dead_question_dropped", id: id)
397
+ Log.info(:turn, "dead_question_dropped", id: id)
577
398
  session.save
578
399
  end
579
400
 
580
- # Take the session's OwnerLock for this process's lifetime: the TUI runs
401
+ # Take the session's OwnerLock for this process's lifetime: the run has
581
402
  # its own Engine, so no worker may run the session meanwhile.
582
- # @raise [SessionBusy] when a worker or another TUI owns it
403
+ # @raise [SessionBusy] when a worker or another chi owns it
583
404
  def claim_session!(session_id)
584
405
  session_dir = Session.session_dir(session_id)
585
406
  @owner_lock = OwnerLock.acquire(session_dir, kind: "tui", wait: 1.0)
@@ -591,428 +412,29 @@ module Samagotchi
591
412
  "Close it there first."
592
413
  end
593
414
 
594
- # Build the seed messages for the working session.
595
- #
596
- # Resumed sessions keep their prior conversation (only the system-prompt
597
- # slot is replaced); fresh sessions start with just the system prompt.
598
- # Prints a one-line banner so the user sees which session they're in.
599
- def messages_for(session)
600
- system_message = { role: "system", content: seed_system_prompt }
415
+ # Which session this is, and a resumed one's recap, before the turn: on
416
+ # stderr, as stdout is the answer's alone (a script or a parent agent
417
+ # reads it).
418
+ def banner_for(session)
601
419
  if @resume_session
602
- messages = ContextNote.with_system_head(session.messages.dup, system_message)
603
420
  banner("Resumed session: #{session.id}")
604
421
  saved = @engine.saved_recap
605
422
  banner(recap_block(saved[:text], turns_since: saved[:turns_since], at: saved[:created_at])) if saved
606
- # The first prompt after a quiet gap has its divider.
607
- @gaps.seed(session.messages, fallback: saved&.dig(:created_at))
608
423
  elsif @scratch
609
- messages = [system_message]
610
424
  banner("Scratch session: nothing is kept, it is deleted when you leave.")
611
425
  else
612
- messages = [system_message]
613
426
  banner("Session: #{session.id}")
614
427
  end
615
- messages
616
428
  end
617
429
 
618
- # A line about the session, not the answer: on stderr in a
619
- # --non-interactive run, whose stdout is the answer's alone (a script
620
- # or a parent agent reads it), and before the turn, so it comes first.
621
430
  def banner(text)
622
- return @surface.commit(text) unless @non_interactive
623
-
624
431
  $stderr.puts(text)
625
432
  $stderr.flush
626
433
  end
627
434
 
628
- # Interactive REPL loop. Session seed + messages are built by #run and
629
- # threaded in here (so --prompt / --resume share one code path). The
630
- # session's conversation is the single working copy: turns go through
631
- # Engine#run_turn and out-of-turn edits through Engine's messages API.
632
- # The checkpoint and continue state live in @turn_flow (shared with
633
- # session workers). The session is persisted at the end of every turn.
634
- def run_assist_loop(session:, messages:)
635
- session.messages = messages
636
- @engine.session = session
637
- # Steering: lines submitted at the open prompt during a turn
638
- # (#steer_line), merged by the kernel (#drain_steering).
639
- @pending_input_queue = PendingInputQueue.new
640
- open_repl_input
641
-
642
- loop do
643
- if @exit_after_turn
644
- # Ctrl-D or exit came during a turn: the lines sent before it still
645
- # run (all queued: the prompt closed at Ctrl-D), then the REPL ends.
646
- input = queued_line
647
- break if input.nil?
648
- else
649
- # Drain any pending ask_user_question first — it has priority over reminders and
650
- # must be rendered on the REPL thread (turn thread is parked on Engine Monitor).
651
- drain_pending_question?
652
-
653
- # A due reminder runs its turn now, with the prompt open (on a
654
- # terminal): the turn's output commits above it and whatever is typed
655
- # there stays.
656
- unless @engine.due_reminder_names.empty?
657
- run_reminder_turn(session)
658
- @turn_flow.after_reminder_turn
659
- next
660
- end
661
- input = @prompt
662
- @prompt = nil if input
663
- if input.nil?
664
- input = poll_input_with_reminder_check(awaiting_continue: @turn_flow.awaiting_continue?)
665
- # A reminder fell due: run it at the top, with the prompt still open.
666
- next if input == :due
667
- end
668
- end
669
- break if input.nil?
670
-
671
- local = local_command(input)
672
- if local == :exit
673
- @delete_on_exit ||= SessionCommands.delete_on_exit?(input)
674
- break
675
- end
676
- if local == :archive
677
- next @surface.commit(SCRATCH_ARCHIVE_REFUSED) if @scratch
678
-
679
- @archive_on_exit = true
680
- break
681
- end
682
- # Not an answer to a continue offer either.
683
- next detach_note if local == :detach
684
-
685
- # /stats and /recap run; the offer stays open. So does a session
686
- # command (one queued during the turn: /model X, !cmd); /continue
687
- # answers it.
688
- if @turn_flow.awaiting_continue? && !%i[stats recap].include?(local) && !offer_leaves_open?(input)
689
- answer_continue_offer(session, input)
690
- else
691
- # The ? read left no echo: show what ran.
692
- @surface.commit("#{paint(QUESTION_PROMPT, 33)}#{input}") if @turn_flow.awaiting_continue?
693
- run_input_line(session, input)
694
- end
695
- end
696
-
697
- close_repl_input
698
- @discard_on_exit = !@delete_on_exit && discard_on_exit?(session)
699
- end
700
-
701
- # Run one REPL turn (a prompt, or a continue/reminder turn with nil) through
702
- # Engine#run_turn, rendering via @renderer.
703
- # @param images [Array<Hash>] the prompt's `@path` images ({path:})
704
- def run_engine_turn(session, prompt, continue: false, max_iterations: IterationLimit.for, images: [], resumed: false, resume_from: nil)
705
- cancellation_controller = CancellationController.new
706
- @active_cancel_controller = cancellation_controller
707
- @renderer.begin_turn
708
- # The row shows the turn's model (a -p turn runs before any read).
709
- refresh_status_row
710
- # A prompt the user typed brings an archived session back to the lists.
711
- ArchiveStore.user_input(session.id, state_dir: Session.default_state_dir) if prompt && !continue
712
- save_before_first_turn(session)
713
- attach_mentioned_context(session, prompt) if prompt && !continue
714
- with_steering do
715
- @engine.run_turn(
716
- session,
717
- prompt,
718
- on_event: @render_event,
719
- max_iterations: max_iterations,
720
- cancel_controller: cancellation_controller,
721
- pending_input: method(:drain_steering),
722
- # A steering line waits (#steer_line): a running task_wait ends for it.
723
- input_waiting: -> { !@pending_input_queue.empty? },
724
- continue: continue,
725
- images: images,
726
- resumed: resumed,
727
- resume_from: resume_from
728
- )
729
- end
730
- rescue Interrupt
731
- # Engine kept the prompt in the session and emitted :turn_canceled
732
- # (the renderer's line).
733
- cancellation_controller&.cancel!(:ctrl_c)
734
- cancelled_result_from(session.messages, reason: :ctrl_c)
735
- ensure
736
- @active_cancel_controller = nil
737
- @view.finish_thinking_spinner
738
- # The turn's end is the last activity.
739
- @gaps.touch
740
- end
741
-
742
- # How the REPL leaves its session once the loop ended (#run): :scratch
743
- # (deleted however it ends), :delete (/exit --delete), :discard (left
744
- # empty), :archive (/archive) or :keep.
745
- def exit_action
746
- if @scratch then :scratch
747
- elsif @delete_on_exit then :delete
748
- elsif @discard_on_exit then :discard
749
- elsif @archive_on_exit then :archive
750
- else :keep
751
- end
752
- end
753
-
754
- # The toolkit flags are a new session's: a resumed one keeps its own
755
- # toolkit (as attached mode says it, AttachLauncher).
756
- def warn_toolkit_flags_ignored(session)
757
- return if @toolkit_flags.empty?
758
-
759
- names = @toolkit_flags.size > 1 ? "#{@toolkit_flags[0..-2].join(", ")} and #{@toolkit_flags.last}" : @toolkit_flags.first
760
- warn "(#{names} #{@toolkit_flags.size == 1 ? "applies" : "apply"} to a new session; #{session.id} keeps its own)"
761
- end
762
- private :warn_toolkit_flags_ignored
763
-
764
- private
765
-
766
- # Interactive REPL loop. Session seed + messages are built by #run and
767
- # threaded in here (so --prompt / --resume share one code path). The
768
- # working session is persisted at the end of every turn.
769
- def assist_loop(session:, messages:)
770
- load_persistent_history
771
-
772
- # queue_default_input self-guards on @resume_session, so calling it
773
- # unconditionally preserves the original fresh-session prefill behavior.
774
- # Skip the default input prefill when a user-provided --prompt was used —
775
- # the explicit prompt means the user is in command and shouldn't see the
776
- # default input ("Please " etc.) on the next REPL prompt.
777
- queue_default_input if @prompt.nil?
778
-
779
- # The idle layer (reminders + optional recap) is Engine-owned; start the
780
- # shared scheduler for this REPL and stop it on exit. with_activity_hook
781
- # resets the shared inactivity clock when a prompt opens and on each key.
782
- with_live_region do
783
- with_interrupt_arbiter do
784
- @engine.start_idle
785
- with_activity_hook do
786
- run_assist_loop(session: session, messages: messages)
787
- ensure
788
- @engine.stop_idle
789
- end
790
- end
791
- end
792
- end
793
-
794
- # Ctrl-C while a Reline read owns stdin: Reline's INT trap catches it, so
795
- # the REPL hears of it through RelineSeam. A running turn is cancelled and
796
- # the prompt stays as typed; with no turn running it is Reline's Ctrl-C.
797
- # (With no read open, Ctrl-C stays an Interrupt on the turn's thread.)
798
- def with_interrupt_arbiter
799
- return yield unless RelineSeam.supported?
800
-
801
- RelineSeam.install
802
- previous = RelineSeam.interrupt_handler
803
- RelineSeam.interrupt_handler = method(:cancel_turn_from_prompt)
804
- begin
805
- yield
806
- ensure
807
- RelineSeam.interrupt_handler = previous
808
- end
809
- end
810
-
811
- # @return [Boolean] a running turn was cancelled
812
- def cancel_turn_from_prompt
813
- controller = @active_cancel_controller
814
- return false unless controller
815
-
816
- controller.cancel!(:ctrl_c)
817
- true
818
- end
819
-
820
- # Draw the REPL on a live region (a Screen, with Reline's prompt in it)
821
- # when the terminal can show one; plain output otherwise, or on the
822
- # surface the UI was given.
823
- def with_live_region
824
- screen = LiveRegion.open unless @surface_given
825
- return yield unless screen
826
-
827
- plain = @surface
828
- use_surface(screen)
829
- begin
830
- yield
831
- ensure
832
- # Nothing may draw on the screen once it is closed: the view moves
833
- # off it, then its ticker ends.
834
- use_surface(plain)
835
- @view.stop
836
- LiveRegion.close(screen)
837
- end
838
- end
839
-
840
- def use_surface(surface)
841
- @surface = surface
842
- @view.surface = surface
843
- @status_row.surface = surface
844
- @between_turns.surface = surface
845
- end
846
-
847
- # An answer at the ? prompt of a continue offer. A valid one closes the
848
- # offer's choices and leaves one line; an invalid one gets its error.
849
- # Whether +input+ is a session command that runs past a continue offer,
850
- # leaving it open: any but /continue (the worker's S2).
851
- def offer_leaves_open?(input)
852
- entry = command_registry.lookup(input)
853
- !entry.nil? && entry.id != :continue
854
- end
855
-
856
- def answer_continue_offer(session, input)
857
- answer = @commands.continue_answer(input, as_parent: as_parent?)
858
- if answer.decision == :invalid
859
- # The read left no echo: the line shows above its error.
860
- @surface.commit("#{paint(QUESTION_PROMPT, 33)}#{input}")
861
- else
862
- sync_continue_slot(false)
863
- @surface.commit(QuestionSlot.continue_summary(input.to_s.strip.empty? ? "yes" : input.to_s.strip, paint: method(:paint)))
864
- end
865
- return show_command_result(answer) unless answer.resume
866
-
867
- # The run the offer's turn started: the Continue keeps its reasoning.
868
- resume_from = @turn_flow.resume_from
869
- @turn_flow.before_continue_turn
870
- begin
871
- result = run_engine_turn(session, nil, continue: true, resumed: true, resume_from: resume_from)
872
- rescue LLM::ProviderError
873
- # The renderer showed the failure (:turn_failed).
874
- @surface.commit(turn_end_hint("continue prompt preserved"))
875
- return
876
- end
877
- finish_turn(session, result, continue: true)
878
- end
879
-
880
- # A line at the main prompt: a command, or a prompt for a turn.
881
- def run_input_line(session, input)
882
- return if input.empty?
883
-
884
- # /cut TEXT and /queue TEXT are deliveries only while a turn runs; at
885
- # the prompt both run the text as a normal turn, as their usage says
886
- # (the route is the text's).
887
- route = ComposerRoute.call(input, running: false, registry: command_registry)
888
- return @surface.commit(STEER_CHOICE_USAGE) if route.kind == :usage
889
-
890
- if route.choice
891
- input = route.text
892
- @surface.commit(STEER_CHOICE_IDLE)
893
- end
894
-
895
- # /model, /models, !rollback, !cmd, /continue (shared with workers);
896
- # an anytime command's cards print as it shows them (btw's
897
- # "thinking…" before the answer). Piped input has nobody to answer a
898
- # confirm (the next line is already written): a costly /thinking or
899
- # /reasoning-history applies at once.
900
- case route.kind
901
- when :command
902
- confirm = !piped_input?
903
- command = if command_registry.anytime?(input)
904
- @engine.running_anytime { @commands.run(input, confirm: confirm) }
905
- else
906
- # A parent's /continue yes is held to guardrails.parent_answers;
907
- # only a person here (not piped, no agent marker) loosens
908
- # /parent-answers.
909
- parent = as_parent?
910
- @commands.run(input, confirm: confirm, as_parent: parent && TurnFlow.continue_yes?(input), user_ui: !parent)
911
- end
912
- if command
913
- show_command_result(command)
914
- refresh_status_row(clear_unknown_context: true) if command.changed.include?(:model)
915
- refresh_status_row if command.changed.include?(:title)
916
- # /recap and /title don't save themselves (a worker saves a changed command).
917
- save_session(session) if command.changed.intersect?(%i[recap title])
918
- persist_recent_history(input) if command.shell
919
- return
920
- end
921
- when :local then return if show_local_command(input)
922
- # A typo of a command (/modle) is not a prompt: say so, send nothing.
923
- when :typo then return @surface.commit(route.reply)
924
- end
925
-
926
- @turn_flow.before_prompt_turn
927
- persist_recent_history(input)
928
- # Off a live region (no echo seam) the divider comes here; after
929
- # #gap_above_echo it marks none.
930
- mark_activity
931
- begin
932
- # Engine#run_turn injects due reminders as a tail message, appends
933
- # the prompt, and renders through @renderer via on_event.
934
- result = run_engine_turn(session, input, images: ImageInput.extract(input))
935
- rescue LLM::ProviderError, ImageStore::Error => e
936
- # Engine closed the turn (:turn_failed), which the renderer showed.
937
- # An @path image that can't be used fails the turn the same way.
938
- # The model reads why on its next turn. An image that couldn't be
939
- # used, or that the host refused, never reached it: no note for that
940
- # (a first turn refused that way leaves the session empty, as before).
941
- note = e.is_a?(LLM::ProviderError) && !e.is_a?(LLM::VisionUnsupported)
942
- # A turn that got somewhere stays (the renderer said so, and that
943
- # !rollback erases it), as the Engine saved it: no prompt goes back.
944
- kept = @turn_flow.prompt_turn_failed(error: e, note: note) == :kept
945
- # The Engine saved the failed turn; the file follows the rollback.
946
- save_session(session) if note || kept
947
- if piped_input?
948
- # Nobody can edit a restored prompt, and Reline would hand it back
949
- # as the next line: the turn would run again and again. It failed;
950
- # the exit status says so.
951
- @piped_turn_failed = true
952
- elsif !kept
953
- @surface.commit(turn_end_hint(restore_prompt_for_retry(input)))
954
- end
955
- return
956
- end
957
- finish_turn(session, result, continue: false)
958
- end
959
-
960
- # After a prompt or continue turn: TurnFlow keeps the checkpoint and the
961
- # continue offer; the REPL saves and tells the user.
962
- def finish_turn(session, result, continue:)
963
- if result.canceled?
964
- outcome = @turn_flow.after_turn(result, continue: continue)
965
- # A cancelled continue is back where it started, the offer still open.
966
- return if outcome == :continue_cancelled
967
-
968
- # Ctrl-C on a fresh turn. The kernel salvaged completed tool calls
969
- # and the partial assistant reply (marked [interrupted]) into
970
- # result.conversation, so progress is preserved by default — the
971
- # user's next message continues from it. !rollback restores the
972
- # pre-turn checkpoint for an explicit full discard (the renderer's
973
- # hint under :turn_canceled says so).
974
- save_session(session) if result.conversation.is_a?(Array)
975
- return
976
- end
977
-
978
- # The REPL keeps the kernel's conversation as-is (no [No response]
979
- # placeholder), so /continue resumes from the tool results; the
980
- # answer keeps what the after_turn hooks stored on it.
981
- session.messages = with_answer_display(result.conversation, session.messages)
982
- @turn_flow.after_turn(result, continue: continue)
983
- save_session(session)
984
- end
985
-
986
- # The answer's display keys (Engine#store_answer_display put them on the
987
- # session's last message) carried onto +conversation+'s last message,
988
- # when that is the same answer; +conversation+ as it is otherwise.
989
- ANSWER_DISPLAY_KEYS = [AnswerDisplay::KEY, AnswerRefs::KEY].freeze
990
- private_constant :ANSWER_DISPLAY_KEYS
991
-
992
- def with_answer_display(conversation, stored)
993
- answer = Array(conversation).last
994
- kept = Array(stored).last
995
- return conversation unless answer.is_a?(Hash) && kept.is_a?(Hash) && message_field(answer, :role).to_s == "model"
996
- return conversation unless message_field(kept, :role).to_s == "model" &&
997
- message_field(kept, :content) == message_field(answer, :content)
998
-
999
- extra = ANSWER_DISPLAY_KEYS.each_with_object({}) do |key, found|
1000
- value = message_field(kept, key)
1001
- found[key] = value unless value.nil?
1002
- end
1003
- extra.empty? ? conversation : conversation[0...-1] + [answer.merge(extra)]
1004
- end
1005
-
1006
- def message_field(message, key) = message.key?(key) ? message[key] : message[key.to_s]
1007
-
1008
- def save_session(session)
1009
- @engine.store_model!(session)
1010
- session.save
1011
- end
1012
-
1013
435
  # A fresh session reaches the disk before its first turn: a tool in that
1014
436
  # turn reads it from there (delegate loads its parent to start a child,
1015
- # "Session not found" otherwise). Later turns find it saved already.
437
+ # "Session not found" otherwise).
1016
438
  def save_before_first_turn(session)
1017
439
  session.save unless Session.exist?(session.id)
1018
440
  end
@@ -1026,695 +448,28 @@ module Samagotchi
1026
448
  rescue Interrupt
1027
449
  nil
1028
450
  rescue StandardError => e
1029
- Log.warn(:context, "candidates_repl_failed", error: e.class.name, msg: e.message.to_s[0, 200])
1030
- end
1031
-
1032
- # A !cmd shows its own output; everything else is the model> line. A
1033
- # change that asks first (CacheConfirm) puts its --yes line in the next
1034
- # input: Enter applies it.
1035
- def show_command_result(result)
1036
- if result.shell
1037
- @surface.commit(result.output)
1038
- @surface.commit("")
1039
- elsif !result.output.nil?
1040
- @surface.commit("\nmodel> #{result.output}")
1041
- end
1042
- queue_input_prefill(result.confirm.line) if result.confirm && !piped_input?
1043
- sync_model_mirrors if result.changed.include?(:model)
1044
- end
1045
-
1046
- # The status line and the next session save read these.
1047
- def sync_model_mirrors
1048
- @effective_model_name = @engine.effective_model_name
1049
- @default_model_name = @commands.default_model
1050
- end
1051
-
1052
- # Engine's built-once system prompt (the one run_turn sends).
1053
- def seed_system_prompt = @engine.system_prompt
1054
-
1055
- def read_input(awaiting_continue:)
1056
- refresh_status_row
1057
-
1058
- if awaiting_continue
1059
- sync_continue_slot(true)
1060
- input = Reline.readline(paint(QUESTION_PROMPT, 33), true)
1061
- return nil if input.nil?
1062
-
1063
- return input.strip
1064
- end
1065
-
1066
- read_prompt_line(paint("> ", 92))
1067
- rescue Interrupt
1068
- nil
1069
- end
1070
-
1071
- # The synthetic turn for due reminders (Engine#run_turn injects them as
1072
- # a tail message). The hints row says which, while it runs.
1073
- def run_reminder_turn(session)
1074
- names = @engine.due_reminder_names
1075
- @engine.clear_due_reminder_names!
1076
- # A normal turn may already have injected them (a stale latch): no
1077
- # empty synthetic turn then (it duplicated output).
1078
- return unless @engine.reminders_due?
1079
-
1080
- # A pending continue offer goes, as for a new prompt (the worker's rule).
1081
- if @turn_flow.before_reminder_turn
1082
- sync_continue_slot(false)
1083
- @surface.commit(QuestionSlot.continue_summary("(dropped: a reminder ran)", paint: method(:paint)))
1084
- end
1085
- mark_activity
1086
- @surface.commit(reminder_line(names))
1087
- hint = "#{reminder_line(names)} · Ctrl-C cancels it"
1088
- @surface.set_slot(:hints, [color_output? ? paint(hint, 90) : hint])
1089
- result = nil
1090
- begin
1091
- result = run_engine_turn(session, nil, continue: true)
1092
- @prompt = nil
1093
- rescue LLM::ProviderError
1094
- nil # the renderer showed the failure; back to the prompt
1095
- ensure
1096
- @surface.clear_slot(:hints)
1097
- end
1098
- canceled = result&.canceled?
1099
- if result && !canceled
1100
- session.messages = with_answer_display(result.conversation, session.messages) if result.conversation.is_a?(Array)
1101
- @engine.store_model!(session)
1102
- session.save
1103
- end
1104
- # The synthetic turn is activity: the next reminder waits a full
1105
- # interval (without it they fired back to back).
1106
- @engine.record_activity
1107
- end
1108
-
1109
- # The next line from the prompt, or :due when a reminder falls due first.
1110
- # On a terminal the prompt stays open the whole session (#open_repl_input):
1111
- # a reminder turn runs with it (and anything typed in it) still there, and
1112
- # a line submitted meanwhile comes next. Ctrl-C during a turn reaches
1113
- # #cancel_turn_from_prompt.
1114
- def poll_input_with_reminder_check(awaiting_continue:)
1115
- return :due unless @engine.due_reminder_names.empty?
1116
-
1117
- # Specs and pipes: a plain blocking read.
1118
- unless @repl_input
1119
- @between_turns.flush_cards
1120
- return read_input(awaiting_continue: awaiting_continue)
1121
- end
1122
-
1123
- if @idle_status_due
1124
- @idle_status_due = false
1125
- refresh_status_row
1126
- end
1127
- sync_continue_slot(awaiting_continue)
1128
- @repl_input.sync_prompt
1129
- loop do
1130
- @between_turns.flush_recap
1131
- @between_turns.flush_cards
1132
- kind, line = @repl_input.pop(timeout: REMINDER_PENDING_POLL_INTERVAL)
1133
- if kind
1134
- # What the line does may change the status (/model, a turn).
1135
- @idle_status_due = true
1136
- # Ctrl-C at an idle prompt ends the REPL, as Ctrl-D does.
1137
- return kind == :line ? line : nil
1138
- end
1139
- return :due unless @engine.due_reminder_names.empty?
1140
- end
1141
- end
1142
-
1143
- # On a terminal: one read for the whole session, on a LineReader.
1144
- def open_repl_input
1145
- return unless STDIN.tty? && $stdin.tty?
1146
-
1147
- @idle_status_due = false
1148
- refresh_status_row
1149
- @repl_input = ReplInput.new(prompt: method(:repl_prompt_text), read: method(:read_repl_line), surface: @surface).start
1150
- end
1151
-
1152
- def close_repl_input
1153
- @repl_input&.stop
1154
- @repl_input = nil
1155
- end
1156
-
1157
- # The main prompt, or ? when a continue offer waits and no turn runs.
1158
- def repl_prompt_text
1159
- return paint("> ", 92) if @active_cancel_controller || !@turn_flow.awaiting_continue?
1160
-
1161
- paint(QUESTION_PROMPT, 33)
1162
- end
1163
-
1164
- # The notes slot shows a continue offer's choices while it waits.
1165
- def sync_continue_slot(shown)
1166
- if shown
1167
- return if @continue_slot
1168
-
1169
- @continue_slot = true
1170
- @surface.set_slot(:notes, QuestionSlot.continue_offer(@turn_flow.offer&.dig(:context), paint: method(:paint)))
1171
- elsif @continue_slot
1172
- @continue_slot = false
1173
- @surface.clear_slot(:notes)
1174
- end
1175
- end
1176
-
1177
- # One read on the reader thread (ReplInput): the multiline read with Tab
1178
- # completion at the main prompt, a plain line for the continue offer and
1179
- # a question's ? prompt. +prefill+ is typed in first.
1180
- def read_repl_line(prompt, prefill)
1181
- queue_input_prefill(prefill) if prefill
1182
- return RelineSeam.echo_above(method(:gap_above_echo)) { read_prompt_line(prompt) } if prompt == paint("> ", 92)
1183
-
1184
- read = -> { with_next_input_prefill { Reline.readline(prompt, true) }&.strip }
1185
- # A question's answer leaves only its summary line.
1186
- prompt == paint(QUESTION_PROMPT, 33) ? RelineSeam.without_echo(&read) : read.call
1187
- end
1188
-
1189
- # Activity starts now: a gap's divider first when one passed since the
1190
- # last (none while a turn runs: a steer is no gap; none in a
1191
- # --non-interactive run, whose stdout is the answer's alone).
1192
- def mark_activity
1193
- line = @gaps.divider(quiet: @non_interactive || @engine.turn_running?)
1194
- @surface.commit(gap_divider(line)) if line
1195
- end
1196
-
1197
- # The divider above a typed line's echo (RelineSeam.echo_above, on the
1198
- # reader thread): a prompt typed at the idle prompt after a quiet gap.
1199
- # It marks the activity, so #run_input_line marks none.
1200
- def gap_above_echo(text)
1201
- return nil if @engine.turn_running? || @turn_flow.awaiting_continue?
1202
-
1203
- route = ComposerRoute.call(text.to_s.strip, running: false, registry: command_registry)
1204
- return nil unless %i[prompt deliver].include?(route.kind)
1205
-
1206
- line = @gaps.divider
1207
- line && gap_divider(line)
1208
- end
1209
-
1210
- # Lines come from a pipe or a file (`chi -p X </dev/null`, `echo … | chi`),
1211
- # not a terminal: no prompt is restored for a retry.
1212
- def piped_input? = !$stdin.tty?
1213
-
1214
- # A failed prompt goes back into the input for a retry.
1215
- # @return [String] what the failed-turn line says about it
1216
- def restore_prompt_for_retry(input)
1217
- unless @repl_input
1218
- queue_input_prefill(input)
1219
- return "prompt restored for retry"
1220
- end
1221
- @repl_input.prefill(input) ? "prompt restored for retry" : "the failed prompt is in the input history (↑)"
1222
- end
1223
-
1224
- # The id of the terminal's own command +input+ is (:exit, :archive,
1225
- # :detach, :stats, :recap; SessionCommands registers them), or nil.
1226
- def local_command(input) = command_registry.lookup_local(input)&.id
1227
-
1228
- # /stats and /recap, which show and change nothing (at the prompt, a
1229
- # continue offer's ? prompt, or during a turn).
1230
- # @return [Boolean] whether +input+ was one
1231
- def show_local_command(input)
1232
- case local_command(input)
1233
- when :stats then @surface.commit("\nmodel> session stats:\n#{format_session_metrics(@engine.stats_snapshot)}")
1234
- when :recap then @surface.commit("\nmodel> #{handle_recap_command}")
1235
- when :unqueue then @surface.commit(REPL_UNQUEUE_NOTE)
1236
- when :share, :participants then @surface.commit(REPL_SHARE_NOTE)
1237
- else return false
1238
- end
1239
- true
1240
- end
1241
-
1242
- # /recap: the saved recap, and a new one asked for at once when the
1243
- # chat moved on (it prints when it arrives, BetweenTurns#flush_recap).
1244
- def handle_recap_command
1245
- recap = @engine.recap
1246
- return recap_command_text(enabled: false, session_off: @engine.recap_override == false) unless recap
1247
-
1248
- saved = @engine.saved_recap
1249
- request = @engine.turn_running? ? :busy : @engine.request_recap
1250
- recap_command_text(enabled: true, saved: saved, request: request, min_user_turns: recap.min_user_turns)
1251
- end
1252
-
1253
- # A resumed session doesn't get the default input either.
1254
- def default_input_wanted? = !@resume_session && !@no_default_input
1255
-
1256
- def clone_messages(messages)
1257
- Array(messages).map(&:dup)
1258
- end
1259
-
1260
- # While a turn runs, a line submitted at the open prompt steers it: it
1261
- # merges at the next iteration boundary (Kernel), and one that comes after
1262
- # the last runs as the next turn. Reminder turns too.
1263
- #
1264
- # A command that waits for the turn's end (/model X, !cmd) and every line
1265
- # after it wait in @after_turn, in the order typed (D1), with the image
1266
- # lines (which never steer) and /queue's: once the turn ends they come
1267
- # back with the steering it didn't merge, in the order sent (each line
1268
- # the turn takes gets the next @input_seq), ahead of anything sent after
1269
- # Ctrl-C.
1270
- def with_steering(&)
1271
- return yield unless @repl_input
1272
-
1273
- @after_turn = []
1274
- @command_waits = false
1275
- @input_seq = 0
1276
- @repl_input.during_turn(method(:steer_line), leftovers: -> { leftover_lines }, &)
1277
- end
1278
-
1279
- # The turn's leftovers in send order: steering it didn't merge and the
1280
- # lines that waited for its end.
1281
- def leftover_lines
1282
- (@pending_input_queue.drain_entries + lines_after_turn).sort_by { |seq, _| seq || 0 }.map(&:last)
1283
- end
1284
-
1285
- # The next line's place in send order (#leftover_lines).
1286
- def next_input_seq
1287
- @input_seq = (@input_seq || 0) + 1
1288
- end
1289
-
1290
- # The [seq, line] entries that waited for the turn's end, taken from
1291
- # @after_turn. After a canceled or failed turn (Ctrl-C raises through
1292
- # here; a failure too) a !cmd among them is dropped (D4): running it
1293
- # would close the !rollback the turn left.
1294
- def lines_after_turn
1295
- entries = @after_turn.slice!(0..)
1296
- ended = if @active_cancel_controller&.cancelled? || $!.is_a?(Interrupt) then "canceled"
1297
- elsif $! then "failed"
1298
- end
1299
- return entries unless ended
1300
-
1301
- entries.reject do |_seq, line|
1302
- next false unless command_registry.lookup(line)&.id == :shell
1303
-
1304
- @surface.commit("turn #{ended}: #{line.strip} not run; send it again")
1305
- true
1306
- end
1307
- end
1308
-
1309
- # On the reader thread, from ReplInput: takes a line for the running turn.
1310
- # A line sent after Ctrl-C waits for the next turn (the kernel would not
1311
- # merge it into the cancelled one). Ctrl-D or exit ends the REPL after
1312
- # the turn. /stats and /recap run now, an anytime command (/help, a
1313
- # plugin's /btw, /model alone) starts now on its own thread; one that
1314
- # runs after the turn (/model X, !cmd) waits in the queue, in arrival
1315
- # order with the other lines that wait; others go back in the prompt.
1316
- #
1317
- # A plain line goes in at the running turn's next step boundary and
1318
- # never cuts; /cut TEXT asks for a cut, /queue TEXT waits for the turn
1319
- # to end and runs as a turn of its own.
1320
- # @return [Boolean, :back] whether the turn took it, :back to put it back
1321
- def steer_line(line)
1322
- route = ComposerRoute.call(line, running: true, registry: command_registry) unless line.nil?
1323
- local = route&.local
1324
- return exit_after_turn(delete: SessionCommands.delete_on_exit?(line)) if line.nil? || local == :exit
1325
- return archive_after_turn if local == :archive
1326
- return detach_note if local == :detach
1327
- return false if @active_cancel_controller&.cancelled?
1328
-
1329
- case route.kind
1330
- # Before every command check (ComposerRoute): /cut and /queue are the
1331
- # UIs' own words for a delivery (listed for Tab and /help, never run).
1332
- when :usage
1333
- @surface.commit(STEER_CHOICE_USAGE)
1334
- return true
1335
- when :deliver then return submit_steer_choice(route.delivery, route.text)
1336
- when :command
1337
- policy = command_registry.mid_turn(line)
1338
- return start_anytime_command(line) if policy == :anytime
1339
- return command_queued(line) if policy == :queue
1340
- end
1341
- # A command, never steering text.
1342
- return command_during_turn(line) if %i[local command].include?(route.kind)
1343
- return true if route.kind == :empty
1344
-
1345
- # A typo of a command (/modle) is not steering text: say so, take it.
1346
- if route.kind == :typo
1347
- @surface.commit(route.reply)
1348
- return true
1349
- end
1350
- # Steering merges text only: a line with images runs as the next turn.
1351
- return image_line_waits(line) if ImageInput.extract(line).any?
1352
- # Behind a command that waits for the turn's end, a line waits too.
1353
- return wait_for_turn_end(line) if @command_waits
1354
-
1355
- @pending_input_queue.push(route.text, seq: next_input_seq)
1356
- @surface.commit(NEXT_STEP_NOTE)
1357
- true
1358
- end
1359
-
1360
- # /cut TEXT and /queue TEXT during a turn, which the REPL would
1361
- # otherwise read as prompts (SessionCommands registers them for Tab and
1362
- # help only). A cut asks the engine to cut the generation only thinking
1363
- # too long; a queue waits for the turn's end (the REPL's own after-turn
1364
- # list). A line with images never steers (as a plain one doesn't).
1365
- # @param delivery [String] Delivery::CUT or Delivery::QUEUE (ComposerRoute)
1366
- # @return [Boolean] whether the turn took it
1367
- def submit_steer_choice(delivery, text)
1368
- return image_line_waits(text) if ImageInput.extract(text).any?
1369
-
1370
- # A /queue waits for the turn's end; a /cut behind a command that
1371
- # already waits there goes after it too.
1372
- if Delivery.queue?(delivery) || @command_waits
1373
- @surface.commit(COMMAND_QUEUED)
1374
- return wait_for_turn_end(text)
1375
- end
1376
-
1377
- epoch = @engine.input_epoch
1378
- @pending_input_queue.push(text, seq: next_input_seq)
1379
- # With no session running the cut is :off; the text still waits for
1380
- # the running turn's next step.
1381
- @surface.commit(steer_cut_note(@engine.cut_for_steer(nil, epoch: epoch), @engine.steer_cut_after))
1382
- true
1383
- end
1384
-
1385
- def image_line_waits(line)
1386
- @surface.commit(IMAGE_LINE_WAITS)
1387
- wait_for_turn_end(line)
1388
- end
1389
-
1390
- # +line+ runs once the turn has ended, in the order typed (#with_steering).
1391
- # @return [true] the turn took it
1392
- def wait_for_turn_end(line)
1393
- @after_turn << [next_input_seq, line]
1394
- true
1395
- end
1396
-
1397
- def detach_note
1398
- @surface.commit(REPL_DETACH_NOTE)
1399
- true
1400
- end
1401
-
1402
- # /archive during a turn (D2): the REPL leaves and archives once the
1403
- # turn and the lines queued behind it (S5) ran, as /exit does.
1404
- # @return [true]
1405
- def archive_after_turn
1406
- return @surface.commit(SCRATCH_ARCHIVE_REFUSED).then { true } if @scratch
1407
-
1408
- @exit_after_turn = true
1409
- @archive_on_exit = true
1410
- @surface.commit("(archives after this turn; Ctrl-C cancels it)")
1411
- true
1412
- end
1413
-
1414
- def exit_after_turn(delete: false)
1415
- @exit_after_turn = true
1416
- @delete_on_exit = true if delete
1417
- @surface.commit(delete ? "(exits after this turn and deletes the session; Ctrl-C cancels the turn)" : "(exits after this turn; Ctrl-C cancels it)")
1418
- true
1419
- end
1420
-
1421
- # A line already queued (none waits for input), or nil.
1422
- def queued_line
1423
- kind, line = @repl_input&.pop(timeout: 0)
1424
- kind == :line ? line : nil
1425
- end
1426
-
1427
- # @return [true, :back]
1428
- def command_during_turn(line)
1429
- return true if show_local_command(line)
1430
-
1431
- @surface.commit(command_registry.lookup(line)&.id == :rollback ? ROLLBACK_BUSY : COMMAND_BUSY)
1432
- :back
1433
- end
1434
-
1435
- # @return [true] it waits for the turn's end (#wait_for_turn_end)
1436
- def command_queued(line)
1437
- @surface.commit(COMMAND_QUEUED)
1438
- @command_waits = true
1439
- wait_for_turn_end(line)
1440
- end
1441
-
1442
- # D8: an anytime command runs on its own thread while the turn goes on
1443
- # (it reads copies, and shows things through its ctx). What it prints
1444
- # while the turn runs goes above the live region now, its cards as it
1445
- # shows them (BetweenTurns#observe); once the turn has ended it waits for
1446
- # the prompt's flush.
1447
- # @return [true]
1448
- def start_anytime_command(line)
1449
- @engine.spawn_anytime do
1450
- output = @engine.running_anytime { @commands.run(line) }&.output
1451
- @between_turns.show_or_keep({ type: :command_output, text: "\nmodel> #{output}" }) unless output.nil?
1452
- rescue StandardError => e
1453
- @between_turns.keep({ type: :command_output, text: "\nmodel> #{line.split.first}: #{e.message}" })
1454
- end
1455
- true
1456
- end
1457
-
1458
- # The kernel's drain at an iteration boundary: queued lines, sent as
1459
- # typed and saved in the history.
1460
- def drain_steering
1461
- @pending_input_queue.drain.map do |line|
1462
- persist_recent_history(line)
1463
- line
1464
- end
1465
- end
1466
-
1467
- # The REPL's on_event sink for Engine#run_turn: render one event, after
1468
- # taking what the status row shows from it (as AttachedLoop does from the
1469
- # Bridge's). Engine swallows on_event errors to protect the turn, so log
1470
- # ours instead.
1471
- def handle_stream_event(event)
1472
- @status_row.take_event(event)
1473
- @renderer.call(event)
1474
- rescue StandardError => e
1475
- Log.error(:repl, "render_failed", echo: "[render] #{event[:type]}: #{e.class}: #{e.message}", event_type: event[:type].to_s, error: e.class.name)
1476
- end
1477
-
1478
- def cancelled_result_from(messages, reason:)
1479
- LLM::ModelResult.new(text: "", conversation: clone_messages(messages), canceled: true, cancellation_reason: reason)
1480
- end
1481
-
1482
- # The status row under the prompt, from the Engine's state (what a
1483
- # worker hands an attached TUI when it joins): drawn when it changed.
1484
- def refresh_status_row(clear_unknown_context: false)
1485
- state = @engine.session_state_snapshot.merge(model_name: @effective_model_name)
1486
- @status_row.take_state(state, default_model: @default_model_name)
1487
- @status_row.update(context: nil) if clear_unknown_context && state.key?(:context_status) && state[:context_status].nil?
1488
- refresh_children_status
1489
- end
1490
-
1491
- # The status row's children segment: this session's delegates, read
1492
- # from their files (ChildrenStatus.counts) wherever the row is drawn
1493
- # again. Not read with status.line off; a failed read keeps the row.
1494
- def refresh_children_status
1495
- id = @engine.session&.id
1496
- return unless id && @status_row.enabled?
1497
-
1498
- @status_row.update(children: ChildrenStatus.counts(id, state_dir: @engine.peer_state_dir || Session.default_state_dir))
1499
- rescue StandardError => e
1500
- Log.debug(:repl, "children_counts_failed", error: e.class.name)
1501
- end
1502
-
1503
- # Reset the Engine's shared inactivity clock when a prompt opens (a
1504
- # Reline.pre_input_hook) and on each key typed (RelineSeam.key_handler). This is the
1505
- # single seam the Engine-owned idle recap detector reads, so the idle clock
1506
- # is identical for the REPL and any other Engine-backed UI. Chains onto any
1507
- # previously-installed hook (e.g. the input prefill hook).
1508
- def with_activity_hook
1509
- previous_hook = Reline.pre_input_hook
1510
- previous_key_handler = RelineSeam.key_handler
1511
- Reline.pre_input_hook = proc do
1512
- @engine.record_activity
1513
- previous_hook.call if previous_hook
1514
- end
1515
- # Typing is activity too: a reminder waits while you type.
1516
- RelineSeam.key_handler = -> { @engine.record_activity }
1517
- yield
1518
- ensure
1519
- Reline.pre_input_hook = previous_hook
1520
- RelineSeam.key_handler = previous_key_handler
1521
- end
1522
-
1523
- # ── Ask-user-question adapter (generic TUI renderer) ─────────────────────
1524
-
1525
- # Non-blocking observer: the turn thread emits :question_requested; we stash
1526
- # it so the REPL thread (the only one that may touch Reline) can drain it
1527
- # at the top of run_assist_loop without racing the completion.
1528
- def handle_question_event(event)
1529
- return unless event.is_a?(Hash)
1530
-
1531
- type = event[:type] || event["type"]
1532
- return unless type.to_s == "question_requested"
1533
-
1534
- pq = event[:pending_question] || event["pending_question"]
1535
- return unless pq
1536
-
1537
- @pending_question_event = pq
1538
- rescue StandardError
1539
- nil
1540
- end
1541
-
1542
- # Called from REPL thread (run_assist_loop top) — renders widget, blocks
1543
- # until user selects, then answers via Engine#answer_question which wakes
1544
- # the parked turn thread.
1545
- def drain_pending_question?
1546
- pq = @pending_question_event
1547
- # Also check Engine's persisted pending (covers resume)
1548
- pq ||= @engine.pending_question&.to_h
1549
- return false unless pq
1550
-
1551
- # If the pending no longer matches the engine's current pending (stale
1552
- # event after sync handler already answered and cleared), discard.
1553
- current = @engine.pending_question
1554
- if current && (pq[:id] || pq["id"]).to_s != current.id.to_s
1555
- @pending_question_event = nil
1556
- return false
1557
- end
1558
- if current.nil? && @pending_question_event
1559
- # Stale stash where engine already cleared (sync path completed)
1560
- @pending_question_event = nil
1561
- return false
1562
- end
1563
-
1564
- @pending_question_event = nil
1565
- result = render_question_widget(pq)
1566
- return false unless result
1567
-
1568
- # result is already answered via Engine#answer_question in render_question_widget
1569
- true
1570
- rescue StandardError => e
1571
- Log.warn(:repl, "question_drain_failed", echo: "[ask_user_question] drain failed: #{e.class}: #{e.message}", error: e.class.name)
1572
- false
451
+ Log.warn(:context, "candidates_one_shot_failed", error: e.class.name, msg: e.message.to_s[0, 200])
1573
452
  end
1574
453
 
1575
- # The choices wait in the notes slot, laid out for the rows it gets, and
1576
- # the answer is a line at the ? prompt. Once the question closes, one
1577
- # line (the question and what became of it) stays in the scrollback.
1578
- def render_question_widget(pending)
1579
- prompt = QuestionPrompt.new(pending)
1580
-
1581
- # The activity row goes while the question waits.
1582
- @view.finish_thinking_spinner
1583
- return dismiss_unaskable_question(prompt) if @non_interactive
1584
-
1585
- # An edit's diff goes above the slot, into the scrollback.
1586
- preview = prompt.preview_lines(paint: method(:paint))
1587
- @surface.commit(preview.join("\n")) unless preview.empty?
1588
- choices = prompt.slot(paint: method(:paint))
1589
-
1590
- question_prompt = paint(QUESTION_PROMPT, 33)
1591
- unless @repl_input
1592
- @surface.set_slot(:notes, choices)
1593
- return answer_question_widget(prompt) { read_choice_line(question_prompt) }
1594
- end
454
+ # The toolkit flags are a new session's: a resumed one keeps its own
455
+ # toolkit (as attached mode says it, AttachLauncher).
456
+ def warn_toolkit_flags_ignored(session)
457
+ return if @toolkit_flags.empty?
1595
458
 
1596
- # On a terminal the prompt is open (a turn runs with it): it turns into
1597
- # the ? prompt for the answer. Only a line submitted there answers,
1598
- # never one typed before the question came. The choices show once the
1599
- # typed text is out of the prompt.
1600
- @repl_input.ask(question_prompt) do |answers|
1601
- @surface.set_slot(:notes, choices)
1602
- answer_question_widget(prompt) { take_open_prompt_answer(answers) }
1603
- end
1604
- ensure
1605
- @surface.clear_slot(:notes)
459
+ names = @toolkit_flags.size > 1 ? "#{@toolkit_flags[0..-2].join(", ")} and #{@toolkit_flags.last}" : @toolkit_flags.first
460
+ warn "(#{names} #{@toolkit_flags.size == 1 ? "applies" : "apply"} to a new session; #{session.id} keeps its own)"
1606
461
  end
1607
462
 
1608
- # --non-interactive has no one to ask (as for an approval or a hook's
1609
- # question there): the question is dismissed at once, as an empty answer
1610
- # would be, and the model finishes its reply. Never a read of stdin: a
1611
- # pipe its writer keeps open would block it forever. Its one line goes to
1612
- # stderr (#banner): stdout is the answer's alone. The model is told no
1613
- # one could answer (QuestionDesk::UNANSWERABLE_NOTE), not that the user
1614
- # dismissed it.
463
+ # No one is here to ask (as for an approval or a hook's question): the
464
+ # question is dismissed at once, as an empty answer would be, and the
465
+ # model finishes its reply. Never a read of stdin: a pipe its writer
466
+ # keeps open would block it forever. Its one line goes to stderr. The
467
+ # model is told no one could answer (QuestionDesk::UNANSWERABLE_NOTE),
468
+ # not that the user dismissed it.
1615
469
  def dismiss_unaskable_question(prompt)
1616
- @engine.cancel_question(QuestionDesk::UNANSWERABLE_REASON) rescue nil
470
+ Degrade.call(:turn, "question_dismiss_failed") { @engine.cancel_question(QuestionDesk::UNANSWERABLE_REASON) }
1617
471
  banner(prompt.summary(QuestionPrompt::UNANSWERABLE_TEXT, paint: method(:paint)))
1618
472
  false
1619
473
  end
1620
-
1621
- # Loop until a valid selection or a cancel. The block reads one answer:
1622
- # a line, nil (Ctrl-D, or Ctrl-C with no turn), or :canceled (the turn
1623
- # was cancelled).
1624
- def answer_question_widget(prompt)
1625
- loop do
1626
- @choice_echoed = false
1627
- raw = begin
1628
- yield
1629
- rescue Interrupt
1630
- nil
1631
- end
1632
- raw = raw.to_s.strip unless raw.nil? || raw == :canceled
1633
- if raw.nil? || raw == :canceled || raw.empty?
1634
- # Empty, EOF / Ctrl-D / Ctrl-C -> cancel (an approval: denied)
1635
- @engine.cancel_question("user") rescue nil
1636
- outcome = if raw == :canceled then "(turn cancelled)"
1637
- elsif prompt.approval? then "(denied)"
1638
- else "(cancelled)"
1639
- end
1640
- close_question_widget(prompt, outcome)
1641
- return false
1642
- end
1643
-
1644
- answer = prompt.parse(raw)
1645
- @surface.commit(answer.note) if answer.note
1646
- unless answer.ok?
1647
- # The read left no echo: the line shows above its error.
1648
- echo_choice(raw)
1649
- @surface.commit(answer.error)
1650
- next
1651
- end
1652
-
1653
- begin
1654
- parent = as_parent? ? { client_id: ClientId::CLI_ANSWER } : {}
1655
- @engine.answer_question(id: prompt.id, selected: answer.selected, freeform: answer.freeform, **parent)
1656
- close_question_widget(prompt, prompt.answer_text(answer))
1657
- return true
1658
- rescue QuestionDesk::Refused => e
1659
- # A parent's pick its strategy doesn't let through
1660
- # (Guardrails::ParentPolicy): the question stays open for a deny
1661
- # (or the end of the input).
1662
- echo_choice(raw)
1663
- @surface.commit(e.detail(typed: true))
1664
- next
1665
- rescue ArgumentError => e
1666
- @surface.commit("Invalid: #{e.message}. Try again.")
1667
- next
1668
- rescue StandardError => e
1669
- close_question_widget(prompt, "(error: #{e.message})")
1670
- return false
1671
- end
1672
- end
1673
- end
1674
-
1675
- # Whether the answers typed here are a parent agent's (piped stdin, an
1676
- # agent marker): QuestionDesk holds them to Guardrails::ParentPolicy.
1677
- def as_parent?
1678
- Guardrails::ParentApprovals.parent_process?
1679
- end
1680
-
1681
- def close_question_widget(prompt, outcome)
1682
- @surface.clear_slot(:notes)
1683
- @surface.commit(prompt.summary(outcome, paint: method(:paint)))
1684
- end
1685
-
1686
- # A ? read of its own, off a terminal (specs, pipes): $stdin.gets.
1687
- def read_choice_line(question_prompt)
1688
- # Off a terminal the ? prompt can't be taken back: at the input's end
1689
- # it would stay bare above the summary. eof? waits as gets would.
1690
- return nil if !$stdin.tty? && $stdin.eof?
1691
-
1692
- @surface.set_slot(:editor, [question_prompt])
1693
- line = $stdin.gets
1694
- # A pipe doesn't echo: the line completes the ? prompt here, once,
1695
- # and the widget doesn't echo it again (#echo_choice).
1696
- unless $stdin.tty?
1697
- @surface.commit(line.to_s.chomp)
1698
- @choice_echoed = true
1699
- end
1700
- line
1701
- end
1702
-
1703
- # The answer line above its error, unless its read echoed it already.
1704
- def echo_choice(raw)
1705
- @surface.commit("#{paint(QUESTION_PROMPT, 33)}#{raw}") unless @choice_echoed
1706
- end
1707
-
1708
- # The next line submitted at the open prompt (?), nil for Ctrl-D
1709
- # or a Ctrl-C with no turn running, or :canceled when Ctrl-C cancels the
1710
- # running turn first (the read goes on; the question closes).
1711
- def take_open_prompt_answer(answers)
1712
- controller = @active_cancel_controller
1713
- loop do
1714
- kind, line = answers.pop(timeout: REMINDER_PENDING_POLL_INTERVAL)
1715
- return kind == :line ? line : nil if kind
1716
- return :canceled if controller&.cancelled?
1717
- end
1718
- end
1719
474
  end
1720
475
  end