samagotchi 0.62.0 → 0.64.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 (93) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +78 -1
  3. data/README.md +4 -1
  4. data/bin/chi +3 -2
  5. data/docs/architecture.md +12 -2
  6. data/docs/cli.md +20 -9
  7. data/docs/configuration.md +16 -9
  8. data/docs/context.md +13 -3
  9. data/docs/hooks.md +6 -3
  10. data/docs/internals/engine.md +10 -3
  11. data/docs/memory.md +1 -1
  12. data/docs/plugins.md +96 -2
  13. data/docs/releasing.md +1 -1
  14. data/docs/sessions.md +11 -8
  15. data/docs/sub-agent.md +27 -4
  16. data/docs/testing.md +13 -4
  17. data/lib/samagotchi/answer_command.rb +5 -1
  18. data/lib/samagotchi/answer_refs.rb +18 -26
  19. data/lib/samagotchi/bridge/turn_accumulator.rb +1 -0
  20. data/lib/samagotchi/bridge.rb +41 -5
  21. data/lib/samagotchi/bundles/claude-peer/manifest.yml +22 -0
  22. data/lib/samagotchi/bundles/claude-peer/plugin.rb +406 -0
  23. data/lib/samagotchi/bundles/claude-peer/scripts/chi-peer-SKILL.md +104 -0
  24. data/lib/samagotchi/bundles/claude-peer/scripts/pair +66 -0
  25. data/lib/samagotchi/bundles/claude-peer/skill_claude_peer.md +60 -0
  26. data/lib/samagotchi/bundles/dev/manifest.yml +2 -1
  27. data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
  28. data/lib/samagotchi/bundles/loop-guard/plugin.rb +17 -6
  29. data/lib/samagotchi/bundles/slow-commands/manifest.yml +2 -2
  30. data/lib/samagotchi/bundles/slow-commands/plugin.rb +5 -3
  31. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  32. data/lib/samagotchi/bundles/system/manifest.yml +3 -3
  33. data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
  34. data/lib/samagotchi/child_reports.rb +7 -2
  35. data/lib/samagotchi/cli/parent_wait.rb +9 -2
  36. data/lib/samagotchi/composer_route.rb +90 -0
  37. data/lib/samagotchi/config.rb +6 -3
  38. data/lib/samagotchi/context_absorber.rb +25 -6
  39. data/lib/samagotchi/continue_offer.rb +17 -3
  40. data/lib/samagotchi/delivery.rb +13 -8
  41. data/lib/samagotchi/engine.rb +2 -0
  42. data/lib/samagotchi/host_config.rb +6 -6
  43. data/lib/samagotchi/host_model.rb +2 -2
  44. data/lib/samagotchi/idle_recap.rb +2 -3
  45. data/lib/samagotchi/image_store.rb +8 -0
  46. data/lib/samagotchi/interrupted_turn.rb +7 -3
  47. data/lib/samagotchi/last_turn.rb +5 -2
  48. data/lib/samagotchi/pending_input_queue.rb +10 -3
  49. data/lib/samagotchi/plugin/attached_context.rb +7 -4
  50. data/lib/samagotchi/plugin/sessions.rb +27 -5
  51. data/lib/samagotchi/recap_setup.rb +3 -3
  52. data/lib/samagotchi/reply_wait.rb +171 -6
  53. data/lib/samagotchi/row_refs.rb +3 -4
  54. data/lib/samagotchi/send_command.rb +63 -19
  55. data/lib/samagotchi/session.rb +12 -0
  56. data/lib/samagotchi/session_commands.rb +1 -1
  57. data/lib/samagotchi/session_inbox.rb +63 -7
  58. data/lib/samagotchi/session_manager.rb +49 -20
  59. data/lib/samagotchi/terminal_ui/attach_launcher.rb +6 -3
  60. data/lib/samagotchi/terminal_ui/attached_loop.rb +73 -35
  61. data/lib/samagotchi/terminal_ui/formatting.rb +3 -19
  62. data/lib/samagotchi/terminal_ui.rb +103 -68
  63. data/lib/samagotchi/tool_activity.rb +5 -5
  64. data/lib/samagotchi/tools/delegate.rb +28 -1
  65. data/lib/samagotchi/tools/delegate_cursor.rb +9 -4
  66. data/lib/samagotchi/tools/delegate_wait.rb +17 -5
  67. data/lib/samagotchi/tools/execute.rb +33 -8
  68. data/lib/samagotchi/turn_checkpoint.rb +9 -3
  69. data/lib/samagotchi/version.rb +1 -1
  70. data/lib/samagotchi/web/app.rb +6 -4
  71. data/lib/samagotchi/web/public/activity.js +2 -2
  72. data/lib/samagotchi/web/public/app.js +53 -91
  73. data/lib/samagotchi/web/public/card.js +73 -23
  74. data/lib/samagotchi/web/public/chains.js +6 -4
  75. data/lib/samagotchi/web/public/command_view.js +39 -35
  76. data/lib/samagotchi/web/public/commands.js +8 -8
  77. data/lib/samagotchi/web/public/composer.js +3 -3
  78. data/lib/samagotchi/web/public/copy.js +7 -15
  79. data/lib/samagotchi/web/public/diff_view.js +20 -23
  80. data/lib/samagotchi/web/public/format.js +0 -48
  81. data/lib/samagotchi/web/public/images.js +12 -16
  82. data/lib/samagotchi/web/public/question_cards.js +3 -2
  83. data/lib/samagotchi/web/public/read_view.js +10 -9
  84. data/lib/samagotchi/web/public/scope.js +23 -0
  85. data/lib/samagotchi/web/public/timing.js +2 -8
  86. data/lib/samagotchi/web/public/turn_html.js +119 -59
  87. data/lib/samagotchi/web/public/turn_model.js +13 -3
  88. data/lib/samagotchi/web/public/turn_view.js +73 -116
  89. data/lib/samagotchi/web/public/vendor/directive.js +8 -0
  90. data/lib/samagotchi/web/public/vendor/directives/unsafe-html.js +8 -0
  91. data/lib/samagotchi/worker.rb +53 -35
  92. data/lib/samagotchi/worker_inbound.rb +12 -11
  93. metadata +9 -1
@@ -94,18 +94,21 @@ module Samagotchi
94
94
  nil
95
95
  end
96
96
 
97
- # This session's sources (its own, then its project's).
98
- # @return [Array<Hash>] {name:, scope:, why:, hint:, fetched_at:, error:, provider:}
97
+ # This session's sources (its own, then its project's); none before
98
+ # the session has an id or a saved file.
99
+ # +summary+ is the last text's summary (nil before any text).
100
+ # @return [Array<Hash>] {name:, scope:, why:, hint:, fetched_at:, error:, provider:, summary:}
99
101
  def list
100
102
  id = @host.session_id.call
101
- return [] unless id
103
+ # No id, or no saved file yet (a command before the first turn).
104
+ return [] unless id && Session.exist?(id, state_dir: state_dir)
102
105
 
103
106
  session = Session.load(id, state_dir: state_dir)
104
107
  ContextSources.attached(id, project_root: session.project_root, state_dir: state_dir).map do |attached|
105
108
  snapshot = attached.snapshot
106
109
  { name: attached.name, scope: attached.location.scope, why: attached.source.why,
107
110
  hint: snapshot.hint || attached.source.hint, fetched_at: snapshot.fetched_at, error: snapshot.error,
108
- provider: attached.source.provider }
111
+ provider: attached.source.provider, summary: snapshot.summary }
109
112
  end
110
113
  end
111
114
 
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "../client_id"
4
+ require_relative "../delivery"
4
5
  require_relative "../session"
5
6
  require_relative "../bridge_client"
6
7
  require_relative "../bridge/turn_accumulator"
@@ -62,18 +63,28 @@ module Samagotchi
62
63
  # Send +text+ to session +id+ as a user message (it runs as a turn;
63
64
  # a stopped session is woken). Waits up to 5 s for its worker, so call
64
65
  # it from an anytime command or your own thread, never a tool or hook
65
- # of a running turn.
66
+ # of a running turn. A running turn takes it at its next step; with
67
+ # +delivery: :queue+ it runs as a turn of its own after that one
68
+ # instead. A plugin never cuts the running turn's thinking.
69
+ # @param delivery [Symbol, String, nil] nil or :next_step (default),
70
+ # :queue
66
71
  # @return [String] the id of the session it went to
67
- # @raise [Error] no such session, a REPL owns it, or it wasn't taken
68
- def send(id, text)
72
+ # @raise [Error] no such session, a REPL owns it, it wasn't taken
73
+ # (a worker older than queueing refuses a queue), or delivery is
74
+ # :cut or unknown
75
+ def send(id, text, delivery: nil)
69
76
  raise Error, "nothing to send" if text.to_s.strip.empty?
70
77
 
78
+ wire = plugin_delivery(delivery)
71
79
  state_dir = self.state_dir
72
80
  sid = resolve(id, state_dir)
73
- delivered = SessionManager.deliver_turn(sid, prompt: text.to_s, client_id: ClientId::PLUGIN, state_dir: state_dir)
81
+ delivered = SessionManager.deliver_turn(sid, prompt: text.to_s, client_id: ClientId::PLUGIN, state_dir: state_dir,
82
+ delivery: wire)
74
83
  case delivered[:status]
75
84
  when :accepted then sid
76
- when :refused then raise Error, "session #{sid[0, 8]} refused the message (#{delivered.dig(:ack, "error")})"
85
+ when :refused
86
+ ack = delivered[:ack] || {}
87
+ raise Error, "session #{sid[0, 8]} refused the message (#{ack["error"]}#{": #{ack["detail"]}" if ack["detail"]})"
77
88
  when :timeout then raise Error, "session #{sid[0, 8]}'s worker did not answer in time; the message was not sent"
78
89
  else raise Error, "the message to session #{sid[0, 8]} could not be written"
79
90
  end
@@ -81,6 +92,17 @@ module Samagotchi
81
92
  raise Error, "session #{sid[0, 8]} is open in a chi REPL, which takes no messages from others"
82
93
  end
83
94
 
95
+ # The wire value for send's delivery: (nil for the default).
96
+ def plugin_delivery(delivery)
97
+ case delivery&.to_s
98
+ when nil, "", Delivery::NEXT_STEP then nil
99
+ when Delivery::QUEUE then Delivery::QUEUE
100
+ when Delivery::CUT then raise Error, "plugins never cut: send it plain (the next step) or delivery: :queue"
101
+ else raise Error, "delivery takes :next_step or :queue, not #{delivery.inspect}"
102
+ end
103
+ end
104
+ private :plugin_delivery
105
+
84
106
  # Session +id+ now: from its worker when one runs (with a running turn
85
107
  # so far), else as saved.
86
108
  # @return [Hash] {id:, title:, status:, parent_id:, running:, messages:}
@@ -31,12 +31,12 @@ module Samagotchi
31
31
  # @return [IdleRecap, nil]
32
32
  def self.build(recap, engine:, host_registry:, session_target:, session_id:, state_dir:)
33
33
  return nil if recap == false
34
- # The TUI passes the config file's section; a worker passes nothing, so
35
- # read it here too (a scalar `recap: false` is only seen this way).
34
+ # The REPL and a worker pass nothing: the config is read here alone
35
+ # (a scalar `recap: false` is only seen this way).
36
36
  return nil if recap.nil? && ConfigFile.recap_config == false
37
37
  return nil if Samagotchi::Config.get("recap.enabled") == false
38
38
 
39
- # Normalize kwarg (TerminalUI passes recap: recap_config hash or nil)
39
+ # An explicit kwarg (a spec, an embedder) over the registry.
40
40
  kwarg_config = recap.is_a?(Hash) ? recap : {}
41
41
 
42
42
  base_url = string_config(kwarg_config, :base_url) || registry_string("recap.base_url")
@@ -3,6 +3,7 @@
3
3
  require_relative "session"
4
4
  require_relative "turn_note"
5
5
  require_relative "session_inbox"
6
+ require_relative "worker_sidecar"
6
7
 
7
8
  module Samagotchi
8
9
  # Loaded on first use: session_manager requires terminal_ui, which
@@ -14,9 +15,15 @@ module Samagotchi
14
15
  # output/<timestamp>.txt file (one per turn that ended with visible text,
15
16
  # written just before its idle save), so "next" means a filename past the
16
17
  # cursor the caller holds: the newest one it already has, or nil for any.
18
+ # A wait for one input's turn (turn:, a queued message) looks for that
19
+ # turn's own file instead (output/<timestamp>-<enqueued_id>.txt).
17
20
  # The delegate tools and `chi send --wait` wrap it in their own words.
18
21
  module ReplyWait
19
22
  POLL_INTERVAL = 0.5
23
+ # A worker's sidecar feature (Bridge::FEATURES): its running turn's
24
+ # checkpoint mark (InterruptedTurn#enqueued_id), a continue turn and a
25
+ # Stop's last_turn carry the turn's enqueued_id (#own_turn).
26
+ TURN_IDS_FEATURE = "turn_ids"
20
27
 
21
28
  # @!attribute status [Symbol] :done, :waiting_for_answer, :no_reply,
22
29
  # :error, :stopped, :canceled, :timeout or :worker_gone
@@ -38,7 +45,9 @@ module Samagotchi
38
45
  # @!attribute retryable [Boolean, nil] whether that error may pass on a
39
46
  # retry
40
47
  # @!attribute cancel_reason [String, nil] a "canceled" turn's reason
41
- # (user, hook, ctrl_c, manual)
48
+ # (user, hook, ctrl_c, manual, session_stopped: the session was
49
+ # stopped mid-turn), passed through as the worker saved it; any
50
+ # reason is a "canceled" outcome
42
51
  # @!attribute stopped_by [String, nil] the hook that canceled it
43
52
  # (loop-guard)
44
53
  # @!attribute session [Session, nil] the session as the look that
@@ -70,18 +79,64 @@ module Samagotchi
70
79
  # @param interject [#call, nil] called once a poll, before the sleep
71
80
  # (the delegate wait relays other children's approvals there); the
72
81
  # time it takes doesn't count against +timeout+
82
+ # @param turn [String, nil] the enqueued_id of the input whose turn is
83
+ # waited for (a queued message, which runs after the turn running
84
+ # now). With it only that turn ends the wait: its reply file
85
+ # (SessionInbox.reply_for), or an idle session whose last_turn has
86
+ # that enqueued_id (failed, canceled, empty: #no_reply). Other turns'
87
+ # replies and ends are not its, and a pending question counts only
88
+ # when it is that turn's (#own_question?). A last_turn that ended
89
+ # before the baseline's (the turn whose continue offer was just
90
+ # answered) is not the end. An input a running turn merged as a
91
+ # steer (taken, never run as its own) is waited for as without turn:
92
+ # (that turn's reply or question). A turn seen running, then past
93
+ # (another turn runs or ended, last_turn not it): :no_reply, outcome
94
+ # unknown.
95
+ # @param queued [Boolean] the turn: input was sent as a queue (or with
96
+ # images), which a running turn never merges: no fallback, even when
97
+ # its file was taken before the first look
73
98
  # @return [Result]
74
99
  # @raise [ArgumentError] no such session
75
100
  def call(id, state_dir:, cursor:, timeout: nil, poll_interval: POLL_INTERVAL, cancelled: -> { false },
76
- baseline: nil, owner_grace: nil, interject: nil)
101
+ baseline: nil, owner_grace: nil, interject: nil, turn: nil, queued: false)
77
102
  deadline = timeout && (monotonic + timeout.to_f)
78
103
  seen_running = false
79
104
  gone_since = nil
105
+ turn = turn.to_s.empty? ? nil : turn.to_s
106
+ session_dir = Session.session_dir(id, state_dir: state_dir) if turn
107
+ # Whether the turn's input file has been taken (latched): looked at
108
+ # before each load, so a running session seen after it is that turn
109
+ # or a later one, never the turn that ran when the message was sent.
110
+ claimed = false
111
+ # Its input runs a turn of its own (a queue, images: +queued+, or seen
112
+ # waiting as one): never merged into another turn.
113
+ own_turn_input = queued
114
+ # The turn was seen running (its mark's id), and last_turn's ended_at
115
+ # then: a later end that isn't its is a turn after it.
116
+ seen_own = false
117
+ own_base = nil
80
118
 
81
119
  loop do
120
+ if turn && !claimed
121
+ waiting = SessionInbox.waiting_input(session_dir, turn)
122
+ own_turn_input ||= waiting == :turn_end
123
+ claimed = waiting.nil?
124
+ end
82
125
  session = Session.load(id, state_dir: state_dir)
126
+ if turn && !seen_own && session.status == Session::STATUS_RUNNING && session.interrupted_turn&.enqueued_id == turn
127
+ seen_own = true
128
+ own_base = session.last_turn&.ended_at
129
+ end
130
+ # Taken but never run as a turn of its own: a running turn's drain
131
+ # merged it as a steer, so that turn's reply (or question) is its
132
+ # answer. Waited for as the next reply past the cursor, as without
133
+ # turn:, never as a turn that will not come.
134
+ if turn && claimed && !seen_own && !own_turn_input && merged_elsewhere?(session, turn, baseline, session_dir)
135
+ turn = nil
136
+ seen_running = session.status == Session::STATUS_RUNNING
137
+ end
83
138
 
84
- if (reply = reply_past(id, state_dir: state_dir, cursor: cursor))
139
+ if (reply = turn ? reply_for_turn(id, state_dir: state_dir, turn: turn) : reply_past(id, state_dir: state_dir, cursor: cursor))
85
140
  reply.session = settled(session, id, state_dir: state_dir)
86
141
  return reply
87
142
  end
@@ -98,13 +153,31 @@ module Samagotchi
98
153
  # REPL's is answered there.
99
154
  pending = session.pending_question
100
155
  if pending && !(baseline && pending[:id] == baseline[:question_id]) &&
156
+ (turn.nil? || own_question?(session, turn, claimed)) &&
101
157
  session.waiting_question(live: SessionManager.worker_live?(id, state_dir: state_dir))
102
158
  return Result.new(status: :waiting_for_answer, question: pending, session: session)
103
159
  end
104
160
 
105
161
  return Result.new(status: :canceled) if cancelled.call
106
162
 
107
- if session.status == Session::STATUS_RUNNING
163
+ if turn
164
+ # Only the turn's own end: the session runs another turn when the
165
+ # wait starts, and that one's end is not the answer. A last_turn
166
+ # with its id is its end, even with a next queued turn running
167
+ # by now (the look between the two missed the idle).
168
+ # Only an end after the baseline: a continue offer answered from
169
+ # idle leaves the exhausted turn's last_turn, with the same id,
170
+ # until the continue turn (or a Stop) records its own.
171
+ if session.last_turn&.enqueued_id == turn && ended_since_baseline?(session, baseline)
172
+ return reply_for_turn(id, state_dir: state_dir, turn: turn)&.tap { |r| r.session = session } ||
173
+ no_reply(session, baseline, fresh: true)
174
+ end
175
+ # Seen running, and now another turn runs or a later turn ended
176
+ # while last_turn (looked at first) isn't its: lost (its worker
177
+ # died), or it ended with no reply and a next turn ran and ended
178
+ # between two looks. Waiting on would sit through those turns.
179
+ return Result.new(status: :no_reply, session: session) if seen_own && past_own?(session, turn, own_base)
180
+ elsif session.status == Session::STATUS_RUNNING
108
181
  seen_running = true
109
182
  elsif (seen_running && !baseline&.key?(:last_turn)) || turn_ended_since?(session, baseline)
110
183
  # It ran and is idle again with no new reply: canceled, failed or
@@ -164,11 +237,103 @@ module Samagotchi
164
237
  !!(baseline[:messages] && session.messages.size > baseline[:messages])
165
238
  end
166
239
 
240
+ # The turn a caller that sent nothing means (chi answer, chi send --wait
241
+ # with no message): the one running now (its checkpoint mark's
242
+ # enqueued_id), or, idle, the turn whose continue offer stands (its
243
+ # last_turn's), when the worker's sidecar says it ties a continue turn
244
+ # to that id (TURN_IDS_FEATURE). nil otherwise (no id known, a worker
245
+ # from before): the caller waits for the next reply as before.
246
+ # @param session [Session] the session as loaded now
247
+ # @return [String, nil] an enqueued_id for call's turn:
248
+ def own_turn(session, session_dir:)
249
+ if session.status == Session::STATUS_RUNNING
250
+ # Only a worker that writes it has one: no feature check.
251
+ return session.interrupted_turn&.enqueued_id
252
+ end
253
+
254
+ pending = session.pending_question
255
+ return nil unless pending && pending[:kind].to_s == "continue"
256
+ return nil unless turn_ids?(session_dir)
257
+
258
+ session.last_turn&.enqueued_id
259
+ end
260
+
261
+ # Whether the session's worker sidecar names TURN_IDS_FEATURE.
262
+ def turn_ids?(session_dir)
263
+ WorkerSidecar.read(session_dir)&.features&.include?(TURN_IDS_FEATURE) || false
264
+ end
265
+
266
+ # A pending question is a turn: wait's (its enqueued_id) only when that
267
+ # turn asked it. While a turn runs its checkpoint mark says which turn
268
+ # that is (a continue turn carries its offer turn's id); a mark with no
269
+ # id (a worker from before) falls back to the claimed rule (its input
270
+ # claimed, the session running, last_turn not already it). Idle: only
271
+ # its own continue offer (last_turn has the id). Any other is another
272
+ # turn's, whose questions are the user's: the wait goes on.
273
+ def own_question?(session, turn, claimed)
274
+ ended = session.last_turn&.enqueued_id == turn
275
+ return ended unless session.status == Session::STATUS_RUNNING
276
+
277
+ running_id = session.interrupted_turn&.enqueued_id
278
+ return running_id == turn if running_id
279
+
280
+ # Ended, running again: a next turn.
281
+ !ended && claimed
282
+ end
283
+
284
+ # The turn's end, after the baseline was taken (with no baseline, or
285
+ # one without last_turn, any).
286
+ def ended_since_baseline?(session, baseline)
287
+ return true unless baseline&.key?(:last_turn)
288
+
289
+ session.last_turn&.ended_at != baseline[:last_turn]
290
+ end
291
+
292
+ # A turn: wait's taken input ran in another turn (a steer its drain
293
+ # merged): that turn runs (its mark has another id, or none from a
294
+ # worker that writes ids), or, idle, a turn not its ended since the
295
+ # baseline. last_turn or a reply file being its own is never that.
296
+ def merged_elsewhere?(session, turn, baseline, session_dir)
297
+ return false if session.last_turn&.enqueued_id == turn || SessionInbox.reply_for(session_dir, turn)
298
+
299
+ if session.status == Session::STATUS_RUNNING
300
+ running_id = session.interrupted_turn&.enqueued_id
301
+ return running_id != turn if running_id
302
+
303
+ return turn_ids?(session_dir)
304
+ end
305
+ baseline&.key?(:last_turn) ? turn_ended_since?(session, baseline) : false
306
+ end
307
+
308
+ # A turn seen running (+own_base+: last_turn's ended_at then) is past:
309
+ # last_turn isn't its, and another turn runs (a mark with another id)
310
+ # or a turn ended since.
311
+ def past_own?(session, turn, own_base)
312
+ return false if session.last_turn&.enqueued_id == turn
313
+
314
+ running_id = session.status == Session::STATUS_RUNNING ? session.interrupted_turn&.enqueued_id : nil
315
+ return true if running_id && running_id != turn
316
+
317
+ ended = session.last_turn&.ended_at
318
+ !ended.nil? && ended != own_base
319
+ end
320
+
321
+ # @return [Result, nil] :done with the reply of the turn that ran the
322
+ # input +turn+ (its enqueued_id)
323
+ def reply_for_turn(id, state_dir:, turn:)
324
+ file = SessionInbox.reply_for(Session.session_dir(id, state_dir: state_dir), turn)
325
+ return nil unless file
326
+
327
+ Result.new(status: :done, text: File.read(File.join(reply_dir(id, state_dir: state_dir), file)), file: file)
328
+ end
329
+
330
+ # @param fresh [Boolean, nil] the last_turn is known to be the turn
331
+ # waited for (a turn: wait); nil decides it from the baseline
167
332
  # @return [Result] :no_reply with the outcome the session's last_turn
168
333
  # records, when it is the turn waited for
169
- def no_reply(session, baseline)
334
+ def no_reply(session, baseline, fresh: nil)
170
335
  last = session.last_turn || LastTurn.new
171
- fresh = baseline.nil? || !baseline.key?(:last_turn) || (last.ended_at && last.ended_at != baseline[:last_turn])
336
+ fresh = baseline.nil? || !baseline.key?(:last_turn) || (last.ended_at && last.ended_at != baseline[:last_turn]) if fresh.nil?
172
337
  outcome = fresh ? last.outcome : nil
173
338
  return Result.new(status: :no_reply, outcome: "exhausted", limit: last.limit, session: session) if fresh && last.exhausted
174
339
 
@@ -88,7 +88,7 @@ module Samagotchi
88
88
  # +cwd+ as [its spelling, its real path] (one when they are the same,
89
89
  # none without a +cwd+). absolute_path, not expand_path: a leading ~ is
90
90
  # a name, never a home. One realpath per row: the lambda's refs are
91
- # checked together.
91
+ # checked together. The answer's refs (AnswerRef) check against it too.
92
92
  def roots(cwd)
93
93
  return [] if cwd.to_s.empty?
94
94
 
@@ -100,10 +100,10 @@ module Samagotchi
100
100
  end
101
101
  [given, real].uniq
102
102
  end
103
- private_class_method :roots
104
103
 
105
104
  # +ref+ with a file's path absolute, resolved against the folder
106
- # (+roots+, #roots), under its real spelling.
105
+ # (+roots+, #roots), under its real spelling: plan D7, for a plugin
106
+ # row's refs and an answer's (AnswerRef) alike.
107
107
  # @raise [ArgumentError] a relative path with no folder (or not
108
108
  # +relative+), or a path outside the folder
109
109
  def inside(ref, roots, relative)
@@ -121,6 +121,5 @@ module Samagotchi
121
121
 
122
122
  ref.with(target: ref.target.merge(path: "#{roots.last}#{path.delete_prefix(root)}").freeze)
123
123
  end
124
- private_class_method :inside
125
124
  end
126
125
  end
@@ -38,10 +38,11 @@ module Samagotchi
38
38
  CUT_OUTCOMES = %w[now waits off].freeze
39
39
 
40
40
  USAGE = <<~TEXT
41
- Usage: chi send [-m TEXT] [--image PATH]... [--cut] (ID|PREFIX)...
41
+ Usage: chi send [-m TEXT] [--image PATH]... [--cut | --queue] (ID|PREFIX)...
42
42
  chi send --new [--dir DIR] [--model M] [--llm-context LAYERS] [--thinking LEVEL] [-m TEXT] [--image PATH]...
43
43
  chi send --new --continues (ID|PREFIX|last:ID) [-m TEXT] [--image PATH]...
44
44
  chi send --wait [--timeout S] [--format json] [-m TEXT] [--image PATH]... (--new | ID)
45
+ chi send --queue --wait [--timeout S] [--format json] [-m TEXT] ID
45
46
  chi send --wait [--timeout S] [--format json] ID
46
47
  Sends a message to each session, as if typed in it: a turn starts,
47
48
  or a running one takes it at its next step. A stopped session's
@@ -92,6 +93,12 @@ module Samagotchi
92
93
  next step and cuts nothing. To an idle session it
93
94
  sends normally. Refused with --wait into a running
94
95
  turn (the steer's turn is the running one)
96
+ --queue run the message as a turn of its own after the running
97
+ turn ends, instead of merging it at its next step. To
98
+ an idle session it runs at once. With --wait it
99
+ waits for that turn's own answer, so a running turn
100
+ is fine. Not with --cut. Refused for a worker older
101
+ than queueing (it would steer; restart it)
95
102
  --timeout S (--wait) give up after S seconds, exit 4 (the turn
96
103
  goes on); default no limit
97
104
  --format json
@@ -99,8 +106,9 @@ module Samagotchi
99
106
  status answered (text), question (question,
100
107
  answer_with), running, or failed, canceled,
101
108
  limit, no_answer, error, worker_gone, stopped,
102
- command (detail); with --cut also delivery (what
103
- the worker applied) and cut (now, waits, off)
109
+ command (detail); with --cut or --queue also
110
+ delivery (what the worker applied: next_step, cut,
111
+ queue), with --cut also cut (now, waits, off)
104
112
  Exit with --wait: 0 answered, 1 failed or gone, 2 usage, 3 a
105
113
  question waits, 4 still running (--timeout), 130 Ctrl-C.
106
114
  Only sessions on this machine. Answers show in the attached TUI
@@ -121,6 +129,7 @@ module Samagotchi
121
129
  f.value "--image", key: :images, repeat: true
122
130
  f.switch "--wait"
123
131
  f.switch "--cut"
132
+ f.switch "--queue"
124
133
  f.value "--timeout"
125
134
  f.value "--format"
126
135
  # Starting a turn in every live session at once is too easy to do
@@ -181,6 +190,7 @@ module Samagotchi
181
190
  # With --wait stdout is the answer alone.
182
191
  @info = options[:wait] ? @stderr : @stdout
183
192
  @cut = options[:cut] == true
193
+ @queue = options[:queue] == true
184
194
  prompt = compose(utf8(read_stdin), utf8(options[:message]))
185
195
  # Before the wait-only case, which would drop the images.
186
196
  return usage_error("--image needs a message: pass -m TEXT or pipe it in") if prompt.nil? && !options[:images].empty?
@@ -236,6 +246,8 @@ module Samagotchi
236
246
  return parsed if parsed.is_a?(Integer)
237
247
 
238
248
  options = parsed.options.merge(ids: parsed.args)
249
+ return usage_error("--cut and --queue: choose one") if options[:cut] && options[:queue]
250
+
239
251
  if options[:format]
240
252
  return usage_error("--format needs --wait") unless options[:wait]
241
253
  return usage_error("--format takes text or json") unless FORMATS.include?(options[:format])
@@ -385,12 +397,15 @@ module Samagotchi
385
397
  # are taken before the message goes in, so neither an older reply nor a
386
398
  # turn that ends before the first look is mistaken for the answer. A
387
399
  # running turn is refused: a message that misses it runs next, and the
388
- # running one's reply would come back as the answer.
400
+ # running one's reply would come back as the answer. Not with --queue:
401
+ # the message's own turn is waited for (ReplyWait's turn:, its
402
+ # enqueued_id), whatever runs before it.
389
403
  # @return [Integer] the exit status
390
404
  def run_wait(prompt, options)
391
405
  id = resolve(options[:ids].first) or return 1
392
406
  session = Session.load(id, state_dir: @state_dir)
393
- if session.status == Session::STATUS_RUNNING && (owner = SessionManager.session_owner(id, state_dir: @state_dir))
407
+ if session.status == Session::STATUS_RUNNING && (owner = SessionManager.session_owner(id, state_dir: @state_dir)) &&
408
+ (!@queue || session.waiting_question(live: owner.worker?))
394
409
  # The turn waits on a question: the message would only queue
395
410
  # behind it, and the answer goes in with chi answer.
396
411
  if (waiting = session.waiting_question(live: owner.worker?))
@@ -412,7 +427,11 @@ module Samagotchi
412
427
  return 1 unless deliver(id, prompt)
413
428
  return report_command(id) if @sent_command
414
429
 
415
- wait_for_reply(id, cursor: cursor, baseline: baseline, timeout: options[:timeout])
430
+ # The ack's id names the queued message's turn, also when the turn
431
+ # running at the check ended before it went in (an idle worker's ack
432
+ # says next_step, and the file still runs as that turn).
433
+ turn = @queue ? @sent_enqueued_id : nil
434
+ wait_for_reply(id, cursor: cursor, baseline: baseline, timeout: options[:timeout], turn: turn, queued: !turn.nil?)
416
435
  end
417
436
 
418
437
  # --wait with no message: the session's next reply, sending nothing,
@@ -421,15 +440,18 @@ module Samagotchi
421
440
  # was already reported. No message count: a note landing between turns
422
441
  # grows the messages without a turn. An idle session with no worker
423
442
  # waits for whatever wakes one (the web, chi send) rather than calling
424
- # it gone.
443
+ # it gone. The turn running now, or the one whose continue offer
444
+ # stands, is the one waited for (ReplyWait.own_turn): its reply, not a
445
+ # turn's queued behind it; with none known, the next reply.
425
446
  # @return [Integer] the exit status
426
447
  def run_wait_only(options)
427
448
  id = resolve(options[:ids].first) or return 1
428
449
  session = Session.load(id, state_dir: @state_dir)
429
450
  live = session.status == Session::STATUS_RUNNING || SessionManager.session_owner(id, state_dir: @state_dir)
451
+ turn = ReplyWait.own_turn(session, session_dir: Session.session_dir(id, state_dir: @state_dir))
430
452
  wait_for_reply(id, cursor: ReplyWait.newest_reply(id, state_dir: @state_dir),
431
453
  baseline: baseline_of(session).merge(messages: nil),
432
- timeout: options[:timeout], owner_grace: live ? self.class::WORKER_GONE_AFTER : nil)
454
+ timeout: options[:timeout], owner_grace: live ? self.class::WORKER_GONE_AFTER : nil, turn: turn)
433
455
  end
434
456
 
435
457
  # The session as it was before the message went in (ReplyWait's
@@ -535,13 +557,21 @@ module Samagotchi
535
557
  owner = SessionManager.session_owner(id, state_dir: @state_dir)
536
558
  running = owner && session.status == Session::STATUS_RUNNING
537
559
  refs = copy_images(Session.session_dir(id, state_dir: @state_dir))
538
- # --cut asks the running turn for a cut; to an idle session it is an
539
- # ordinary message (there is nothing to cut).
540
- delivery = @cut && running ? Delivery::CUT : nil
560
+ # --cut asks the running turn for a cut (to an idle session an
561
+ # ordinary message: nothing to cut). --queue always goes as a queue,
562
+ # idle or not: the Bridge acks next_step to an idle worker and the
563
+ # file still carries it, so a turn another client starts first never
564
+ # merges it, and its turn and reply carry its enqueued_id (the
565
+ # --wait's turn:); a worker older than queueing is refused.
566
+ delivery = if @queue then Delivery::QUEUE
567
+ elsif @cut && running then Delivery::CUT
568
+ end
541
569
  result = SessionManager.deliver_turn(id, prompt: prompt, client_id: CLIENT_ID, images: refs, state_dir: @state_dir,
542
570
  delivery: delivery)
543
571
  unless result[:status] == :accepted
544
- @info.puts("#{short} failed: #{result.dig(:ack, "detail") || "could not queue it"}")
572
+ # A worker older than queueing would steer it: refused, not sent.
573
+ word = result.dig(:ack, "error") == Delivery::QUEUE_UNAVAILABLE["error"] ? "refused" : "failed"
574
+ @info.puts("#{short} #{word}: #{result.dig(:ack, "detail") || "could not queue it"}")
545
575
  return false
546
576
  end
547
577
 
@@ -556,6 +586,7 @@ module Samagotchi
556
586
  # (next_step for an idle session) and a cut's outcome.
557
587
  @sent_delivery = ack["delivery"] || delivery
558
588
  @sent_cut = ack["cut"]
589
+ @sent_enqueued_id = (ack["enqueued_id"] || ack[:enqueued_id])&.to_s
559
590
  @info.puts("#{short} sent#{with_images}#{delivery_note(ack, delivery: delivery, running: running, owner: owner)}")
560
591
  true
561
592
  rescue SessionManager::OwnedByTUI
@@ -568,12 +599,14 @@ module Samagotchi
568
599
 
569
600
  # What the line says became of the message: a plain one goes in at the
570
601
  # running turn's next step (nothing cuts by default); a --cut one names
571
- # the outcome from the ack (now, waits, off); images keep their own
572
- # wording; an idle session's turn simply runs.
602
+ # the outcome from the ack (now, waits, off); a --queue one runs after
603
+ # the turn (or now, when the turn ended before it went in); images keep
604
+ # their own wording; an idle session's turn simply runs.
573
605
  # @param ack [Hash] the Bridge's 202 body ({} for the input file)
574
606
  # @return [String] "" or " (...)"
575
607
  def delivery_note(ack, delivery:, running:, owner:)
576
608
  return " (started its worker)" if owner.nil?
609
+ return queue_note(ack, running: running) if Delivery.queue?(delivery)
577
610
  return "" unless running
578
611
 
579
612
  # A busy worker runs a message with images as its own next turn
@@ -589,12 +622,23 @@ module Samagotchi
589
622
  " #{TerminalUI::Formatting.steer_cut_note(outcome.to_sym, ack["cut_after"])}"
590
623
  end
591
624
 
592
- # More keys for --format json, with --cut only (the line is unchanged
593
- # otherwise): the delivery the worker applied (next_step to an idle
594
- # session) and a cut's outcome (now, waits, off). In P1 --cut --wait is
595
- # refused for a running turn, so the json line says next_step.
625
+ # A --queue, worded from the Bridge's ack: queue when a turn ran as it
626
+ # went in (one another client started too), next_step when none did
627
+ # (it runs now). The input file (no Bridge) carries the queue to
628
+ # whichever turn runs: worded from the session as chi read it.
629
+ def queue_note(ack, running:)
630
+ queued = ack.key?("delivery") ? ack["delivery"] == Delivery::QUEUE : running
631
+ return " (queued: runs after the current turn)" if queued
632
+
633
+ running ? " (the running turn had ended: it runs now)" : " (nothing running: it runs now)"
634
+ end
635
+
636
+ # More keys for --format json, with --cut or --queue only (the line is
637
+ # unchanged otherwise): the delivery the worker applied (next_step to
638
+ # an idle session) and a cut's outcome (now, waits, off). --cut --wait
639
+ # is refused for a running turn, so its json line says next_step.
596
640
  def report_extra
597
- return {} unless @cut
641
+ return {} unless @cut || @queue
598
642
 
599
643
  extra = { delivery: @sent_delivery || Delivery::NEXT_STEP }
600
644
  extra[:cut] = @sent_cut if @sent_cut
@@ -275,6 +275,18 @@ module Samagotchi
275
275
  { id: pending[:id], kind: %w[approval hook continue].include?(kind) ? kind : "question", relayed_to: relayed_to }.compact
276
276
  end
277
277
 
278
+ # A question saved by a process that no longer runs this session (the
279
+ # one that loads it now owns it): nobody waits on its answer, so it is
280
+ # dropped. Unsaved; the caller saves.
281
+ # @return [String, nil] the dropped question's id, nil with none
282
+ def drop_dead_question
283
+ pending = pending_question
284
+ return nil unless pending
285
+
286
+ @pending_question = nil
287
+ pending[:id].to_s
288
+ end
289
+
278
290
  # The stored project root, else (a file from before the field) the
279
291
  # project of the working directory now: nil when that is in no repo or
280
292
  # gone. +cache+ is ProjectScope.root_for's, shared across one listing.
@@ -116,7 +116,7 @@ module Samagotchi
116
116
  registry.register("/detach", "leave and keep the worker running", local: true, uis: [:attached],
117
117
  match: ->(text) { text.casecmp?("/detach") })
118
118
  # /cut TEXT and /queue TEXT while a turn runs: a delivery the UIs parse
119
- # themselves (Formatting#steer_choice) before any command check; the
119
+ # themselves (ComposerRoute.steer_choice) before any command check; the
120
120
  # entries exist for Tab completion and /help. The web composer sends
121
121
  # /cut too. /queue is a real after-the-turn list only in the local
122
122
  # REPL (the attached TUI's worker doesn't skip queued files yet), so