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
@@ -0,0 +1,213 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "delegate"
4
+ require "json"
5
+
6
+ require_relative "cli/exit"
7
+
8
+ module Samagotchi
9
+ # How a wait's end (ReplyWait::Result) reads to whoever runs chi as a
10
+ # sub-agent: `chi send --wait` and `chi answer`. One JSON object for
11
+ # --format json, or a line (a block for a question) for stderr, and the
12
+ # exit status. A question carries everything needed to answer it, so a
13
+ # parent agent can answer from the output alone. Kept apart from the
14
+ # commands so a later delegate_answer tool reports the same shape.
15
+ module ParentReport
16
+ # The approval facts a parent needs; the rest (preview, branch) is the
17
+ # card's.
18
+ APPROVAL_KEYS = %i[tool label command paths args cwd rule source reason scopes].freeze
19
+
20
+ # stderr that keeps its last line: with --format json a failure before
21
+ # any wait is reported from it (#error_line).
22
+ class LastLine < SimpleDelegator
23
+ attr_reader :last
24
+
25
+ def puts(*lines)
26
+ @last = lines.last.to_s.chomp unless lines.empty?
27
+ __getobj__.puts(*lines)
28
+ end
29
+ end
30
+
31
+ module_function
32
+
33
+ # @param result [ReplyWait::Result]
34
+ # @return [String] answered, question, failed, canceled, no_answer,
35
+ # error, worker_gone, stopped or running
36
+ def status(result)
37
+ case result.status
38
+ when :done then "answered"
39
+ when :waiting_for_answer then "question"
40
+ when :no_reply
41
+ case result.outcome
42
+ when "failed" then "failed"
43
+ when "canceled" then "canceled"
44
+ else "no_answer"
45
+ end
46
+ when :error then "error"
47
+ when :worker_gone then "worker_gone"
48
+ when :stopped then "stopped"
49
+ # ReplyWait's own :canceled is the caller giving up (its cancelled:),
50
+ # and :timeout the deadline: the turn goes on either way.
51
+ else "running"
52
+ end
53
+ end
54
+
55
+ # @return [Integer] 0 answered, 3 a question waits, 4 still running,
56
+ # 1 anything else
57
+ def exit_status(result)
58
+ case status(result)
59
+ when "answered" then CLI::Exit::OK
60
+ when "question" then CLI::Exit::QUESTION
61
+ when "running" then CLI::Exit::RUNNING
62
+ else CLI::Exit::FAILED
63
+ end
64
+ end
65
+
66
+ # @param session_id [String]
67
+ # @param timeout [Numeric, nil] the wait's --timeout (for the running detail)
68
+ # @return [Hash] the --format json object
69
+ def json(result, session_id:, timeout: nil)
70
+ report = { status: status(result), session_id: session_id }
71
+ case report[:status]
72
+ when "answered" then report[:text] = result.text.to_s
73
+ when "question"
74
+ question = question(result.question)
75
+ report[:question] = question
76
+ report[:answer_with] = answer_with(session_id, question)
77
+ else report[:detail] = detail(result, session_id: session_id, timeout: timeout)
78
+ end
79
+ report
80
+ end
81
+
82
+ # @return [String] the JSON object as one line
83
+ def json_line(result, session_id:, timeout: nil)
84
+ JSON.generate(json(result, session_id: session_id, timeout: timeout))
85
+ end
86
+
87
+ # Print a wait's end the way the commands do: the JSON object on
88
+ # stdout, or the reply on stdout and anything else on stderr (a
89
+ # question in full) after "chi send: ".
90
+ # @param command [String] "chi send", "chi answer"
91
+ # @return [Integer] the exit status
92
+ def report(result, session_id:, stdout:, stderr:, command:, json: false, timeout: nil)
93
+ if json
94
+ stdout.puts(json_line(result, session_id: session_id, timeout: timeout))
95
+ elsif result.status == :done
96
+ stdout.puts(result.text)
97
+ else
98
+ stdout.flush
99
+ text = if result.status == :waiting_for_answer
100
+ question_text(result.question, session_id: session_id)
101
+ else
102
+ "#{detail(result, session_id: session_id, timeout: timeout)}\n"
103
+ end
104
+ stderr.print("#{command}: #{text}")
105
+ end
106
+ stdout.flush
107
+ exit_status(result)
108
+ end
109
+
110
+ # A failure before any wait (no such session, a busy one, a message
111
+ # that didn't go in) as the JSON object.
112
+ # @param session_id [String, nil]
113
+ def error_line(detail, session_id: nil)
114
+ JSON.generate({ status: "error", session_id: session_id, detail: detail.to_s })
115
+ end
116
+
117
+ # A pending question (Session#pending_question) as a parent sees it.
118
+ # kind is "question" for the model's own (the desk stores none),
119
+ # "hook" or "approval".
120
+ # @return [Hash]
121
+ def question(pending)
122
+ pending ||= {}
123
+ approval = pending[:approval].is_a?(Hash) ? symbolize(pending[:approval]) : nil
124
+ {
125
+ id: pending[:id].to_s,
126
+ kind: (pending[:kind] || "question").to_s,
127
+ header: blank(pending[:header]),
128
+ text: pending[:question].to_s,
129
+ options: Array(pending[:options]).map(&:to_s),
130
+ multi_select: !!pending[:multi_select],
131
+ allow_freeform: !!pending[:allow_freeform],
132
+ approval: approval&.slice(*APPROVAL_KEYS)
133
+ }.compact
134
+ end
135
+
136
+ # The command that answers +question+; N is the parent's to fill in.
137
+ # An approval's is a deny: allowing it is the user's
138
+ # (Guardrails::ParentApprovals).
139
+ def answer_with(session_id, question)
140
+ return "chi answer #{session_id} --question #{question[:id]} --option Deny --text WHY" if approval?(question)
141
+
142
+ "chi answer #{session_id} --question #{question[:id]} --option N"
143
+ end
144
+
145
+ # What a parent does with an approval.
146
+ DENY_AND_TELL = "deny it, and tell your user"
147
+
148
+ def approval?(question)
149
+ question.is_a?(Hash) && (question[:kind] || question["kind"]).to_s == "approval"
150
+ end
151
+
152
+ # What a wait that ended without an answer says, one line.
153
+ def detail(result, session_id:, timeout: nil)
154
+ attach = "chi --attach #{session_id}"
155
+ case result.status
156
+ when :waiting_for_answer
157
+ if approval?(result.question)
158
+ "waiting for an approval: #{first_line(result.question[:question])}; #{DENY_AND_TELL}"
159
+ else
160
+ "waiting for an answer: #{first_line(result.question&.dig(:question))}; open it: #{attach} or the web"
161
+ end
162
+ when :no_reply then "#{no_reply_line(result)}; #{attach} shows it"
163
+ when :error then "the worker failed: #{result.text}; #{attach} shows what happened"
164
+ when :worker_gone then "the worker is gone; #{attach} shows what happened"
165
+ when :stopped then "the session was stopped (chi sessions stop)"
166
+ else timeout ? "still running after #{format("%g", timeout)} s: #{attach}" : "still running: #{attach}"
167
+ end
168
+ end
169
+
170
+ # The whole question for stderr: header, text, numbered options, what
171
+ # else it takes, and the commands that answer it.
172
+ # @return [String] lines, newline-terminated
173
+ def question_text(pending, session_id:)
174
+ q = question(pending)
175
+ lines = ["waiting for an answer (#{q[:kind]}): #{q[:header] || first_line(q[:text])}"]
176
+ text_lines = q[:text].strip.lines.map(&:rstrip)
177
+ text_lines = text_lines.drop(1) unless q[:header]
178
+ lines.concat(text_lines.map { |line| " #{line}" })
179
+ q[:options].each_with_index { |option, i| lines << " #{i + 1}. #{option}" }
180
+ if approval?(q)
181
+ lines << " allowing it is up to your user: #{DENY_AND_TELL}"
182
+ lines << " deny: #{answer_with(session_id, q)}"
183
+ else
184
+ lines << " more than one allowed: repeat --option" if q[:multi_select]
185
+ lines << " free text allowed: --text" if q[:allow_freeform]
186
+ lines << " answer: #{answer_with(session_id, q)}"
187
+ lines << " or open it: chi --attach #{session_id} or the web"
188
+ end
189
+ lines.map { |line| "#{line}\n" }.join
190
+ end
191
+
192
+ def no_reply_line(result)
193
+ case result.outcome
194
+ when "failed" then result.text.to_s.strip.empty? ? "the turn failed" : "the turn failed: #{result.text.strip}"
195
+ when "canceled" then "the turn was canceled"
196
+ when "completed" then "the turn ended with no visible answer"
197
+ else "the turn ended without a reply (canceled, failed or empty)"
198
+ end
199
+ end
200
+
201
+ def first_line(text)
202
+ text.to_s.strip.lines.first.to_s.strip
203
+ end
204
+
205
+ def blank(value)
206
+ value.to_s.strip.empty? ? nil : value.to_s
207
+ end
208
+
209
+ def symbolize(hash)
210
+ hash.each_with_object({}) { |(key, value), out| out[key.to_sym] = value }
211
+ end
212
+ end
213
+ end
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "json"
3
4
  require_relative "../log"
4
5
  require_relative "../hooks/registry"
5
6
  require_relative "../tools/args"
@@ -298,14 +299,26 @@ module Samagotchi
298
299
  schema: spec[:schema], label: spec[:label],
299
300
  handler: lambda { |call, _kctx|
300
301
  result = block.call(Api.args_of(call, parameters), context)
301
- # A String (a ToolResult too, with its images) as it is.
302
- result.is_a?(String) ? result : result.to_s
302
+ Api.result_text(result)
303
303
  },
304
304
  preview: preview && ->(call) { preview.call(Api.args_of(call, parameters))&.to_s },
305
305
  targets: targets && ->(call) { targets.call(Api.args_of(call, parameters)) }
306
306
  }
307
307
  end
308
308
 
309
+ # A tool's result as the model reads it: a String (a ToolResult too,
310
+ # with its images) as it is, a Hash or Array as JSON (its #to_s is
311
+ # Hash#inspect, which changes with the Ruby version), else #to_s.
312
+ def self.result_text(result)
313
+ case result
314
+ when String then result
315
+ when Hash, Array then JSON.generate(result)
316
+ else result.to_s
317
+ end
318
+ rescue JSON::GeneratorError
319
+ result.to_s
320
+ end
321
+
309
322
  # A tool call's arguments as a plugin sees them: the parsers' args:
310
323
  # (a call built without one, in a spec say: the call without its
311
324
  # name), string keys, typed by +parameters+, frozen.
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "model_profile"
4
+
3
5
  module Samagotchi
4
6
  # Escapes literal control-token text when arbitrary source/output is embedded
5
7
  # inside prompts, then restores those placeholders when model text is used.
@@ -13,8 +15,8 @@ module Samagotchi
13
15
  ["[[SAMAGOTCHI_LITERAL_TOOL_RESPONSE_CLOSE]]", "<tool_response|>"],
14
16
  ["[[SAMAGOTCHI_LITERAL_GEMMA_STRING_DELIM]]", '<|"|>'],
15
17
  ["[[SAMAGOTCHI_LITERAL_THINK_OPEN]]", "<|think|>"],
16
- ["[[SAMAGOTCHI_LITERAL_THOUGHT_CHANNEL_OPEN]]", "<|channel>thought"],
17
- ["[[SAMAGOTCHI_LITERAL_THOUGHT_CHANNEL_CLOSE]]", "<channel|>"]
18
+ ["[[SAMAGOTCHI_LITERAL_THOUGHT_CHANNEL_OPEN]]", ModelProfile::GEMMA_THOUGHT_CHANNEL_OPEN],
19
+ ["[[SAMAGOTCHI_LITERAL_THOUGHT_CHANNEL_CLOSE]]", ModelProfile::GEMMA_THOUGHT_CHANNEL_CLOSE]
18
20
  ].freeze
19
21
 
20
22
  QWEN_TOKEN_PAIRS = [
@@ -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.