samagotchi 0.12.0 → 0.14.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 (82) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +116 -1
  3. data/README.md +20 -7
  4. data/bin/chi +9 -9
  5. data/docs/architecture.md +4 -2
  6. data/docs/cli.md +34 -10
  7. data/docs/configuration.md +79 -15
  8. data/docs/desktop.md +10 -6
  9. data/docs/guardrails.md +74 -7
  10. data/docs/hooks.md +13 -9
  11. data/docs/memory.md +5 -3
  12. data/docs/plugins.md +31 -16
  13. data/docs/sessions.md +9 -6
  14. data/docs/sub-agent.md +128 -0
  15. data/lib/samagotchi/answer_command.rb +220 -0
  16. data/lib/samagotchi/bridge/card_store.rb +94 -6
  17. data/lib/samagotchi/bridge.rb +14 -2
  18. data/lib/samagotchi/bridge_client.rb +8 -3
  19. data/lib/samagotchi/bundle_command.rb +18 -10
  20. data/lib/samagotchi/bundles/check-in/manifest.yml +2 -2
  21. data/lib/samagotchi/bundles/check-in/plugin.rb +68 -5
  22. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +19 -0
  23. data/lib/samagotchi/bundles/guardrails/manifest.yml +2 -2
  24. data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
  25. data/lib/samagotchi/bundles/mcp/plugin.rb +166 -34
  26. data/lib/samagotchi/bundles/system/config_modification_protocol.md +5 -5
  27. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  28. data/lib/samagotchi/cli/command.rb +3 -2
  29. data/lib/samagotchi/cli/exit.rb +23 -0
  30. data/lib/samagotchi/cli/parent_wait.rb +82 -0
  31. data/lib/samagotchi/config.rb +81 -24
  32. data/lib/samagotchi/context_window.rb +30 -5
  33. data/lib/samagotchi/desktop_command.rb +5 -3
  34. data/lib/samagotchi/engine.rb +54 -17
  35. data/lib/samagotchi/guardrails/parent_approvals.rb +158 -0
  36. data/lib/samagotchi/hooks/bundle_loader.rb +10 -3
  37. data/lib/samagotchi/host_registry.rb +31 -7
  38. data/lib/samagotchi/kernel_loop.rb +1 -1
  39. data/lib/samagotchi/llm/chat_loop.rb +5 -3
  40. data/lib/samagotchi/llm/openai_chat.rb +9 -1
  41. data/lib/samagotchi/llm/turn_settings.rb +7 -5
  42. data/lib/samagotchi/memory_bundle/installer.rb +12 -0
  43. data/lib/samagotchi/memory_bundle/status.rb +9 -1
  44. data/lib/samagotchi/memory_paths.rb +8 -0
  45. data/lib/samagotchi/model_profile.rb +30 -16
  46. data/lib/samagotchi/parent_report.rb +213 -0
  47. data/lib/samagotchi/plugin/api.rb +15 -2
  48. data/lib/samagotchi/prompt_literal_guard.rb +4 -2
  49. data/lib/samagotchi/question_desk.rb +29 -2
  50. data/lib/samagotchi/recap_setup.rb +15 -5
  51. data/lib/samagotchi/reply_wait.rb +5 -1
  52. data/lib/samagotchi/send_command.rb +50 -75
  53. data/lib/samagotchi/session.rb +15 -0
  54. data/lib/samagotchi/session_manager.rb +40 -7
  55. data/lib/samagotchi/sessions_command.rb +13 -3
  56. data/lib/samagotchi/system_prompt.rb +11 -3
  57. data/lib/samagotchi/terminal_ui/attach_launcher.rb +33 -5
  58. data/lib/samagotchi/terminal_ui/attached_loop.rb +53 -14
  59. data/lib/samagotchi/terminal_ui.rb +30 -3
  60. data/lib/samagotchi/thought_stream_splitter.rb +4 -4
  61. data/lib/samagotchi/tool_call_parser.rb +5 -6
  62. data/lib/samagotchi/tools/builtin_calls.rb +14 -3
  63. data/lib/samagotchi/tools/builtins.rb +14 -2
  64. data/lib/samagotchi/tools/delegate_wait.rb +32 -13
  65. data/lib/samagotchi/tools/execute.rb +8 -8
  66. data/lib/samagotchi/tools/memory.rb +3 -3
  67. data/lib/samagotchi/tools/task_create.rb +3 -2
  68. data/lib/samagotchi/tools/task_runtime.rb +7 -2
  69. data/lib/samagotchi/utf8_default.rb +29 -0
  70. data/lib/samagotchi/version.rb +1 -1
  71. data/lib/samagotchi/web/app.rb +22 -10
  72. data/lib/samagotchi/web/message_parts.rb +2 -1
  73. data/lib/samagotchi/web/public/activity.js +6 -1
  74. data/lib/samagotchi/web/public/app.js +39 -6
  75. data/lib/samagotchi/web/public/index.html +8 -1
  76. data/lib/samagotchi/web/public/notify.js +16 -3
  77. data/lib/samagotchi/web/public/question_cards.js +3 -1
  78. data/lib/samagotchi/web/public/sessions_list.js +37 -0
  79. data/lib/samagotchi/web/public/turn_view.js +6 -9
  80. data/lib/samagotchi/web/server.rb +2 -1
  81. data/lib/samagotchi/web/session_summary.rb +3 -11
  82. metadata +8 -1
@@ -12,6 +12,7 @@ require_relative "config"
12
12
  require_relative "session"
13
13
  require_relative "session_inbox"
14
14
  require_relative "session_metrics"
15
+ require_relative "utf8_default"
15
16
  require_relative "turn_note"
16
17
  require_relative "owner_lock"
17
18
  require_relative "bridge_client"
@@ -162,7 +163,9 @@ module Samagotchi
162
163
  # child inherits this process's ENV and reads config.yml itself (as it
163
164
  # is when the worker starts); opts[:env] adds to it (merged, not
164
165
  # replaced) what it can't read there: this `chi`'s CLI settings
165
- # (Config.cli_env), the hosts and the absolute log file.
166
+ # (Config.cli_env), the hosts and the absolute log file. It unsets
167
+ # SAMAGOTCHI_GUARDRAILS_*. XDG_CONFIG_HOME passes: whoever starts or
168
+ # wakes the worker picks which config.yml it reads (docs/guardrails.md).
166
169
  private_class_method def self.spawn_options(session)
167
170
  # Own process group: workers outlive `chi web`, and a Ctrl-C in its
168
171
  # terminal must not reach them.
@@ -195,10 +198,17 @@ module Samagotchi
195
198
  else
196
199
  child_env["SAMAGOTCHI_LOG_DISABLE"] = "true"
197
200
  end
201
+ # The spawner's environment must not reach the worker's guardrails (a
202
+ # parent agent starting or waking a session): Process.spawn unsets a
203
+ # key mapped to nil. config.yml no longer reads them; this keeps an
204
+ # older or future env-exposed one out as well.
205
+ ENV.each_key { |key| child_env[key] = nil if key.start_with?(GUARDRAILS_ENV_PREFIX) }
198
206
  opts[:env] = child_env unless child_env.empty?
199
207
  opts
200
208
  end
201
209
 
210
+ GUARDRAILS_ENV_PREFIX = "SAMAGOTCHI_GUARDRAILS_"
211
+
202
212
  # List all sessions, reading status from persisted session.json files.
203
213
  # +project_root+: only that project's sessions (nil: every session).
204
214
  # +include_archived+: archived sessions too (Session#archived says which).
@@ -226,9 +236,11 @@ module Samagotchi
226
236
  # @param include_archived [Boolean] archived sessions too
227
237
  # @return [Array<Hash>] {id:, short_id:, desc:, preview:, cwd:, project:,
228
238
  # updated_at:, status:, live:, busy:, owner:, recap:, parent_id:,
229
- # parent_short_id:, archived:, scratch:, test_run:}; busy = live with
239
+ # parent_short_id:, archived:, scratch:, test_run:, waiting:, waiting_id:}; busy = live with
230
240
  # a turn running, recap = the saved recap's first sentence, project =
231
- # Session#project_root
241
+ # Session#project_root, waiting = the kind of question it waits on
242
+ # ("question", "approval", "hook"; Session#waiting_question) or nil,
243
+ # waiting_id = that question's id or nil
232
244
  def self.session_summaries(live: false, cwd: nil, limit: nil, include_tests: true, exclude: nil, state_dir: nil,
233
245
  project_root: nil, include_archived: false, sort: nil, order: nil)
234
246
  sd = state_dir || Session.default_state_dir
@@ -239,20 +251,27 @@ module Samagotchi
239
251
  .reject { |s| (!include_tests && s.test_run) || s.id == exclude }
240
252
  .select { |s| root.nil? || in_folder?(s.working_directory, root) }
241
253
  .filter_map do |s|
242
- owner = session_owner(s.id, state_dir: sd)&.kind
243
- owned = owner == "worker"
254
+ owner = session_owner(s.id, state_dir: sd)
255
+ owned = !!owner&.worker?
244
256
  next if live && !owned
245
257
 
246
258
  { id: s.id, short_id: s.id[0, 8], desc: summary_desc(s), preview: summary_preview(s), cwd: s.working_directory,
247
259
  project: s.project_root(cache: roots), updated_at: s.updated_at, status: s.status, live: owned, busy: owned && s.status == Session::STATUS_RUNNING,
248
- owner: owner, recap: RecapStore.preview(Session.session_dir(s.id, state_dir: sd)),
260
+ owner: owner&.kind, recap: RecapStore.preview(Session.session_dir(s.id, state_dir: sd)),
249
261
  ctx_pct: SessionMetrics.saved_context_pct(Session.session_dir(s.id, state_dir: sd))&.round(1),
250
262
  parent_id: s.parent_id, parent_short_id: s.parent_id&.[](0, 8), archived: s.archived,
251
- scratch: s.scratch, test_run: s.test_run }
263
+ scratch: s.scratch, test_run: s.test_run }.merge(waiting_fields(s.waiting_question(live: owned)))
252
264
  end
253
265
  (limit ? summaries.first(limit) : summaries.to_a)
254
266
  end
255
267
 
268
+ # waiting: the question's kind, waiting_id: its id (what chi answer
269
+ # --question takes); both nil with none.
270
+ def self.waiting_fields(waiting)
271
+ { waiting: waiting&.dig(:kind), waiting_id: waiting&.dig(:id) }
272
+ end
273
+ private_class_method :waiting_fields
274
+
256
275
  # The sessions delegated by +parent_id+ (the `delegate` tool), newest
257
276
  # first, as .session_summaries rows. A running one (busy) counts against
258
277
  # session.max_children. Archived ones too.
@@ -534,6 +553,9 @@ module Samagotchi
534
553
  File.directory?(path) && (!EMPTY_DIRS.include?(name) || Dir.children(path).empty?)
535
554
  elsif name == "analytics.json"
536
555
  Array(JSON.parse(File.read(path))["turn_records"]).empty?
556
+ # Cards and notices from before any turn (Bridge::CardStore).
557
+ elsif name == "cards.json"
558
+ JSON.parse(File.read(path))["turns"].to_i.zero?
537
559
  else
538
560
  EMPTY_SKELETON_FILES.include?(name)
539
561
  end
@@ -613,6 +635,8 @@ module Samagotchi
613
635
  # @return [Symbol] :idle_exit or :exit_requested
614
636
  def self.run_session_loop(session_id, state_dir: nil, owner_wait: OwnerLock::DEFAULT_WAIT,
615
637
  idle_exit_minutes: nil, poll_interval: nil)
638
+ # A worker inherits chi's locale (LC_ALL=C too): read files as UTF-8.
639
+ Utf8Default.apply!
616
640
  sd = state_dir || Session.default_state_dir
617
641
  # The spawner passed the file and level through ENV; a worker's stderr
618
642
  # is /dev/null, so warnings only reach the file.
@@ -831,6 +855,15 @@ module Samagotchi
831
855
  OwnerLock.owner(Session.session_dir(session_id, state_dir: state_dir || Session.default_state_dir))
832
856
  end
833
857
 
858
+ # A worker owns the session now: the one rule for whether a saved
859
+ # pending question waits for anyone (Session#waiting_question's live:).
860
+ # A chi REPL's question is its own (nothing else can answer it), and one
861
+ # a dead worker saved waits for no one.
862
+ # @return [Boolean]
863
+ def self.worker_live?(session_id, state_dir: nil)
864
+ !!session_owner(session_id, state_dir: state_dir)&.worker?
865
+ end
866
+
834
867
  # The session's owner (#session_owner), unless it is the interactive
835
868
  # TUI, which nothing else may resume, stop or delete.
836
869
  # @return [OwnerLock::Owner, nil]
@@ -146,13 +146,20 @@ module Samagotchi
146
146
  # scratch: a `chi scratch` session (deleted when its REPL ends, a
147
147
  # leftover one at the next sweep)
148
148
  # ctx_pct: how full the context was after the last turn, or nil
149
- keys = %i[id short_id desc cwd project updated_at live busy owner recap parent_id archived scratch ctx_pct]
149
+ # waiting: the kind of question it waits on (question, approval,
150
+ # hook: chi answer or the web answers it), or nil; waiting_id: its
151
+ # id, for chi answer --question
152
+ keys = %i[id short_id desc cwd project updated_at live busy owner recap parent_id archived scratch ctx_pct waiting
153
+ waiting_id]
150
154
  @stdout.puts JSON.generate(summaries.map { |summary| summary.slice(*keys) })
151
155
  when "tsv"
152
156
  summaries.each { |summary| @stdout.puts "#{summary[:id]}\t#{summary[:desc]}" }
153
157
  else
154
158
  summaries.each do |summary|
155
- state = summary[:busy] ? "running" : (summary[:live] ? "live" : summary[:status].to_s)
159
+ state = if summary[:waiting] then "waiting"
160
+ elsif summary[:busy] then "running"
161
+ else summary[:live] ? "live" : summary[:status].to_s
162
+ end
156
163
  # The saved recap's first sentence says more than the last prompt
157
164
  # (text only: tsv and json keep desc for pickers).
158
165
  desc = summary[:desc]
@@ -197,7 +204,10 @@ module Samagotchi
197
204
  ctx = Samagotchi::SessionMetrics.context_label(
198
205
  Samagotchi::SessionMetrics.saved_context_pct(Samagotchi::Session.session_dir(s.id, state_dir: state_dir))
199
206
  )
200
- "#{s.id} #{s.status.ljust(8)} #{ctx.ljust(8)} #{s.updated_at} #{list_text.call(s)}#{flag}#{child}"
207
+ # A question waits for an answer (chi answer, the web, chi --attach).
208
+ live = s.pending_question && Samagotchi::SessionManager.worker_live?(s.id, state_dir: state_dir)
209
+ status = s.waiting_question(live: !!live) ? "waiting" : s.status
210
+ "#{s.id} #{status.ljust(8)} #{ctx.ljust(8)} #{s.updated_at} #{list_text.call(s)}#{flag}#{child}"
201
211
  end
202
212
  if project
203
213
  sessions.each { |s| @stdout.puts row.call(s) }
@@ -328,13 +328,14 @@ module Samagotchi
328
328
 
329
329
  # ── Project / rg helpers ───────────────────────────────────────────────────
330
330
 
331
+ # AGENT.md in the cwd, else at the top of its git work tree.
331
332
  def project_specific_description
332
333
  return nil if skip_agent_description?
333
334
 
334
- path = File.join(Dir.pwd, AGENT_DESCRIPTION_FILE)
335
- return nil unless File.file?(path)
335
+ path = agent_description_path
336
+ return nil unless path
336
337
 
337
- content = File.read(path).strip
338
+ content = File.read(path, encoding: "UTF-8").strip
338
339
  return nil if content.empty?
339
340
 
340
341
  "Project specific description:\n#{content}"
@@ -342,6 +343,13 @@ module Samagotchi
342
343
  nil
343
344
  end
344
345
 
346
+ def agent_description_path
347
+ cwd = Dir.pwd
348
+ [cwd, MemoryPaths.work_tree_root(cwd)].compact.uniq
349
+ .map { |dir| File.join(dir, AGENT_DESCRIPTION_FILE) }
350
+ .find { |path| File.file?(path) }
351
+ end
352
+
345
353
  # Where the session runs and which project memory folder it uses. The root
346
354
  # line appears only when it differs from the cwd (a worktree or subdir).
347
355
  # The home directory is spelled out once so the model copies the right
@@ -5,6 +5,8 @@ require_relative "../config"
5
5
  require_relative "../model_profile"
6
6
  require_relative "../session_manager"
7
7
  require_relative "../bridge_client"
8
+ require_relative "../parent_report"
9
+ require_relative "../cli/exit"
8
10
  require_relative "live_region"
9
11
  require_relative "plain_surface"
10
12
  require_relative "attached_loop"
@@ -43,14 +45,40 @@ module Samagotchi
43
45
  memories: memories, muted_memories: muted_memories)
44
46
  first_command = model && (attach || resume) ? "/model #{model}" : nil
45
47
  surface = open_surface
46
- begin
47
- AttachedLoop.new(client: client, screen: surface, client_id: "tui:#{Process.pid}", first_prompt: prompt,
48
- first_command: first_command, no_interrupt: no_interrupt,
49
- default_input: default_input && !prompt && !attach && !resume,
50
- wait_at_eof: !$stdin.tty?).run
48
+ attached = AttachedLoop.new(client: client, screen: surface, client_id: "tui:#{Process.pid}", first_prompt: prompt,
49
+ first_command: first_command, no_interrupt: no_interrupt,
50
+ default_input: default_input && !prompt && !attach && !resume,
51
+ wait_at_eof: !$stdin.tty?,
52
+ parent_answers: Guardrails::ParentApprovals.parent_process?)
53
+ ended = begin
54
+ attached.run
51
55
  ensure
52
56
  close_surface(surface)
53
57
  end
58
+ report_unanswered(attached.unanswered, session_id: client.session_id) if ended == :unanswered && attached.unanswered
59
+ ended
60
+ end
61
+
62
+ # A question left waiting (exit 3), in full on stderr as `chi send
63
+ # --wait` prints it (ParentReport), so a script or a parent agent
64
+ # can answer it with chi answer.
65
+ # @param pending [Hash] the pending question (symbol keys)
66
+ def report_unanswered(pending, session_id:, err: $stderr)
67
+ err.print("chi: #{ParentReport.question_text(pending, session_id: session_id)}")
68
+ err.flush
69
+ end
70
+
71
+ # chi's exit status after #run: 0 detached, 3 a question left waiting
72
+ # for an answer (input from a pipe ran out; chi --attach or chi answer
73
+ # answers it), 1 anything else (a failed turn, the worker gone).
74
+ # @param ended [Symbol] what #run returned
75
+ # @return [Integer]
76
+ def exit_status(ended)
77
+ case ended
78
+ when :detached then CLI::Exit::OK
79
+ when :unanswered then CLI::Exit::QUESTION
80
+ else CLI::Exit::FAILED
81
+ end
54
82
  end
55
83
 
56
84
  # A live region (Screen, with Reline drawing into it) on a terminal
@@ -20,6 +20,7 @@ require_relative "../model_profile"
20
20
  require_relative "../output_formatter"
21
21
  require_relative "../session_commands"
22
22
  require_relative "../session_manager"
23
+ require_relative "../guardrails/parent_approvals"
23
24
 
24
25
  module Samagotchi
25
26
  class TerminalUI
@@ -62,6 +63,10 @@ module Samagotchi
62
63
  # @return [QuestionPrompt, nil] the question waiting for an answer
63
64
  attr_reader :question
64
65
 
66
+ # @return [Hash, nil] the pending question (symbol keys) #run left
67
+ # waiting when it ended :unanswered
68
+ attr_reader :unanswered
69
+
65
70
  # @param client [BridgeClient]
66
71
  # @param screen [Surface] with #synchronize and #columns (a Screen, or a
67
72
  # PlainSurface when the terminal can't show a live region)
@@ -81,7 +86,7 @@ module Samagotchi
81
86
  # X </dev/null`): at their end, detach only once this run's prompts
82
87
  # (the -p one too) have had their turns; if one failed, #run says so
83
88
  def initialize(client:, screen:, client_id:, first_prompt: nil, first_command: nil, no_interrupt: false,
84
- default_input: false, wait_at_eof: false,
89
+ default_input: false, wait_at_eof: false, parent_answers: false,
85
90
  clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) },
86
91
  delete_session: ->(id) { SessionManager.delete_session(id, stop: true, wait: DELETE_WAIT) },
87
92
  archive_session: ->(id) { SessionManager.archive_session(id, wait: DELETE_WAIT) })
@@ -90,12 +95,17 @@ module Samagotchi
90
95
  @archive_session = archive_session
91
96
  @screen = screen
92
97
  @client_id = client_id
98
+ # A parent agent drives this attach (AttachLauncher:
99
+ # ParentApprovals.parent_process?): its answers go as chi answer's,
100
+ # so the worker holds them to guardrails.parent_approvals.
101
+ @parent_answers = parent_answers
93
102
  @view = AttachedView.new(screen)
94
103
  @renderer = EventRenderer.new(@view)
95
104
  @shown_enqueued = Set.new
96
105
  @running = false
97
106
  @joined_mid_turn = false
98
107
  @attached = false
108
+ @early_lines = []
99
109
  @question = nil
100
110
  @answered_ids = Set.new
101
111
  @reader = nil
@@ -153,16 +163,17 @@ module Samagotchi
153
163
  when :event
154
164
  ended = safely_handle(payload)
155
165
  return ended if %i[closed failed unanswered].include?(ended)
166
+
167
+ ended = take_early_lines
168
+ return ended if ended
156
169
  return end_of_input if @input_ended && !own_turns_pending?
157
170
  when :line
158
- if payload.nil? && @wait_at_eof && own_turns_pending?
159
- # The pipe's end: this run's turns first. An open question
160
- # has nothing left to answer it.
161
- return unanswered_question if @question
162
-
163
- next @input_ended = true
164
- end
165
- return end_of_input if submit(payload) == :detach
171
+ # A pipe is read at once: its lines wait for the joining
172
+ # snapshot, which may bring the question they answer.
173
+ next @early_lines << payload if @wait_at_eof && !@attached
174
+
175
+ ended = take_line(payload)
176
+ return ended if ended
166
177
  when :interrupt then return :detached if interrupt(payload) == :detach
167
178
  end
168
179
  end
@@ -178,6 +189,30 @@ module Samagotchi
178
189
  stream&.close
179
190
  end
180
191
 
192
+ # One input line (nil: the input ended).
193
+ # @return [Symbol, nil] how the run ends, or nil to go on
194
+ def take_line(line)
195
+ if line.nil? && @wait_at_eof && own_turns_pending?
196
+ # The pipe's end: this run's turns first. An open question
197
+ # has nothing left to answer it.
198
+ return unanswered_question(@question_pending) if @question
199
+
200
+ @input_ended = true
201
+ return nil
202
+ end
203
+ end_of_input if submit(line) == :detach
204
+ end
205
+
206
+ # The lines read before the snapshot came, once it has.
207
+ # @return [Symbol, nil] how the run ends, or nil to go on
208
+ def take_early_lines
209
+ until !@attached || @early_lines.empty?
210
+ ended = take_line(@early_lines.shift)
211
+ return ended if ended
212
+ end
213
+ nil
214
+ end
215
+
181
216
  # Render one Bridge event (string keys).
182
217
  # @return [Symbol, nil] :closed when the stream ended, :failed when the
183
218
  # launch's first command didn't go through
@@ -217,7 +252,7 @@ module Samagotchi
217
252
  when :input_merged then merged_own_prompts(event)
218
253
  when :question_requested
219
254
  # Nothing is left to answer it with.
220
- return unanswered_question if @input_ended
255
+ return unanswered_question(event[:pending_question]) if @input_ended
221
256
 
222
257
  ask(event[:pending_question])
223
258
  when :question_answered then question_answered(event)
@@ -557,7 +592,8 @@ module Samagotchi
557
592
  @own_failed ? :turn_failed : :detached
558
593
  end
559
594
 
560
- def unanswered_question
595
+ def unanswered_question(pending)
596
+ @unanswered = pending
561
597
  detach("A question waits for an answer: chi --attach #{@client.session_id}")
562
598
  :unanswered
563
599
  end
@@ -634,6 +670,7 @@ module Samagotchi
634
670
  # What was typed at the prompt waits for the question to close.
635
671
  @set_aside = @reader&.typed_text unless @question
636
672
  @question = QuestionPrompt.new(pending)
673
+ @question_pending = pending
637
674
  # An edit's diff goes above, into the scrollback, once per question
638
675
  # (a join gets it from the snapshot and may get the event too).
639
676
  unless @previewed_id == @question.id
@@ -656,7 +693,8 @@ module Samagotchi
656
693
  return @screen.commit(answer.error)
657
694
  end
658
695
 
659
- reply = @client.answer(id: @question.id, selected: answer.selected, freeform: answer.freeform)
696
+ parent = @parent_answers ? { client_id: Guardrails::ParentApprovals::CLIENT_ID } : {}
697
+ reply = @client.answer(id: @question.id, selected: answer.selected, freeform: answer.freeform, **parent)
660
698
  case reply.status
661
699
  when 200
662
700
  @answered_ids << @question.id
@@ -936,7 +974,8 @@ module Samagotchi
936
974
  # The snapshot's cards and between-turns notices (Bridge CardStore):
937
975
  # on join the ones since the last turn (and the running turn's), as
938
976
  # the join header shows the last exchange; on a resync only cards not
939
- # shown here yet. A turn's own rows (notices, retries) are left out:
977
+ # shown here yet, and not the ones an earlier worker saved (a resync
978
+ # onto a new worker: earlier turns, not news). A turn's own rows (notices, retries) are left out:
940
979
  # the running turn's come with its parts, and the join shows no older
941
980
  # turn's steps.
942
981
  def render_snapshot_cards(entries, joining:)
@@ -947,7 +986,7 @@ module Samagotchi
947
986
 
948
987
  @screen.commit(EventRenderer.hook_notice_line(entry)) if joining && entry[:turns_since].to_i.zero?
949
988
  when "card"
950
- next unless joining ? entry[:turns_since].to_i.zero? : !@renderer.card_shown?(entry[:id])
989
+ next unless joining ? entry[:turns_since].to_i.zero? : !entry[:earlier] && !@renderer.card_shown?(entry[:id])
951
990
 
952
991
  @renderer.render_card(entry, updated: entry[:updated] == true)
953
992
  end
@@ -15,6 +15,7 @@ require_relative "host_registry"
15
15
  require_relative "session"
16
16
  require_relative "owner_lock"
17
17
  require_relative "engine"
18
+ require_relative "guardrails/parent_approvals"
18
19
  require_relative "tools/memory"
19
20
  require_relative "output_formatter"
20
21
  require_relative "turn_flow"
@@ -1111,6 +1112,7 @@ module Samagotchi
1111
1112
  # was cancelled).
1112
1113
  def answer_question_widget(prompt)
1113
1114
  loop do
1115
+ @choice_echoed = false
1114
1116
  raw = begin
1115
1117
  yield
1116
1118
  rescue Interrupt
@@ -1132,15 +1134,22 @@ module Samagotchi
1132
1134
  @surface.commit(answer.note) if answer.note
1133
1135
  unless answer.ok?
1134
1136
  # The read left no echo: the line shows above its error.
1135
- @surface.commit("#{paint(QUESTION_PROMPT, 33)}#{raw}")
1137
+ echo_choice(raw)
1136
1138
  @surface.commit(answer.error)
1137
1139
  next
1138
1140
  end
1139
1141
 
1140
1142
  begin
1141
- @engine.answer_question(id: prompt.id, selected: answer.selected, freeform: answer.freeform)
1143
+ parent = parent_answers? ? { client_id: Guardrails::ParentApprovals::CLIENT_ID } : {}
1144
+ @engine.answer_question(id: prompt.id, selected: answer.selected, freeform: answer.freeform, **parent)
1142
1145
  close_question_widget(prompt, prompt.answer_text(answer))
1143
1146
  return true
1147
+ rescue QuestionDesk::Refused => e
1148
+ # A parent's allow guardrails.parent_approvals doesn't let through:
1149
+ # the question stays open for a deny (or the end of the input).
1150
+ echo_choice(raw)
1151
+ @surface.commit(Guardrails::ParentApprovals.message(e.reason, typed: true))
1152
+ next
1144
1153
  rescue ArgumentError => e
1145
1154
  @surface.commit("Invalid: #{e.message}. Try again.")
1146
1155
  next
@@ -1151,6 +1160,12 @@ module Samagotchi
1151
1160
  end
1152
1161
  end
1153
1162
 
1163
+ # Whether the answers typed here are a parent agent's (piped stdin, an
1164
+ # agent marker): QuestionDesk holds them to guardrails.parent_approvals.
1165
+ def parent_answers?
1166
+ Guardrails::ParentApprovals.parent_process?
1167
+ end
1168
+
1154
1169
  def close_question_widget(prompt, outcome)
1155
1170
  @surface.clear_slot(:notes)
1156
1171
  @surface.commit(prompt.summary(outcome, paint: method(:paint)))
@@ -1159,7 +1174,19 @@ module Samagotchi
1159
1174
  # A ? read of its own, off a terminal (specs, pipes): $stdin.gets.
1160
1175
  def read_choice_line(question_prompt)
1161
1176
  @surface.set_slot(:editor, [question_prompt])
1162
- $stdin.gets
1177
+ line = $stdin.gets
1178
+ # A pipe doesn't echo: the line completes the ? prompt here, once,
1179
+ # and the widget doesn't echo it again (#echo_choice).
1180
+ unless $stdin.tty?
1181
+ @surface.commit(line.to_s.chomp)
1182
+ @choice_echoed = true
1183
+ end
1184
+ line
1185
+ end
1186
+
1187
+ # The answer line above its error, unless its read echoed it already.
1188
+ def echo_choice(raw)
1189
+ @surface.commit("#{paint(QUESTION_PROMPT, 33)}#{raw}") unless @choice_echoed
1163
1190
  end
1164
1191
 
1165
1192
  # The next line submitted at the open prompt (?), nil for Ctrl-D
@@ -25,15 +25,15 @@ module Samagotchi
25
25
  # not a block and stays in `:text` (the model doesn't emit it; the system
26
26
  # prompt carries it).
27
27
  class ThoughtStreamSplitter
28
- # Gemma 4's thought channel (ToolCallParser::Gemma strips the same pair).
29
- GEMMA_THOUGHT_CHANNEL = { open: "<|channel>thought", close: "<channel|>" }.freeze
30
-
31
28
  # @return [Array<Hash>] each entry { open: String, close: String, lane: :thinking|:drop }
32
29
  def self.for_profile(profile)
33
30
  blocks = []
34
31
  # A thinking block needs a close marker to be bounded.
35
32
  blocks << { open: profile.thought_open, close: profile.thought_close, lane: :thinking } if profile.thought_close
36
- blocks << GEMMA_THOUGHT_CHANNEL.merge(lane: :thinking) if profile.name == "gemma4"
33
+ # Gemma 4's thought channel (ToolCallParser::Gemma strips the same pair).
34
+ if profile.thought_channel_open
35
+ blocks << { open: profile.thought_channel_open, close: profile.thought_channel_close, lane: :thinking }
36
+ end
37
37
  blocks << { open: profile.tool_call_open, close: profile.tool_call_close, lane: :drop }
38
38
  new(blocks)
39
39
  end
@@ -38,8 +38,8 @@ module Samagotchi
38
38
  # Digits after the first character: plugin tools may have them
39
39
  # (Plugin::Api::TOOL_NAME).
40
40
  TOOL_CALL_BODY_RE = /\Acall:([a-z_][a-z0-9_]{0,49})\{/
41
- THOUGHT_CHANNEL_OPEN = "<|channel>thought"
42
- THOUGHT_CHANNEL_CLOSE = "<channel|>"
41
+ THOUGHT_CHANNEL_OPEN = ModelProfile::GEMMA_THOUGHT_CHANNEL_OPEN
42
+ THOUGHT_CHANNEL_CLOSE = ModelProfile::GEMMA_THOUGHT_CHANNEL_CLOSE
43
43
  CONTROL_TOKEN_START = "<|"
44
44
 
45
45
  def initialize(profile)
@@ -305,10 +305,10 @@ module Samagotchi
305
305
  end
306
306
 
307
307
  def parse(text)
308
- read(text).map { |call| Tools::BuiltinCalls.build(call[:name], call[:args], raw: call[:raw]) }
308
+ read(text).map { |call| Tools::BuiltinCalls.build(call[:name], call[:args]) }
309
309
  end
310
310
 
311
- # Each <tool_call> block as {name:, args:, raw:}: its <parameter=…>
311
+ # Each <tool_call> block as {name:, args:}: its <parameter=…>
312
312
  # (or <arg_key>/<arg_value>) text by lowercased key, unbuilt.
313
313
  def read(text)
314
314
  results = []
@@ -324,8 +324,7 @@ module Samagotchi
324
324
 
325
325
  body = text[body_start...close_pos]
326
326
  if (name = qwen_function_name(body))
327
- params = qwen_params(body)
328
- results << { name: name, args: params, raw: params.to_s }
327
+ results << { name: name, args: qwen_params(body) }
329
328
  end
330
329
  pos = close_pos + tool_close.length
331
330
  end
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "json"
3
4
  require_relative "../tool_declarations"
4
5
  require_relative "ask_user_question"
5
6
 
@@ -18,7 +19,7 @@ module Samagotchi
18
19
  # and the handler reading it.
19
20
  #
20
21
  # A tool that is not built in keeps its arguments whole on args: (typed
21
- # by its schema at dispatch, Tools::Args) with +raw+ as its content.
22
+ # by its schema at dispatch, Tools::Args) with their JSON as its content.
22
23
  module BuiltinCalls
23
24
  # Per tool, what the schema alone doesn't say:
24
25
  # content: the property (or ordered list of keys to try) that fills
@@ -103,7 +104,7 @@ module Samagotchi
103
104
  # @param name [String]
104
105
  # @param args [Hash] string keys, as the model gave them
105
106
  # @param raw [String, nil] the call's text, the content of a tool
106
- # that isn't built in
107
+ # that isn't built in when no argument could be read from it
107
108
  # @return [Hash]
108
109
  def build(name, args, raw: nil)
109
110
  name = name.to_s
@@ -139,7 +140,17 @@ module Samagotchi
139
140
  end
140
141
 
141
142
  def passthrough(name, args, raw)
142
- { name: name, content: raw.nil? ? args.values.join(" ") : raw.to_s, path: nil, scope: nil, args: args }
143
+ { name: name, content: passthrough_content(args, raw), path: nil, scope: nil, args: args }
144
+ end
145
+
146
+ # The arguments as JSON, one form for every model format and Ruby
147
+ # version; the raw text when none could be read from it.
148
+ def passthrough_content(args, raw)
149
+ return raw.to_s if args.empty? && !raw.to_s.empty?
150
+
151
+ JSON.generate(args)
152
+ rescue JSON::GeneratorError
153
+ args.values.join(" ")
143
154
  end
144
155
  end
145
156
  end
@@ -21,6 +21,7 @@ require_relative "send_note"
21
21
  require_relative "delegate"
22
22
  require_relative "delegate_result"
23
23
  require_relative "ask_user_question"
24
+ require_relative "../guardrails/parent_approvals"
24
25
 
25
26
  module Samagotchi
26
27
  module Tools
@@ -72,13 +73,15 @@ module Samagotchi
72
73
  TaskCreate::NAME => lambda do |call, kctx|
73
74
  next Execute::NOT_RUN_ON_STOP if cancelled_proc(kctx).call
74
75
 
75
- TaskCreate.call(call[:content], cwd: call[:cwd], env: call[:env])
76
+ TaskCreate.call(call[:content], cwd: call[:cwd], env: call[:env], marker_env: parent_env(kctx))
76
77
  end,
77
78
  TaskWait::NAME => lambda do |call, kctx|
78
79
  TaskWait.call(call[:content], timeout: call[:timeout], tail_lines: call[:tail_lines],
79
80
  done_pattern: call[:done_pattern], cancelled: cancelled_proc(kctx))
80
81
  end,
81
- Execute::NAME => ->(call, kctx) { Execute.call(call[:content], cwd: call[:cwd], cancelled: cancelled_proc(kctx)) },
82
+ Execute::NAME => lambda do |call, kctx|
83
+ Execute.call(call[:content], cwd: call[:cwd], cancelled: cancelled_proc(kctx), env: parent_env(kctx))
84
+ end,
82
85
  RegisterReminder::NAME => lambda do |call, kctx|
83
86
  RegisterReminder.call(call[:content], reminder_store: kctx.reminder_store, description: call[:description],
84
87
  interval_minutes: call[:interval_minutes])
@@ -99,6 +102,15 @@ module Samagotchi
99
102
 
100
103
  module_function
101
104
 
105
+ # What execute and task_create export into the commands they run: a
106
+ # chi started there answers as a parent agent
107
+ # (Guardrails::ParentApprovals.parent_process?), even under a PTY
108
+ # wrapper. The session's id, "chi" without one.
109
+ def parent_env(kctx)
110
+ id = kctx.peers.respond_to?(:session_id) ? kctx.peers.session_id.to_s : ""
111
+ { Guardrails::ParentApprovals::PARENT_SESSION_ENV => id.empty? ? "chi" : id }
112
+ end
113
+
102
114
  # Stop flips the turn's controller, seen through the Engine's PeerView
103
115
  # (as DelegateWait does); a bare kernel has no peers: never cancelled.
104
116
  def cancelled_proc(kctx)