samagotchi 0.5.1 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +77 -1
  3. data/bin/chi +32 -891
  4. data/docs/architecture.md +8 -1
  5. data/docs/cli.md +12 -13
  6. data/docs/configuration.md +24 -1
  7. data/docs/desktop.md +52 -1
  8. data/docs/hooks.md +69 -4
  9. data/docs/plugins.md +98 -3
  10. data/docs/sessions.md +3 -3
  11. data/lib/samagotchi/bridge/card_store.rb +30 -5
  12. data/lib/samagotchi/bridge/turn_accumulator.rb +7 -4
  13. data/lib/samagotchi/bridge/turn_notice.rb +37 -0
  14. data/lib/samagotchi/bundle_command.rb +652 -0
  15. data/lib/samagotchi/bundles/loop-guard/manifest.yml +4 -4
  16. data/lib/samagotchi/bundles/loop-guard/plugin.rb +214 -2
  17. data/lib/samagotchi/bundles/system/config_modification_protocol.md +3 -0
  18. data/lib/samagotchi/bundles/system/manifest.yml +3 -3
  19. data/lib/samagotchi/bundles/system/self_map.md +2 -1
  20. data/lib/samagotchi/cancellation_controller.rb +42 -1
  21. data/lib/samagotchi/config.rb +7 -2
  22. data/lib/samagotchi/desktop/macos/App.swift +5 -1
  23. data/lib/samagotchi/desktop/macos/ChiRunner.swift +27 -68
  24. data/lib/samagotchi/desktop/macos/Images.swift +38 -3
  25. data/lib/samagotchi/desktop/macos/Kitty.swift +346 -0
  26. data/lib/samagotchi/desktop/macos/Panel.swift +248 -55
  27. data/lib/samagotchi/desktop/macos/ProcessRunner.swift +74 -0
  28. data/lib/samagotchi/desktop/macos.rb +23 -8
  29. data/lib/samagotchi/desktop_command.rb +1 -1
  30. data/lib/samagotchi/engine.rb +138 -618
  31. data/lib/samagotchi/guardrail_wiring.rb +238 -0
  32. data/lib/samagotchi/hooks/registry.rb +11 -3
  33. data/lib/samagotchi/hooks/stream_watch.rb +93 -0
  34. data/lib/samagotchi/hooks.rb +3 -0
  35. data/lib/samagotchi/kernel_loop.rb +65 -6
  36. data/lib/samagotchi/llm/chat_loop.rb +73 -11
  37. data/lib/samagotchi/llm/openai_chat.rb +4 -2
  38. data/lib/samagotchi/log_subscriber.rb +10 -4
  39. data/lib/samagotchi/plugin/api.rb +8 -0
  40. data/lib/samagotchi/plugin/context.rb +12 -3
  41. data/lib/samagotchi/plugin_tasks.rb +229 -0
  42. data/lib/samagotchi/question_desk.rb +280 -0
  43. data/lib/samagotchi/recap_setup.rb +128 -0
  44. data/lib/samagotchi/session.rb +9 -1
  45. data/lib/samagotchi/sessions_command.rb +255 -0
  46. data/lib/samagotchi/steer.rb +19 -2
  47. data/lib/samagotchi/terminal_ui/attached_loop.rb +18 -5
  48. data/lib/samagotchi/terminal_ui/formatting.rb +4 -2
  49. data/lib/samagotchi/tool_declarations.rb +2 -2
  50. data/lib/samagotchi/tools/delegate.rb +4 -1
  51. data/lib/samagotchi/turn_note.rb +20 -1
  52. data/lib/samagotchi/update_command.rb +1 -1
  53. data/lib/samagotchi/version.rb +1 -1
  54. data/lib/samagotchi/web/app.rb +12 -8
  55. data/lib/samagotchi/web/public/app.js +94 -52
  56. data/lib/samagotchi/web/public/card.js +8 -1
  57. data/lib/samagotchi/web/public/format.js +1 -1
  58. data/lib/samagotchi/web/public/index.html +2 -5
  59. data/lib/samagotchi/web/public/stage_model.js +44 -0
  60. data/lib/samagotchi/web/public/stage_view.js +26 -17
  61. data/lib/samagotchi/web/public/timing.js +9 -3
  62. data/lib/samagotchi/web/public/turn_events.js +18 -5
  63. data/lib/samagotchi/web/public/turn_view.js +68 -16
  64. data/lib/samagotchi/web/server.rb +4 -3
  65. metadata +11 -2
  66. data/lib/samagotchi/web/public/chat_view.js +0 -376
@@ -14,6 +14,7 @@ module Samagotchi
14
14
  COMMAND_NAME = %r{\A/[a-z][a-z0-9_-]{0,31}\z}
15
15
  TOOL_NAME = /\A[a-z][a-z0-9_]{0,47}\z/
16
16
  PARAM_NAME = /\A[a-z_][a-z0-9_]*\z/i
17
+ FAILED_STREAM_HOOK_LOG_SECONDS = 60
17
18
 
18
19
  # @param bundle [String]
19
20
  # @param label [String] "<file> (bundle <name>)": what hooks and
@@ -222,10 +223,17 @@ module Samagotchi
222
223
  label = @label
223
224
  block = hook[:block]
224
225
  context = @context
226
+ # A stream hook fires ~once a second: a broken one logs once a minute.
227
+ quiet_for = hook[:event] == :generation_progress ? FAILED_STREAM_HOOK_LOG_SECONDS : 0
228
+ logged_at = nil
225
229
  @registries.hooks.register_bundle(bundle, hook[:event], hook_name: label.split(" ").first,
226
230
  priority: hook[:priority]) do |event|
227
231
  block.call(event, context)
228
232
  rescue StandardError => e
233
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
234
+ next if logged_at && now - logged_at < quiet_for
235
+
236
+ logged_at = now
229
237
  # Logged only: a turn's live region is on screen.
230
238
  Log.warn(:plugins, "plugin_hook_failed", bundle: bundle, event: hook[:event].to_s, error: e.class.name,
231
239
  msg: "#{label} #{hook[:event]} hook failed: #{e.message}")
@@ -18,10 +18,10 @@ module Samagotchi
18
18
  # the answer text.
19
19
  # +messages_partial+ says whether +messages+ leaves out a running turn;
20
20
  # +model_name+ and +state_dir+ are what ctx.sessions forks with.
21
- # +steer+ takes (text, source) and +stop_turn+ (reason, label), each
22
- # true when it acted on a running turn.
21
+ # +steer+ takes (text, source), +stop_turn+ and +stop_generation+
22
+ # (reason, label), each true when it acted on a running turn.
23
23
  Host = Struct.new(:session_id, :cwd, :messages, :messages_partial, :notify, :ask_user, :cancelled, :card,
24
- :ask_model, :model_name, :state_dir, :scratch, :steer, :stop_turn, keyword_init: true)
24
+ :ask_model, :model_name, :state_dir, :scratch, :steer, :stop_turn, :stop_generation, keyword_init: true)
25
25
 
26
26
  # ctx.ask_model failed: the model couldn't be reached, timed out, or
27
27
  # sent nothing usable. The message says why, for the user.
@@ -199,6 +199,15 @@ module Samagotchi
199
199
  !!@host.stop_turn&.call(reason.to_s, @label)
200
200
  end
201
201
 
202
+ # Cut the generation that is streaming, as a hook's
203
+ # event[:stop_generation] does: the turn goes on and the model is
204
+ # asked again (retry.empty_answer), or with no retry left the turn
205
+ # ends as cancelled (hook). Shows nothing: post your own notice.
206
+ # @return [Boolean] whether a streaming generation was cut now
207
+ def stop_generation(reason)
208
+ !!@host.stop_generation&.call(reason.to_s, @label)
209
+ end
210
+
202
211
  private
203
212
 
204
213
  def deep_freeze(value)
@@ -0,0 +1,229 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "cancellation_controller"
4
+ require_relative "log"
5
+ require_relative "plugin/api"
6
+
7
+ module Samagotchi
8
+ # An Engine's plugin init tasks (chi.init) and the tool sets plugins stage
9
+ # (chi.replace_tools) for the turn thread.
10
+ #
11
+ # A plugin's slow setup (docs/plugins.md, Init tasks): run on its own
12
+ # thread once the owner can show it (#start!), announced as
13
+ # plugin_init_started / plugin_init_finished unless quiet. A turn waits
14
+ # for the running ones that provide tools before its first model request
15
+ # (#await), up to each one's timeout.
16
+ #
17
+ # What it needs from the Engine comes through lookups, called at use.
18
+ # Lock order: the Engine's lifecycle lock may be held when this one is
19
+ # taken (#shut_down!), the event lock too (#running); this lock is never
20
+ # held while the event lock is taken.
21
+ class PluginTasks
22
+ # +cancel+ is the task's own controller, cancelled only by #shut_down!:
23
+ # a Ctrl-C ends a turn's wait, not the task.
24
+ InitTask = Struct.new(:id, :bundle, :label, :plugin_label, :provides_tools, :quiet, :timeout, :failed, :block,
25
+ :cancel, :thread, :state, :started_at, keyword_init: true) do
26
+ # What the task's block reads: whether chi is shutting down.
27
+ def cancelled? = cancel.cancelled?
28
+ end
29
+
30
+ # How long a task may hold a turn when it gives no timeout.
31
+ INIT_TASK_TIMEOUT = 60.0
32
+
33
+ INIT_WAIT_POLL = 0.05
34
+
35
+ # @param clock [#call] → Float, monotonic seconds
36
+ # @param synchronize_events [#call] (&block) → runs the block holding the event log
37
+ # @param announce [#call] (event) → announces an event
38
+ # @param emit [#call] (sink, event) → emits to a turn's sink + observers
39
+ # @param show_card [#call] (**card) → shows a card
40
+ # @param tools [#call] → Tools::Registry
41
+ # @param notify [#call] (text, level, source) → a notice
42
+ # @param tools_changed [#call] → the tools changed (prompts built again)
43
+ def initialize(clock:, synchronize_events:, announce:, emit:, show_card:, tools:, notify:, tools_changed:)
44
+ @clock = clock
45
+ @synchronize_events = synchronize_events
46
+ @announce = announce
47
+ @emit = emit
48
+ @show_card = show_card
49
+ @tools = tools
50
+ @notify = notify
51
+ @tools_changed = tools_changed
52
+ @mutex = Mutex.new
53
+ @shut_down = false
54
+ @tasks = []
55
+ # Plugins' tool sets from chi.replace_tools, by bundle, until the
56
+ # turn thread applies them (#apply_staged_tools!).
57
+ @staged_tools = {}
58
+ end
59
+
60
+ # Add a plugin's init task (Plugin::Api#init at commit); it starts with
61
+ # #start!.
62
+ def add(bundle:, label:, plugin_label:, provides_tools:, quiet:, timeout:, failed: nil, &block)
63
+ @mutex.synchronize do
64
+ @tasks << InitTask.new(id: "#{bundle}-#{@tasks.size + 1}", bundle: bundle.to_s, label: label.to_s,
65
+ plugin_label: plugin_label, provides_tools: provides_tools ? true : false,
66
+ quiet: quiet ? true : false, timeout: timeout || INIT_TASK_TIMEOUT, failed: failed,
67
+ block: block, cancel: CancellationController.new, state: :pending)
68
+ end
69
+ nil
70
+ end
71
+
72
+ # Start the init tasks not started yet, each on its own thread. The
73
+ # worker calls it once its Bridge is up, the REPL once it renders
74
+ # events, and every turn (a -p run has only that); later calls start
75
+ # nothing new.
76
+ def start!
77
+ @mutex.synchronize do
78
+ return if @shut_down
79
+
80
+ @tasks.each do |task|
81
+ next unless task.state == :pending
82
+
83
+ task.state = :starting
84
+ task.started_at = @clock.call
85
+ task.thread = Thread.new { run(task) }
86
+ task.thread.report_on_exception = false
87
+ end
88
+ end
89
+ nil
90
+ end
91
+
92
+ # The running init tasks a UI shows (not the quiet ones), for a UI that
93
+ # joins while they run (Bridge#snapshot, with the event log held).
94
+ # @return [Array<Hash>] {bundle:, id:, label:}
95
+ def running
96
+ @mutex.synchronize do
97
+ @tasks.select { |task| task.state == :running && !task.quiet }
98
+ .map { |task| { bundle: task.bundle, id: task.id, label: task.label } }
99
+ end
100
+ end
101
+
102
+ # Wait for the running init tasks that provide tools, each up to its
103
+ # timeout from its start, while +controller+ isn't cancelled; tell the
104
+ # turn's sink what it waits for (:plugin_init_wait). A task that ends
105
+ # late or fails leaves the turn without its tools.
106
+ # @return [Boolean] whether it waited
107
+ def await(controller = nil, on_event = nil)
108
+ waiting = @mutex.synchronize do
109
+ @tasks.select { |task| task.provides_tools && %i[starting running].include?(task.state) }
110
+ end
111
+ return false if waiting.empty?
112
+
113
+ @emit.call(on_event, { type: :plugin_init_wait,
114
+ tasks: waiting.map { |task| { bundle: task.bundle, id: task.id, label: task.label } } })
115
+ started = @clock.call
116
+ loop do
117
+ now = @clock.call
118
+ left = waiting.select { |task| %i[starting running].include?(task.state) && now < task.started_at + task.timeout }
119
+ break if left.empty? || controller&.cancelled?
120
+
121
+ sleep(INIT_WAIT_POLL)
122
+ end
123
+ Log.info(:plugins, "init_wait", ms: ((@clock.call - started) * 1000).round,
124
+ cancelled: controller&.cancelled? ? true : nil)
125
+ true
126
+ end
127
+
128
+ # The init task this thread runs, or nil.
129
+ def current = Thread.current[:"samagotchi_init_#{object_id}"]
130
+
131
+ # Keep a plugin's new tool set (chi.replace_tools, from any thread)
132
+ # for the turn thread, which applies it (#apply_staged_tools!): the
133
+ # registry is read only there. A later set of the same bundle wins.
134
+ def stage_tools(bundle, specs, context)
135
+ @mutex.synchronize do
136
+ return if @shut_down
137
+
138
+ @staged_tools[bundle] = [specs, context]
139
+ end
140
+ nil
141
+ end
142
+
143
+ # Apply the staged tool sets (#stage_tools), on the turn thread, before
144
+ # the turn's system prompt is built; the prompts are built again when
145
+ # a set changed anything. A name another source has is left out with a
146
+ # notice.
147
+ # @return [Boolean] whether the tools changed
148
+ def apply_staged_tools!
149
+ staged = @mutex.synchronize do
150
+ taken = @staged_tools
151
+ @staged_tools = {}
152
+ taken
153
+ end
154
+ changed = false
155
+ staged.each do |bundle, (specs, context)|
156
+ result = Plugin::Api.apply_tools(@tools.call, bundle, specs, context)
157
+ changed ||= result[:changed]
158
+ result[:skipped].each do |why|
159
+ Log.warn(:plugins, "plugin_tool_skipped", bundle: bundle, msg: why)
160
+ @notify.call("#{why}; left out", :warn, bundle)
161
+ end
162
+ Log.info(:plugins, "plugin_tools_replaced", bundle: bundle, tools: specs.size) if result[:changed]
163
+ end
164
+ @tools_changed.call if changed
165
+ changed
166
+ end
167
+
168
+ # Shutting down (Engine#shutdown, under its lifecycle lock): nothing
169
+ # starts or stages after this; an init task's requests end (a server's
170
+ # boot), so it finishes.
171
+ # @return [Array<Thread>] the task threads, to join
172
+ def shut_down!
173
+ @mutex.synchronize do
174
+ @shut_down = true
175
+ @tasks.each { |task| task.cancel.cancel!(:shutdown) }
176
+ @tasks.filter_map(&:thread)
177
+ end
178
+ end
179
+
180
+ # After the joins: the task threads still alive are killed.
181
+ def kill_leftovers
182
+ @tasks.each { |task| task.thread&.kill if task.thread&.alive? }
183
+ end
184
+
185
+ # @return [Array<InitTask>] a copy of the task list (specs)
186
+ def tasks
187
+ @mutex.synchronize { @tasks.dup }
188
+ end
189
+
190
+ private
191
+
192
+ def run(task)
193
+ Thread.current[:"samagotchi_init_#{object_id}"] = task
194
+ @synchronize_events.call do
195
+ task.state = :running
196
+ @announce.call({ type: :plugin_init_started, bundle: task.bundle, id: task.id, label: task.label }) unless task.quiet
197
+ end
198
+ Log.info(:plugins, "init_started", bundle: task.bundle, id: task.id, label: task.label)
199
+ summary = task.block.call(task)
200
+ finish(task, ok: true, summary: summary.is_a?(String) ? summary : nil)
201
+ rescue StandardError => e
202
+ finish(task, ok: false, error: e.message)
203
+ end
204
+
205
+ def finish(task, ok:, summary: nil, error: nil)
206
+ Log.public_send(ok ? :info : :warn, :plugins, "init_finished", bundle: task.bundle, id: task.id, ok: ok,
207
+ ms: ((@clock.call - task.started_at) * 1000).round,
208
+ msg: error)
209
+ shut_down = @mutex.synchronize { @shut_down }
210
+ @synchronize_events.call do
211
+ task.state = ok ? :done : :failed
212
+ next if shut_down || (task.quiet && ok)
213
+
214
+ unless task.quiet
215
+ @announce.call({ type: :plugin_init_finished, bundle: task.bundle, id: task.id, label: task.label, ok: ok,
216
+ summary: summary, error: error }.compact)
217
+ end
218
+ # A failure stays on screen (and for a UI that joins later) as a
219
+ # card: a short title (the card shows the bundle beside it), the
220
+ # detail in the body.
221
+ unless ok
222
+ title = task.failed || "setup failed"
223
+ body = task.failed ? error.to_s : "#{task.label}: #{error}"
224
+ @show_card.call(source: task.bundle, title: title, body: body, level: :warn, id: "init-#{task.id}")
225
+ end
226
+ end
227
+ end
228
+ end
229
+ end
@@ -0,0 +1,280 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "monitor"
5
+ require "securerandom"
6
+ require "time"
7
+
8
+ require_relative "log"
9
+ require_relative "tools/ask_user_question"
10
+
11
+ module Samagotchi
12
+ # An Engine's question flow: the one open question (ask_user_question, a
13
+ # hook's ask_user, a guardrail approval), its answer and its cancel, shared
14
+ # by every UI. The turn thread blocks in #open_question; the UIs answer or
15
+ # cancel from theirs (a REPL answers inline through the sync handler).
16
+ #
17
+ # What it needs from the Engine comes through lookups, read at call time.
18
+ class QuestionDesk
19
+ # Raised by #answer when the question it targets is no longer open
20
+ # (never asked, superseded, already answered or cancelled). A subclass
21
+ # of ArgumentError for existing callers; transports map it to 409 Conflict.
22
+ class NotPending < ArgumentError; end
23
+
24
+ DISMISSED_NOTE = "The user dismissed the question without answering. Go on with your best judgement, " \
25
+ "or ask in your reply if you can't."
26
+
27
+ # @param session [#call] → Session, nil
28
+ # @param state_dir [#call] → String, the session's state dir
29
+ # @param emit [#call] (event) → emits an event to the turn sink + observers
30
+ # @param cancel_controller [#call] → CancellationController, nil (the running turn's)
31
+ # @param interface [#call] → Symbol, the Engine's interface
32
+ # @param user_input [#call] (session_id) → a human answered (ArchiveStore)
33
+ def initialize(session:, state_dir:, emit:, cancel_controller:, interface:, user_input:)
34
+ @session_lookup = session
35
+ @state_dir_lookup = state_dir
36
+ @emit = emit
37
+ @cancel_controller_lookup = cancel_controller
38
+ @interface_lookup = interface
39
+ @user_input = user_input
40
+ # A Monitor: re-entered by the specs from the same thread.
41
+ @lock = Monitor.new
42
+ @cv = @lock.new_cond
43
+ @pending = nil
44
+ @answer = nil
45
+ @sync_handler = nil
46
+ end
47
+
48
+ # @return [Hash, nil] current pending question (thread-safe copy)
49
+ def pending
50
+ @lock.synchronize { @pending&.dup }
51
+ end
52
+
53
+ # Ask the model's question (ask_user_question): the kernel's
54
+ # question_handler, called on the turn thread with the payload
55
+ # Tools::AskUserQuestion.validate made. Opens it (#open_question) and
56
+ # returns the answer as JSON for the tool result.
57
+ # @param payload [Hash] {question:, options:, header:, multi_select:, allow_freeform:}
58
+ # @return [String] normalized answer JSON
59
+ def request(payload)
60
+ result = open_question(**payload.slice(:question, :options, :header),
61
+ multi_select: !!payload[:multi_select], allow_freeform: !!payload[:allow_freeform])
62
+ # Dismissed (the card's dismiss, Esc): an answer of its own, not a
63
+ # tool failure the model learns to avoid the tool from.
64
+ result = { dismissed: true, id: result[:id], note: DISMISSED_NOTE } if result.is_a?(Hash) && result[:error] == "no answer"
65
+ result.is_a?(String) ? result : JSON.generate(result)
66
+ end
67
+
68
+ # Open a question for the UIs and wait for its answer. Emits
69
+ # :question_requested, persists it to the session, and BLOCKS until
70
+ # answer / cancel wakes it (or the turn is cancelled).
71
+ # The fields go to pending as given (no cleaning), extra keys
72
+ # included, so a caller can add its own (kind:, approval:).
73
+ # @param fields [Hash] question:, options:, header:, multi_select:, allow_freeform:, …
74
+ # @return [Hash, String] the answer {id:, selected:, freeform:, selected_indices:},
75
+ # or {error:, …}; a String when a sync handler returned text itself
76
+ def open_question(fields)
77
+ id = SecureRandom.uuid
78
+ pending = { id: id, **fields, status: "pending", created_at: Time.now.iso8601(3) }.compact
79
+
80
+ @lock.synchronize do
81
+ @pending = pending
82
+ @answer = nil
83
+ end
84
+ # Persist to session file for WEB stub + resume (generic for all UIs)
85
+ if session
86
+ session.pending_question = pending.dup
87
+ begin; session.save(state_dir: state_dir); rescue StandardError; nil; end
88
+ end
89
+ # Generic emit for all UIs (TUI, WEB, Bridge, future). Observers that
90
+ # stash this event (e.g. TerminalUI handle_question_event) will
91
+ # discard it as stale if the synchronous handler below already answers
92
+ # and clears pending — see drain_pending_question? staleness check.
93
+ emit({ type: :question_requested, pending_question: pending })
94
+
95
+ # If a synchronous UI handler is registered (TUI), invoke it inline on the
96
+ # SAME thread that called request_question (TerminalUI's REPL thread is the
97
+ # turn thread — no second thread exists to answer). This avoids deadlock.
98
+ # This path is TUI-specific but the surrounding emit/clear is generic, so
99
+ # any future UI that registers a sync handler gets the same guarantee.
100
+ if @sync_handler
101
+ begin
102
+ sync_res = @sync_handler.call(pending.dup)
103
+ # Handler may have called answer_question or returned a hash/string.
104
+ # Decided under the lock; saved and announced after it (never emit
105
+ # holding the question lock: a snapshot holds the event lock and
106
+ # then reads #pending).
107
+ ans = @lock.synchronize do
108
+ if @answer
109
+ @pending = nil
110
+ @answer
111
+ elsif sync_res.is_a?(Hash) && sync_res[:selected]
112
+ # Treat returned hash as answer (handler rendered and parsed)
113
+ @answer = sync_res
114
+ @pending = nil
115
+ sync_res
116
+ end
117
+ end
118
+ if ans
119
+ clear_saved_question
120
+ emit({ type: :question_answered, id: id, answer: ans })
121
+ return ans
122
+ end
123
+ return sync_res if sync_res.is_a?(String) && !sync_res.strip.empty?
124
+ rescue StandardError => e
125
+ Log.warn(:turn, "question_handler_failed", echo: "[ask_user_question] sync handler failed: #{e.message}", error: e.class.name)
126
+ end
127
+ # Sync handler existed but did not produce an answer — do not deadlock on
128
+ # CV (no cross-thread answerer exists for synchronous UIs). Clear pending
129
+ # and return an error so the model can fallback to plain text. Generic
130
+ # observers will discard the stale question_requested via staleness check.
131
+ @lock.synchronize { @pending = nil }
132
+ clear_saved_question
133
+ return { error: "no answer", detail: "handler failed to capture selection", id: id }
134
+ end
135
+
136
+ # Block until answered/cancelled (cross-thread path: WEB/Bridge/background worker)
137
+ answer = nil
138
+ cancelled_reason = nil
139
+ @lock.synchronize do
140
+ loop do
141
+ break if @answer
142
+ break if cancel_controller&.cancelled?
143
+ break if @pending.nil? || @pending[:status] != "pending"
144
+
145
+ # Wait with timeout to check cancel; 0.2s matches reminder poll
146
+ @cv.wait(0.2)
147
+ end
148
+ answer = @answer
149
+ controller = cancel_controller
150
+ cancelled_reason = controller.reason.to_s if answer.nil? && controller&.cancelled?
151
+ @pending = nil
152
+ end
153
+ # Saved and announced outside the question lock (see the sync path).
154
+ clear_saved_question
155
+ if cancelled_reason
156
+ emit({ type: :question_cancelled, id: id, reason: cancelled_reason })
157
+ return { error: "cancelled", reason: cancelled_reason, id: id }
158
+ end
159
+
160
+ if answer
161
+ emit({ type: :question_answered, id: id, answer: answer })
162
+ answer
163
+ else
164
+ { error: "no answer", id: id }
165
+ end
166
+ end
167
+
168
+ # Answer the pending question (called from UI thread).
169
+ # @param id [String] pending id
170
+ # @param selected [Array<String>] values/labels
171
+ # @param freeform [String, nil]
172
+ # @return [Hash] normalized answer
173
+ def answer(id:, selected:, freeform: nil)
174
+ sel = Array(selected).map { |v| v.to_s.strip }.reject(&:empty?)
175
+ fm = freeform.to_s.strip
176
+ fm = nil if fm.empty?
177
+ @lock.synchronize do
178
+ pending = @pending
179
+ raise NotPending, "no pending question" unless pending
180
+ raise NotPending, "id mismatch" unless pending[:id].to_s == id.to_s
181
+ # First responder wins: after the first answer the turn thread clears
182
+ # @pending in a later lock block, so a second UI's answer can
183
+ # land in between and must not overwrite the first.
184
+ raise NotPending, "question already answered" if @answer
185
+ raise NotPending, "question #{pending[:status]}" unless pending[:status].to_s == "pending"
186
+
187
+ opts = Array(pending[:options])
188
+ # Validate selected subset of options (value == label in v1)
189
+ invalid = sel.reject { |v| opts.include?(v) }
190
+ unless invalid.empty?
191
+ raise ArgumentError, "invalid selection: #{invalid.join(', ')} (valid: #{opts.join(', ')})"
192
+ end
193
+ if !pending[:multi_select] && sel.size > 1
194
+ raise ArgumentError, "single-select question: got #{sel.size} selections"
195
+ end
196
+ if pending[:multi_select] == false && sel.empty? && fm.nil?
197
+ raise ArgumentError, "selection required"
198
+ end
199
+ # Persist pending cleared elsewhere; just set answer
200
+ answer = { id: id.to_s, selected: sel, freeform: fm }
201
+ # Derive indices for convenience
202
+ answer[:selected_indices] = sel.map { |v| opts.index(v) }.compact
203
+ @answer = answer
204
+ @cv.broadcast
205
+ answer
206
+ end.tap do
207
+ # A human answered: the session is back in the lists (ArchiveStore).
208
+ @user_input.call(session&.id)
209
+ end
210
+ end
211
+
212
+ def sync_handler=(block)
213
+ @sync_handler = block
214
+ end
215
+
216
+ # Cancel the pending question (e.g. /cancel, a dismiss). Announces which
217
+ # one, so every UI closes it; with none pending there is nothing to
218
+ # announce. A question already answered (the turn thread hasn't taken the
219
+ # answer yet) or already closed stays as it is: the first responder wins.
220
+ # @param id [String, nil] cancel only this question (a UI's dismiss
221
+ # names the one it showed)
222
+ # @return [Boolean] whether it was cancelled (true with none pending and
223
+ # no id, as before)
224
+ def cancel(reason = "user", id: nil)
225
+ cancelled_id = @lock.synchronize do
226
+ pending = @pending
227
+ next unless pending
228
+ next if id && pending[:id].to_s != id.to_s
229
+ next if @answer || pending[:status].to_s != "pending"
230
+
231
+ pending[:status] = "cancelled"
232
+ @cv.broadcast
233
+ pending[:id]
234
+ end
235
+ return id.nil? && self.pending.nil? unless cancelled_id
236
+
237
+ emit({ type: :question_cancelled, id: cancelled_id, reason: reason.to_s }) rescue nil
238
+ true
239
+ end
240
+
241
+ # A question through the question flow (REPL sync handler, attached TUI,
242
+ # web), single-select, kind "hook". A --non-interactive run has no one
243
+ # to ask: nil at once. Anything but an answer (a sync handler's text, no
244
+ # answer, cancelled) is nil too.
245
+ # @return [Hash, nil] {selected:, freeform:, selected_indices:}
246
+ def ask_for_hook(question, options, header, allow_freeform, hook)
247
+ return nil if @interface_lookup.call == :non_interactive
248
+
249
+ opts = Tools::AskUserQuestion.normalize_options(options)
250
+ unless opts
251
+ Log.warn(:hooks, "ask_user_invalid", echo: "[samagotchi:hooks] #{hook} asked with invalid options (2-8 strings)", hook: hook.to_s)
252
+ return nil
253
+ end
254
+
255
+ fields = { question: question.to_s, options: opts, header: header, multi_select: false,
256
+ allow_freeform: !!allow_freeform, kind: "hook", hook: hook.to_s }.compact
257
+ answer = open_question(fields)
258
+ return nil unless answer.is_a?(Hash) && answer[:selected]
259
+
260
+ result = { selected: Array(answer[:selected]), freeform: answer[:freeform] }
261
+ result[:selected_indices] = answer[:selected_indices] if answer.key?(:selected_indices)
262
+ result
263
+ end
264
+
265
+ private
266
+
267
+ def session = @session_lookup.call
268
+ def state_dir = @state_dir_lookup.call
269
+ def cancel_controller = @cancel_controller_lookup.call
270
+ def emit(event) = @emit.call(event)
271
+
272
+ # The session file no longer holds a pending question.
273
+ def clear_saved_question
274
+ return unless session
275
+
276
+ session.pending_question = nil
277
+ begin; session.save(state_dir: state_dir); rescue StandardError; nil; end
278
+ end
279
+ end
280
+ end