samagotchi 0.12.0 → 0.13.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 (64) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +62 -1
  3. data/README.md +20 -7
  4. data/bin/chi +5 -2
  5. data/docs/architecture.md +4 -2
  6. data/docs/cli.md +34 -10
  7. data/docs/configuration.md +29 -8
  8. data/docs/desktop.md +10 -6
  9. data/docs/guardrails.md +73 -7
  10. data/docs/hooks.md +13 -9
  11. data/docs/memory.md +5 -3
  12. data/docs/plugins.md +4 -4
  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.rb +11 -1
  17. data/lib/samagotchi/bridge_client.rb +8 -3
  18. data/lib/samagotchi/bundle_command.rb +18 -10
  19. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +19 -0
  20. data/lib/samagotchi/bundles/guardrails/manifest.yml +2 -2
  21. data/lib/samagotchi/bundles/system/config_modification_protocol.md +5 -5
  22. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  23. data/lib/samagotchi/cli/command.rb +3 -2
  24. data/lib/samagotchi/cli/exit.rb +23 -0
  25. data/lib/samagotchi/cli/parent_wait.rb +82 -0
  26. data/lib/samagotchi/config.rb +50 -18
  27. data/lib/samagotchi/desktop_command.rb +5 -3
  28. data/lib/samagotchi/engine.rb +40 -14
  29. data/lib/samagotchi/guardrails/parent_approvals.rb +155 -0
  30. data/lib/samagotchi/hooks/bundle_loader.rb +10 -3
  31. data/lib/samagotchi/host_registry.rb +0 -1
  32. data/lib/samagotchi/llm/openai_chat.rb +9 -1
  33. data/lib/samagotchi/memory_bundle/installer.rb +12 -0
  34. data/lib/samagotchi/memory_bundle/status.rb +9 -1
  35. data/lib/samagotchi/memory_paths.rb +8 -0
  36. data/lib/samagotchi/model_profile.rb +30 -16
  37. data/lib/samagotchi/parent_report.rb +213 -0
  38. data/lib/samagotchi/plugin/api.rb +15 -2
  39. data/lib/samagotchi/prompt_literal_guard.rb +4 -2
  40. data/lib/samagotchi/question_desk.rb +29 -2
  41. data/lib/samagotchi/recap_setup.rb +15 -5
  42. data/lib/samagotchi/reply_wait.rb +5 -1
  43. data/lib/samagotchi/send_command.rb +50 -75
  44. data/lib/samagotchi/session.rb +15 -0
  45. data/lib/samagotchi/session_manager.rb +34 -7
  46. data/lib/samagotchi/sessions_command.rb +13 -3
  47. data/lib/samagotchi/system_prompt.rb +10 -2
  48. data/lib/samagotchi/terminal_ui/attach_launcher.rb +33 -5
  49. data/lib/samagotchi/terminal_ui/attached_loop.rb +17 -5
  50. data/lib/samagotchi/terminal_ui.rb +15 -1
  51. data/lib/samagotchi/thought_stream_splitter.rb +4 -4
  52. data/lib/samagotchi/tool_call_parser.rb +5 -6
  53. data/lib/samagotchi/tools/builtin_calls.rb +14 -3
  54. data/lib/samagotchi/tools/builtins.rb +14 -2
  55. data/lib/samagotchi/tools/delegate_wait.rb +16 -4
  56. data/lib/samagotchi/tools/execute.rb +8 -8
  57. data/lib/samagotchi/tools/task_create.rb +3 -2
  58. data/lib/samagotchi/tools/task_runtime.rb +7 -2
  59. data/lib/samagotchi/version.rb +1 -1
  60. data/lib/samagotchi/web/app.rb +4 -3
  61. data/lib/samagotchi/web/message_parts.rb +2 -1
  62. data/lib/samagotchi/web/server.rb +2 -1
  63. data/lib/samagotchi/web/session_summary.rb +3 -11
  64. metadata +7 -1
@@ -7,6 +7,7 @@ require "time"
7
7
 
8
8
  require_relative "log"
9
9
  require_relative "tools/ask_user_question"
10
+ require_relative "guardrails/parent_approvals"
10
11
 
11
12
  module Samagotchi
12
13
  # An Engine's question flow: the one open question (ask_user_question, a
@@ -21,6 +22,20 @@ module Samagotchi
21
22
  # of ArgumentError for existing callers; transports map it to 409 Conflict.
22
23
  class NotPending < ArgumentError; end
23
24
 
25
+ # Raised by #answer when a parent agent's answer (chi answer's client
26
+ # id) allows an approval further than this worker's
27
+ # guardrails.parent_approvals lets it (Guardrails::ParentApprovals).
28
+ # The question stays open; transports map it to 403.
29
+ class Refused < StandardError
30
+ # @return [Symbol] :off or :once_only
31
+ attr_reader :reason
32
+
33
+ def initialize(reason)
34
+ @reason = reason
35
+ super("a parent may not allow this approval (#{reason})")
36
+ end
37
+ end
38
+
24
39
  DISMISSED_NOTE = "The user dismissed the question without answering. Don't do what you asked about, " \
25
40
  "or anything else that changes files or state. Finish your reply with what you found " \
26
41
  "and what you would do, and wait."
@@ -170,11 +185,16 @@ module Samagotchi
170
185
  # @param id [String] pending id
171
186
  # @param selected [Array<String>] values/labels
172
187
  # @param freeform [String, nil]
188
+ # @param client_id [String, nil] who answers; chi answer's (a parent
189
+ # agent) is held to guardrails.parent_approvals on an approval
173
190
  # @return [Hash] normalized answer
174
- def answer(id:, selected:, freeform: nil)
191
+ # @raise [NotPending, ArgumentError, Refused]
192
+ def answer(id:, selected:, freeform: nil, client_id: nil)
175
193
  sel = Array(selected).map { |v| v.to_s.strip }.reject(&:empty?)
176
194
  fm = freeform.to_s.strip
177
195
  fm = nil if fm.empty?
196
+ # Read before the lock (config may touch the disk); this worker's own.
197
+ parent_setting = Guardrails::ParentApprovals.setting if client_id.to_s == Guardrails::ParentApprovals::CLIENT_ID
178
198
  @lock.synchronize do
179
199
  pending = @pending
180
200
  raise NotPending, "no pending question" unless pending
@@ -197,10 +217,17 @@ module Samagotchi
197
217
  if pending[:multi_select] == false && sel.empty? && fm.nil?
198
218
  raise ArgumentError, "selection required"
199
219
  end
220
+ indices = sel.map { |v| opts.index(v) }
221
+ # Checked against the question pending now, under the lock: the one
222
+ # whose answer settles the approval (Approval.settle, by index).
223
+ if parent_setting
224
+ reason = Guardrails::ParentApprovals.refusal(pending, indices, setting: parent_setting)
225
+ raise Refused, reason if reason
226
+ end
200
227
  # Persist pending cleared elsewhere; just set answer
201
228
  answer = { id: id.to_s, selected: sel, freeform: fm }
202
229
  # Derive indices for convenience
203
- answer[:selected_indices] = sel.map { |v| opts.index(v) }.compact
230
+ answer[:selected_indices] = indices.compact
204
231
  @answer = answer
205
232
  @cv.broadcast
206
233
  answer
@@ -13,7 +13,8 @@ module Samagotchi
13
13
  # host or model configured it asks the session's current model on its
14
14
  # host, resolved at each attempt the way a turn does (a /model switch
15
15
  # counts). An explicit `recap: {host_ref:, model:}` or `{base_url:,
16
- # model:}` pins it; an incomplete one warns and leaves recap off.
16
+ # model:}` pins it; a model alone goes to its host, or where a bare
17
+ # --model goes; an incomplete one warns and leaves recap off.
17
18
  #
18
19
  # Single precedence path: explicit `recap:` kwarg > Config registry
19
20
  # (CLI > ENV > file > default). An explicit disable (`recap: false` as
@@ -42,14 +43,23 @@ module Samagotchi
42
43
  model = string_config(kwarg_config, :model) || registry_string("recap.model")
43
44
  label = model
44
45
 
46
+ # The model as any model ref (ModelRef): its alias applied once, and
47
+ # its host (own or the alias's) picks the recap host when host_ref
48
+ # doesn't.
49
+ parsed = model && host_registry.model_ref(model)
45
50
  target = nil
46
51
  if base_url.nil? && host_ref.nil? && model.nil?
47
52
  target = -> { session_target.call }
53
+ elsif base_url.nil? && host_ref.nil? && !parsed.host_name
54
+ # A model naming no host goes where a bare --model goes
55
+ # (HostRegistry#host_for_model: the default host unless another's
56
+ # list has it), resolved at each attempt.
57
+ target = lambda do
58
+ resolved = host_registry.resolve(model)
59
+ { base_url: resolved.openai_base_url, api_key_env: resolved.entry.api_key_env,
60
+ model: resolved.bare_model, label: label.to_s.strip }
61
+ end
48
62
  else
49
- # The model as any model ref (ModelRef): its alias applied once, and
50
- # its host (own or the alias's) picks the recap host when host_ref
51
- # doesn't.
52
- parsed = model && host_registry.model_ref(model)
53
63
  host_ref ||= parsed.host_name if parsed && base_url.nil?
54
64
  # If host_ref given, derive base_url (the host's OpenAI base) and its
55
65
  # API key variable from the host_registry entry
@@ -72,8 +72,12 @@ module Samagotchi
72
72
  return Result.new(status: :stopped)
73
73
  end
74
74
 
75
+ # Only a question a live worker holds waits for an answer (the
76
+ # lists' rule): one a dead worker left waits for no one, and a chi
77
+ # REPL's is answered there.
75
78
  pending = session.pending_question
76
- if pending && !(baseline && pending[:id] == baseline[:question_id])
79
+ if pending && !(baseline && pending[:id] == baseline[:question_id]) &&
80
+ session.waiting_question(live: SessionManager.worker_live?(id, state_dir: state_dir))
77
81
  return Result.new(status: :waiting_for_answer, question: pending)
78
82
  end
79
83
 
@@ -5,12 +5,14 @@ require_relative "session_inbox"
5
5
  require_relative "session_manager"
6
6
  require_relative "context_quote"
7
7
  require_relative "reply_wait"
8
+ require_relative "parent_report"
8
9
  require_relative "image_store"
9
10
  require_relative "host_registry"
10
11
  require_relative "model_profile"
11
12
  require_relative "vision_support"
12
13
  require_relative "cli/command"
13
14
  require_relative "cli/flags"
15
+ require_relative "cli/parent_wait"
14
16
 
15
17
  module Samagotchi
16
18
  # `chi send`: put text into sessions as the user's message, the same as
@@ -22,20 +24,17 @@ module Samagotchi
22
24
  # prints it: an agent's one-shot the user can watch in the web.
23
25
  class SendCommand
24
26
  include CLI::Command
27
+ include CLI::ParentWait
25
28
 
26
29
  CLIENT_ID = "cli:send"
27
- POLL_INTERVAL = ReplyWait::POLL_INTERVAL
28
- # No live worker this long while waiting: it died before it could mark
29
- # the session (a worker takes well under a second to start).
30
- WORKER_GONE_AFTER = 5
31
30
  # The cap on one turn's images the Bridge checks.
32
31
  MAX_IMAGES = ImageStore::MAX_TURN_REFS
33
32
 
34
33
  USAGE = <<~TEXT
35
34
  Usage: chi send [-m TEXT] [--image PATH]... (ID|PREFIX)...
36
35
  chi send --new [--dir DIR] [--model M] [-m TEXT] [--image PATH]...
37
- chi send --wait [--timeout S] [-m TEXT] [--image PATH]... (--new | ID)
38
- chi send --wait [--timeout S] ID
36
+ chi send --wait [--timeout S] [--format json] [-m TEXT] [--image PATH]... (--new | ID)
37
+ chi send --wait [--timeout S] [--format json] ID
39
38
  Sends a message to each session, as if typed in it: a turn starts,
40
39
  or a running one picks it up. A stopped session's worker starts.
41
40
  -m TEXT the message; stdin, when piped too, goes above it as a
@@ -53,11 +52,21 @@ module Samagotchi
53
52
  --model M (--new) its model; default the configured one
54
53
  --wait wait for the answer and print it (one session); the
55
54
  other lines go to stderr. Exit 3: it waits for an
56
- answer from you (chi --attach ID or the web). With
57
- no message (no -m, nothing piped) nothing is sent: it
58
- waits for the session's next reply, a running turn's
59
- too (after exit 3 or 130, wait again this way)
60
- --timeout S (--wait) give up after S seconds; default no limit
55
+ answer from you: the question, its options and the
56
+ chi answer command go to stderr (or chi --attach ID,
57
+ the web). With no message (no -m, nothing piped)
58
+ nothing is sent: it waits for the session's next
59
+ reply, a running turn's too (after exit 3, 4 or 130,
60
+ wait again this way)
61
+ --timeout S (--wait) give up after S seconds, exit 4 (the turn
62
+ goes on); default no limit
63
+ --format json
64
+ (--wait) one JSON object on stdout, whatever the end:
65
+ status answered (text), question (question,
66
+ answer_with), running, or failed, canceled,
67
+ no_answer, error, worker_gone, stopped (detail)
68
+ Exit with --wait: 0 answered, 1 failed or gone, 2 usage, 3 a
69
+ question waits, 4 still running (--timeout), 130 Ctrl-C.
61
70
  Only sessions on this machine. Answers show in the attached TUI
62
71
  or web page, not here.
63
72
  Find ids with: chi sessions list --live [--scope=all] [--format tsv]
@@ -71,6 +80,7 @@ module Samagotchi
71
80
  f.value "--image", key: :images, repeat: true
72
81
  f.switch "--wait"
73
82
  f.value "--timeout"
83
+ f.value "--format"
74
84
  # Starting a turn in every live session at once is too easy to do
75
85
  # by accident.
76
86
  f.refuse "--all", "there is no --all: name the sessions"
@@ -119,13 +129,13 @@ module Samagotchi
119
129
  @state_dir = state_dir || Session.default_state_dir
120
130
  end
121
131
 
122
- # @return [Integer] exit status: 0 all sent, 1 any refused or failed,
123
- # 2 usage; with --wait 0 answered, 3 waiting for an answer, 130
124
- # Ctrl-C (the turn goes on)
125
- def run
126
- options = parse
127
- return options if options.is_a?(Integer)
132
+ private
128
133
 
134
+ # Under #run (CLI::ParentWait).
135
+ # @return [Integer] exit status: 0 all sent, 1 any refused or failed,
136
+ # 2 usage; with --wait 0 answered, 3 waiting for an answer, 4 still
137
+ # running after --timeout, 130 Ctrl-C (the turn goes on)
138
+ def run_parsed(options)
129
139
  # With --wait stdout is the answer alone.
130
140
  @info = options[:wait] ? @stderr : @stdout
131
141
  prompt = compose(utf8(read_stdin), utf8(options[:message]))
@@ -140,7 +150,9 @@ module Samagotchi
140
150
  @stderr.puts("chi send: #{e.message}")
141
151
  return 1
142
152
  end
143
- return 2 unless ingest_images(options[:images])
153
+ # A file that is not there or not an image: a run-time failure (1),
154
+ # reported in the --format json line too; --image itself was fine.
155
+ return CLI::Exit::FAILED unless ingest_images(options[:images])
144
156
 
145
157
  return run_new(prompt, options) if options[:new]
146
158
  return run_wait(prompt, options) if options[:wait]
@@ -160,8 +172,6 @@ module Samagotchi
160
172
  FileUtils.rm_rf(@image_dir) if @image_dir
161
173
  end
162
174
 
163
- private
164
-
165
175
  def command_name = "chi send"
166
176
 
167
177
  # @return [Hash, Integer] the options, or the exit status after the
@@ -171,6 +181,10 @@ module Samagotchi
171
181
  return parsed if parsed.is_a?(Integer)
172
182
 
173
183
  options = parsed.options.merge(ids: parsed.args)
184
+ if options[:format]
185
+ return usage_error("--format needs --wait") unless options[:wait]
186
+ return usage_error("--format takes text or json") unless FORMATS.include?(options[:format])
187
+ end
174
188
  if options[:timeout]
175
189
  return usage_error("--timeout needs --wait") unless options[:wait]
176
190
 
@@ -220,16 +234,6 @@ module Samagotchi
220
234
  @stdin.read
221
235
  end
222
236
 
223
- def resolve(given)
224
- id = Session.resolve_id(given, state_dir: @state_dir)
225
- Session.load(id, state_dir: @state_dir)
226
- id
227
- rescue ArgumentError => e
228
- message = e.is_a?(Session::AmbiguousId) ? e.message : "no session #{given}"
229
- error_line("chi send: #{message}")
230
- nil
231
- end
232
-
233
237
  # A worker session like the web start page's: saved as running with the
234
238
  # message before its worker spawns, so lists and the web show it at
235
239
  # once. The full id, so a script can pass it on. Only the model's host
@@ -257,6 +261,7 @@ module Samagotchi
257
261
  else
258
262
  @info.puts("#{session.id} started")
259
263
  end
264
+ @session_id = session.id
260
265
  return 0 unless options[:wait]
261
266
 
262
267
  wait_for_reply(session.id, cursor: nil, baseline: baseline_of(session, question_id: nil),
@@ -272,8 +277,20 @@ module Samagotchi
272
277
  def run_wait(prompt, options)
273
278
  id = resolve(options[:ids].first) or return 1
274
279
  session = Session.load(id, state_dir: @state_dir)
275
- if session.status == Session::STATUS_RUNNING && SessionManager.session_owner(id, state_dir: @state_dir)
276
- error_line("#{id[0, 8]} busy: a turn is running; wait or attach")
280
+ if session.status == Session::STATUS_RUNNING && (owner = SessionManager.session_owner(id, state_dir: @state_dir))
281
+ # The turn waits on a question: the message would only queue
282
+ # behind it, and the answer goes in with chi answer.
283
+ if (waiting = session.waiting_question(live: owner.worker?))
284
+ if ParentReport.approval?(waiting)
285
+ error_line("#{id[0, 8]} waiting for an approval: #{ParentReport::DENY_AND_TELL} " \
286
+ "(#{ParentReport.answer_with(id, waiting)})")
287
+ else
288
+ error_line("#{id[0, 8]} waiting for an answer: #{ParentReport.answer_with(id, waiting)} (or --text); " \
289
+ "chi --attach #{id} shows it")
290
+ end
291
+ else
292
+ error_line("#{id[0, 8]} busy: a turn is running; wait or attach")
293
+ end
277
294
  return 1
278
295
  end
279
296
 
@@ -298,7 +315,7 @@ module Samagotchi
298
315
  live = session.status == Session::STATUS_RUNNING || SessionManager.session_owner(id, state_dir: @state_dir)
299
316
  wait_for_reply(id, cursor: ReplyWait.newest_reply(id, state_dir: @state_dir),
300
317
  baseline: baseline_of(session).merge(messages: nil),
301
- timeout: options[:timeout], owner_grace: live ? WORKER_GONE_AFTER : nil)
318
+ timeout: options[:timeout], owner_grace: live ? self.class::WORKER_GONE_AFTER : nil)
302
319
  end
303
320
 
304
321
  # The session as it was before the message went in (ReplyWait's
@@ -307,48 +324,6 @@ module Samagotchi
307
324
  ReplyWait.baseline_of(session, **options)
308
325
  end
309
326
 
310
- # @return [Integer] the exit status
311
- def wait_for_reply(id, cursor:, baseline:, timeout:, owner_grace: WORKER_GONE_AFTER)
312
- result = ReplyWait.call(id, state_dir: @state_dir, cursor: cursor, timeout: timeout, baseline: baseline,
313
- owner_grace: owner_grace, poll_interval: POLL_INTERVAL)
314
- return reply(result.text) if result.status == :done
315
-
316
- attach = "chi --attach #{id}"
317
- line, status = case result.status
318
- when :waiting_for_answer
319
- question = result.question[:question].to_s.strip.lines.first.to_s.strip
320
- ["waiting for an answer: #{question}; open it: #{attach} or the web", 3]
321
- when :no_reply then ["#{no_reply_line(result)}; #{attach} shows it", 1]
322
- when :error then ["the worker failed: #{result.text}; #{attach} shows what happened", 1]
323
- when :worker_gone then ["the worker is gone; #{attach} shows what happened", 1]
324
- when :stopped then ["the session was stopped (chi sessions stop)", 1]
325
- else ["still running after #{format("%g", timeout)} s: #{attach}", 1]
326
- end
327
- error_line("chi send: #{line}")
328
- status
329
- rescue Interrupt
330
- error_line("chi send: still running: chi --attach #{id}")
331
- 130
332
- rescue ArgumentError
333
- error_line("chi send: the session is gone (deleted while waiting)")
334
- 1
335
- end
336
-
337
- def no_reply_line(result)
338
- case result.outcome
339
- when "failed" then result.text.to_s.strip.empty? ? "the turn failed" : "the turn failed: #{result.text.strip}"
340
- when "canceled" then "the turn was canceled"
341
- when "completed" then "the turn ended with no visible answer"
342
- else "the turn ended without a reply (canceled, failed or empty)"
343
- end
344
- end
345
-
346
- def reply(text)
347
- @stdout.puts(utf8(text))
348
- @stdout.flush
349
- 0
350
- end
351
-
352
327
  # A new idle session's first turn, with the images. Its worker's Bridge
353
328
  # first: deliver_turn wakes a worker when none owns the session yet,
354
329
  # and the spawned one takes its lock only once it runs.
@@ -99,6 +99,21 @@ module Samagotchi
99
99
  @archived = false
100
100
  end
101
101
 
102
+ # The question this session waits on, as the lists show it: {id:, kind:}
103
+ # with kind "question" (the model's), "approval" or "hook"; nil with
104
+ # none. Only while a worker runs it (+live+): a question saved by a
105
+ # worker that died waits for no one (the next worker drops it). The
106
+ # web's summaries and `chi sessions list` both ask this.
107
+ # @param live [Boolean] a worker owns the session now
108
+ # @return [Hash, nil]
109
+ def waiting_question(live:)
110
+ pending = pending_question
111
+ return nil unless live && pending.is_a?(Hash) && pending[:id]
112
+
113
+ kind = pending[:kind].to_s
114
+ { id: pending[:id], kind: %w[approval hook].include?(kind) ? kind : "question" }
115
+ end
116
+
102
117
  # The stored project root, else (a file from before the field) the
103
118
  # project of the working directory now: nil when that is in no repo or
104
119
  # gone. +cache+ is ProjectScope.root_for's, shared across one listing.
@@ -162,7 +162,9 @@ module Samagotchi
162
162
  # child inherits this process's ENV and reads config.yml itself (as it
163
163
  # is when the worker starts); opts[:env] adds to it (merged, not
164
164
  # replaced) what it can't read there: this `chi`'s CLI settings
165
- # (Config.cli_env), the hosts and the absolute log file.
165
+ # (Config.cli_env), the hosts and the absolute log file. It unsets
166
+ # SAMAGOTCHI_GUARDRAILS_*. XDG_CONFIG_HOME passes: whoever starts or
167
+ # wakes the worker picks which config.yml it reads (docs/guardrails.md).
166
168
  private_class_method def self.spawn_options(session)
167
169
  # Own process group: workers outlive `chi web`, and a Ctrl-C in its
168
170
  # terminal must not reach them.
@@ -195,10 +197,17 @@ module Samagotchi
195
197
  else
196
198
  child_env["SAMAGOTCHI_LOG_DISABLE"] = "true"
197
199
  end
200
+ # The spawner's environment must not reach the worker's guardrails (a
201
+ # parent agent starting or waking a session): Process.spawn unsets a
202
+ # key mapped to nil. config.yml no longer reads them; this keeps an
203
+ # older or future env-exposed one out as well.
204
+ ENV.each_key { |key| child_env[key] = nil if key.start_with?(GUARDRAILS_ENV_PREFIX) }
198
205
  opts[:env] = child_env unless child_env.empty?
199
206
  opts
200
207
  end
201
208
 
209
+ GUARDRAILS_ENV_PREFIX = "SAMAGOTCHI_GUARDRAILS_"
210
+
202
211
  # List all sessions, reading status from persisted session.json files.
203
212
  # +project_root+: only that project's sessions (nil: every session).
204
213
  # +include_archived+: archived sessions too (Session#archived says which).
@@ -226,9 +235,11 @@ module Samagotchi
226
235
  # @param include_archived [Boolean] archived sessions too
227
236
  # @return [Array<Hash>] {id:, short_id:, desc:, preview:, cwd:, project:,
228
237
  # updated_at:, status:, live:, busy:, owner:, recap:, parent_id:,
229
- # parent_short_id:, archived:, scratch:, test_run:}; busy = live with
238
+ # parent_short_id:, archived:, scratch:, test_run:, waiting:, waiting_id:}; busy = live with
230
239
  # a turn running, recap = the saved recap's first sentence, project =
231
- # Session#project_root
240
+ # Session#project_root, waiting = the kind of question it waits on
241
+ # ("question", "approval", "hook"; Session#waiting_question) or nil,
242
+ # waiting_id = that question's id or nil
232
243
  def self.session_summaries(live: false, cwd: nil, limit: nil, include_tests: true, exclude: nil, state_dir: nil,
233
244
  project_root: nil, include_archived: false, sort: nil, order: nil)
234
245
  sd = state_dir || Session.default_state_dir
@@ -239,20 +250,27 @@ module Samagotchi
239
250
  .reject { |s| (!include_tests && s.test_run) || s.id == exclude }
240
251
  .select { |s| root.nil? || in_folder?(s.working_directory, root) }
241
252
  .filter_map do |s|
242
- owner = session_owner(s.id, state_dir: sd)&.kind
243
- owned = owner == "worker"
253
+ owner = session_owner(s.id, state_dir: sd)
254
+ owned = !!owner&.worker?
244
255
  next if live && !owned
245
256
 
246
257
  { id: s.id, short_id: s.id[0, 8], desc: summary_desc(s), preview: summary_preview(s), cwd: s.working_directory,
247
258
  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)),
259
+ owner: owner&.kind, recap: RecapStore.preview(Session.session_dir(s.id, state_dir: sd)),
249
260
  ctx_pct: SessionMetrics.saved_context_pct(Session.session_dir(s.id, state_dir: sd))&.round(1),
250
261
  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 }
262
+ scratch: s.scratch, test_run: s.test_run }.merge(waiting_fields(s.waiting_question(live: owned)))
252
263
  end
253
264
  (limit ? summaries.first(limit) : summaries.to_a)
254
265
  end
255
266
 
267
+ # waiting: the question's kind, waiting_id: its id (what chi answer
268
+ # --question takes); both nil with none.
269
+ def self.waiting_fields(waiting)
270
+ { waiting: waiting&.dig(:kind), waiting_id: waiting&.dig(:id) }
271
+ end
272
+ private_class_method :waiting_fields
273
+
256
274
  # The sessions delegated by +parent_id+ (the `delegate` tool), newest
257
275
  # first, as .session_summaries rows. A running one (busy) counts against
258
276
  # session.max_children. Archived ones too.
@@ -831,6 +849,15 @@ module Samagotchi
831
849
  OwnerLock.owner(Session.session_dir(session_id, state_dir: state_dir || Session.default_state_dir))
832
850
  end
833
851
 
852
+ # A worker owns the session now: the one rule for whether a saved
853
+ # pending question waits for anyone (Session#waiting_question's live:).
854
+ # A chi REPL's question is its own (nothing else can answer it), and one
855
+ # a dead worker saved waits for no one.
856
+ # @return [Boolean]
857
+ def self.worker_live?(session_id, state_dir: nil)
858
+ !!session_owner(session_id, state_dir: state_dir)&.worker?
859
+ end
860
+
834
861
  # The session's owner (#session_owner), unless it is the interactive
835
862
  # TUI, which nothing else may resume, stop or delete.
836
863
  # @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,11 +328,12 @@ 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
338
  content = File.read(path).strip
338
339
  return nil if content.empty?
@@ -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,6 +95,10 @@ 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
@@ -158,7 +167,7 @@ module Samagotchi
158
167
  if payload.nil? && @wait_at_eof && own_turns_pending?
159
168
  # The pipe's end: this run's turns first. An open question
160
169
  # has nothing left to answer it.
161
- return unanswered_question if @question
170
+ return unanswered_question(@question_pending) if @question
162
171
 
163
172
  next @input_ended = true
164
173
  end
@@ -217,7 +226,7 @@ module Samagotchi
217
226
  when :input_merged then merged_own_prompts(event)
218
227
  when :question_requested
219
228
  # Nothing is left to answer it with.
220
- return unanswered_question if @input_ended
229
+ return unanswered_question(event[:pending_question]) if @input_ended
221
230
 
222
231
  ask(event[:pending_question])
223
232
  when :question_answered then question_answered(event)
@@ -557,7 +566,8 @@ module Samagotchi
557
566
  @own_failed ? :turn_failed : :detached
558
567
  end
559
568
 
560
- def unanswered_question
569
+ def unanswered_question(pending)
570
+ @unanswered = pending
561
571
  detach("A question waits for an answer: chi --attach #{@client.session_id}")
562
572
  :unanswered
563
573
  end
@@ -634,6 +644,7 @@ module Samagotchi
634
644
  # What was typed at the prompt waits for the question to close.
635
645
  @set_aside = @reader&.typed_text unless @question
636
646
  @question = QuestionPrompt.new(pending)
647
+ @question_pending = pending
637
648
  # An edit's diff goes above, into the scrollback, once per question
638
649
  # (a join gets it from the snapshot and may get the event too).
639
650
  unless @previewed_id == @question.id
@@ -656,7 +667,8 @@ module Samagotchi
656
667
  return @screen.commit(answer.error)
657
668
  end
658
669
 
659
- reply = @client.answer(id: @question.id, selected: answer.selected, freeform: answer.freeform)
670
+ parent = @parent_answers ? { client_id: Guardrails::ParentApprovals::CLIENT_ID } : {}
671
+ reply = @client.answer(id: @question.id, selected: answer.selected, freeform: answer.freeform, **parent)
660
672
  case reply.status
661
673
  when 200
662
674
  @answered_ids << @question.id