samagotchi 0.63.0 → 0.65.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 (90) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +86 -1
  3. data/docs/architecture.md +32 -5
  4. data/docs/cli.md +41 -9
  5. data/docs/configuration.md +143 -11
  6. data/docs/internals/engine.md +25 -6
  7. data/docs/plugins.md +7 -0
  8. data/docs/sessions.md +14 -10
  9. data/docs/sub-agent.md +21 -4
  10. data/docs/testing.md +6 -0
  11. data/lib/samagotchi/answer_command.rb +5 -1
  12. data/lib/samagotchi/bridge/turn_accumulator.rb +1 -0
  13. data/lib/samagotchi/bridge.rb +29 -5
  14. data/lib/samagotchi/bundles/slow-commands/manifest.yml +2 -2
  15. data/lib/samagotchi/bundles/slow-commands/plugin.rb +5 -3
  16. data/lib/samagotchi/bundles/system/config_modification_protocol.md +2 -2
  17. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  18. data/lib/samagotchi/child_reports.rb +7 -2
  19. data/lib/samagotchi/cli/parent_wait.rb +9 -2
  20. data/lib/samagotchi/client.rb +4 -2
  21. data/lib/samagotchi/composer_route.rb +90 -0
  22. data/lib/samagotchi/config.rb +11 -7
  23. data/lib/samagotchi/continue_offer.rb +17 -3
  24. data/lib/samagotchi/delivery.rb +13 -8
  25. data/lib/samagotchi/engine.rb +41 -7
  26. data/lib/samagotchi/generation_phase.rb +17 -0
  27. data/lib/samagotchi/host_config.rb +33 -12
  28. data/lib/samagotchi/host_model.rb +2 -2
  29. data/lib/samagotchi/host_registry.rb +34 -11
  30. data/lib/samagotchi/idle_client.rb +62 -25
  31. data/lib/samagotchi/idle_recap.rb +5 -2
  32. data/lib/samagotchi/idle_target.rb +30 -7
  33. data/lib/samagotchi/interrupted_turn.rb +7 -3
  34. data/lib/samagotchi/last_turn.rb +5 -2
  35. data/lib/samagotchi/llm/adapters.rb +41 -0
  36. data/lib/samagotchi/llm/anthropic_assembly.rb +210 -0
  37. data/lib/samagotchi/llm/anthropic_messages.rb +416 -0
  38. data/lib/samagotchi/llm/anthropic_wire.rb +238 -0
  39. data/lib/samagotchi/llm/api_key.rb +26 -9
  40. data/lib/samagotchi/llm/chat_loop.rb +113 -14
  41. data/lib/samagotchi/llm/dialect.rb +57 -0
  42. data/lib/samagotchi/llm/errors.rb +81 -12
  43. data/lib/samagotchi/llm/model_listing.rb +111 -0
  44. data/lib/samagotchi/llm/model_result.rb +9 -2
  45. data/lib/samagotchi/llm/openai_chat.rb +46 -93
  46. data/lib/samagotchi/model_profile.rb +1 -1
  47. data/lib/samagotchi/pending_input_queue.rb +10 -3
  48. data/lib/samagotchi/plugin/sessions.rb +27 -5
  49. data/lib/samagotchi/reasoning_history.rb +14 -4
  50. data/lib/samagotchi/recap_setup.rb +2 -1
  51. data/lib/samagotchi/reply_wait.rb +171 -6
  52. data/lib/samagotchi/self_report.rb +18 -28
  53. data/lib/samagotchi/send_command.rb +63 -19
  54. data/lib/samagotchi/session.rb +3 -2
  55. data/lib/samagotchi/session_commands.rb +1 -1
  56. data/lib/samagotchi/session_inbox.rb +63 -7
  57. data/lib/samagotchi/session_manager.rb +49 -20
  58. data/lib/samagotchi/session_metrics.rb +6 -11
  59. data/lib/samagotchi/steer_cut.rb +4 -0
  60. data/lib/samagotchi/terminal_ui/attached_loop.rb +55 -28
  61. data/lib/samagotchi/terminal_ui/event_renderer.rb +2 -1
  62. data/lib/samagotchi/terminal_ui/formatting.rb +7 -22
  63. data/lib/samagotchi/terminal_ui/status_row.rb +24 -3
  64. data/lib/samagotchi/terminal_ui.rb +72 -57
  65. data/lib/samagotchi/tool_activity.rb +5 -5
  66. data/lib/samagotchi/tools/delegate.rb +28 -1
  67. data/lib/samagotchi/tools/delegate_cursor.rb +9 -4
  68. data/lib/samagotchi/tools/delegate_wait.rb +17 -5
  69. data/lib/samagotchi/tools/execute.rb +11 -4
  70. data/lib/samagotchi/turn_checkpoint.rb +9 -3
  71. data/lib/samagotchi/turn_note.rb +22 -5
  72. data/lib/samagotchi/version.rb +1 -1
  73. data/lib/samagotchi/web/app.rb +2 -1
  74. data/lib/samagotchi/web/public/activity.js +3 -0
  75. data/lib/samagotchi/web/public/app.js +96 -13
  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/delivery_line.js +228 -0
  79. data/lib/samagotchi/web/public/index.html +29 -0
  80. data/lib/samagotchi/web/public/live_turn.js +8 -0
  81. data/lib/samagotchi/web/public/stage_model.js +27 -7
  82. data/lib/samagotchi/web/public/stage_view.js +32 -10
  83. data/lib/samagotchi/web/public/stream_handlers.js +8 -2
  84. data/lib/samagotchi/web/public/timing.js +2 -1
  85. data/lib/samagotchi/web/public/turn_events.js +22 -8
  86. data/lib/samagotchi/web/public/turn_html.js +20 -4
  87. data/lib/samagotchi/web/public/turn_model.js +29 -12
  88. data/lib/samagotchi/web/public/turn_view.js +6 -4
  89. data/lib/samagotchi/worker.rb +7 -5
  90. metadata +9 -1
@@ -72,7 +72,8 @@ module Samagotchi
72
72
  started = monotonic
73
73
  wait = ReplyWait.call(child_id, state_dir: sd, cursor: cursor.reply_file, timeout: [left, 0].max,
74
74
  poll_interval: poll_interval, cancelled: cancelled, baseline: baseline,
75
- owner_grace: owner_grace, interject: others && -> { others.poll })
75
+ owner_grace: owner_grace, interject: others && -> { others.poll },
76
+ turn: cursor.turn)
76
77
  left -= monotonic - started
77
78
  unless wait.status == :waiting_for_answer && DelegateRelay.relayable?(relay, wait.question)
78
79
  outcomes.concat(relayed_outcomes.delete(key) || [])
@@ -143,7 +144,9 @@ module Samagotchi
143
144
  # gave, and the child as that same look loaded it (a fresh load could
144
145
  # swallow a turn that ended in between), so a later wait looks for a
145
146
  # later turn and a question already reported isn't reported again.
146
- # Nothing moves on a timeout or a cancel.
147
+ # The follow-up's turn (DelegateCursor#turn) is done with once its end
148
+ # was reported; a question keeps it. Nothing moves on a timeout or a
149
+ # cancel.
147
150
  # @param wait [ReplyWait::Result]
148
151
  # @return [DelegateCursor, nil] the new cursor
149
152
  def advance(parent_id, child_id, wait, state_dir:)
@@ -151,6 +154,7 @@ module Samagotchi
151
154
 
152
155
  DelegateCursors.update(parent_id, child_id, state_dir: state_dir) do |cursor|
153
156
  cursor = cursor.with(reply_file: wait.file) if wait.status == :done
157
+ cursor = cursor.with(turn: nil) unless wait.status == :waiting_for_answer
154
158
  cursor.with_baseline(ReplyWait.baseline_of(wait.session))
155
159
  end
156
160
  end
@@ -187,20 +191,28 @@ module Samagotchi
187
191
 
188
192
  # Point the cursor at the newest reply now, so only a later one counts
189
193
  # (a follow-up sent to a child that already answered), and take the
190
- # child's baseline before the follow-up goes in.
194
+ # child's baseline before the follow-up goes in. No turn yet
195
+ # (#mark_turn, once it went in).
191
196
  # @param child [Session] the child as loaded before sending
192
197
  def mark_seen(parent_id, child, state_dir:)
193
198
  newest = ReplyWait.newest_reply(child.id, state_dir: state_dir)
194
199
  DelegateCursors.update(parent_id, child.id, state_dir: state_dir) do |cursor|
195
- cursor.with(reply_file: newest).with_baseline(ReplyWait.baseline_of(child))
200
+ cursor.with(reply_file: newest, turn: nil).with_baseline(ReplyWait.baseline_of(child))
196
201
  end
197
202
  end
198
203
 
204
+ # The follow-up went in: the child's turn it joins, whose reply the
205
+ # wait takes (DelegateCursor#turn).
206
+ # @param turn [String, nil]
207
+ def mark_turn(parent_id, child_id, turn, state_dir:)
208
+ DelegateCursors.update(parent_id, child_id, state_dir: state_dir) { |cursor| cursor.with(turn: turn) }
209
+ end
210
+
199
211
  # A new child's baseline: as spawned, before its first turn ran.
200
212
  # @param child [Session] the session spawn_session returned
201
213
  def mark_started(parent_id, child, state_dir:)
202
214
  DelegateCursors.update(parent_id, child.id, state_dir: state_dir) do |cursor|
203
- cursor.with_baseline(ReplyWait.baseline_of(child, question_id: nil))
215
+ cursor.with(turn: nil).with_baseline(ReplyWait.baseline_of(child, question_id: nil))
204
216
  end
205
217
  end
206
218
 
@@ -32,6 +32,10 @@ module Samagotchi
32
32
  # stopped the turn: its cancel reason is :signal.
33
33
  NOT_RUN_ON_SIGNAL = "Error: not run, a signal to chi stopped the turn"
34
34
  STOPPED_BY_SIGNAL = "Error: command stopped by a signal to chi"
35
+ # The same two when a session stop (chi sessions stop, the web's stop,
36
+ # a parent stopping its delegate) canceled the turn: :session_stopped.
37
+ NOT_RUN_ON_SESSION_STOP = "Error: not run, the session was stopped"
38
+ STOPPED_BY_SESSION_STOP = "Error: command stopped as the session was stopped"
35
39
  # The first words of a command killed at the limit in kill mode (hooks
36
40
  # match on it).
37
41
  TIMEOUT_PREFIX = "Error: command timed out after"
@@ -79,7 +83,8 @@ module Samagotchi
79
83
  # @param cancelled [#call] true once the user stopped the turn: the
80
84
  # command is killed (or not started) and its output so far returned
81
85
  # @param stop_reason [#call] the stop's reason (:signal: a signal to
82
- # chi, which the result then names instead of the user)
86
+ # chi, :session_stopped: a session stop, which the result then names
87
+ # instead of the user)
83
88
  # @param env [Hash, nil] added to the command's environment
84
89
  # @param on_timeout [Symbol, String, nil] :task or :kill (ON_TIMEOUT);
85
90
  # nil reads execute.on_timeout
@@ -116,16 +121,18 @@ module Samagotchi
116
121
  # a long command, and return whatever it printed before it was killed.
117
122
  stopped_result("#{TIMEOUT_PREFIX} #{timeout_sec}s\n#{TIMEOUT_HINT}", e)
118
123
  rescue CommandCancelled => e
119
- first = signal?(stop_reason) ? STOPPED_BY_SIGNAL : STOPPED_BY_USER
124
+ first = STOPPED_BY.fetch(stop_reason.call, STOPPED_BY_USER)
120
125
  stopped_result("#{first} after #{e.elapsed.round}s (killed; rerun it if still needed)", e)
121
126
  rescue StandardError => e
122
127
  "Error: #{e.message}"
123
128
  end
124
129
 
125
130
  # What a command the turn's stop kept from starting returns.
126
- def self.not_run(stop_reason) = signal?(stop_reason) ? NOT_RUN_ON_SIGNAL : NOT_RUN_ON_STOP
131
+ def self.not_run(stop_reason) = NOT_RUN.fetch(stop_reason.call, NOT_RUN_ON_STOP)
127
132
 
128
- def self.signal?(stop_reason) = stop_reason.call == :signal
133
+ # The texts above by the stop's reason; any other is the user's Stop.
134
+ NOT_RUN = { signal: NOT_RUN_ON_SIGNAL, session_stopped: NOT_RUN_ON_SESSION_STOP }.freeze
135
+ STOPPED_BY = { signal: STOPPED_BY_SIGNAL, session_stopped: STOPPED_BY_SESSION_STOP }.freeze
129
136
 
130
137
  # execute.on_timeout, or the caller's: :task (the default) or :kill.
131
138
  def self.on_timeout_mode(value)
@@ -67,6 +67,7 @@ module Samagotchi
67
67
  @boundaries = 0
68
68
  @written_at = nil
69
69
  @started_at = nil
70
+ @enqueued_id = nil
70
71
  end
71
72
 
72
73
  # A turn begins (before Engine#run_turn and the worker's running save):
@@ -74,14 +75,18 @@ module Samagotchi
74
75
  # images, no turn id), marked at iteration 0, so a worker killed before
75
76
  # the loop's first boundary leaves the prompt and a mark the next one
76
77
  # settles. A continue turn (no prompt) is the history, marked.
78
+ # Every mark of the turn carries +enqueued_id+ (InterruptedTurn's).
77
79
  # @param prompt [String, nil]
78
- def begin_turn(prompt: nil)
80
+ # @param enqueued_id [String, nil] the input the turn runs
81
+ def begin_turn(prompt: nil, enqueued_id: nil)
79
82
  @boundaries = 0
83
+ @enqueued_id = enqueued_id.to_s.empty? ? nil : enqueued_id.to_s
80
84
  @written_at = nil
81
85
  @started_at = Time.now.iso8601(3)
82
86
  messages = Array(@session.messages).dup
83
87
  messages << { role: "user", content: prompt } unless prompt.to_s.empty?
84
- @session.turn_checkpoint = { messages: messages, marker: InterruptedTurn.new(started_at: @started_at, at: @started_at, iteration: 0) }
88
+ @session.turn_checkpoint = { messages: messages, marker: InterruptedTurn.new(started_at: @started_at, at: @started_at, iteration: 0,
89
+ enqueued_id: @enqueued_id) }
85
90
  end
86
91
 
87
92
  # The turn ended (or never began): the session saves its own messages.
@@ -104,7 +109,8 @@ module Samagotchi
104
109
  # One level deep: the loop may edit an entry in place later (a
105
110
  # forget stub) while a save on another thread serializes this one.
106
111
  messages = conversation.map { |message| message.is_a?(Hash) ? message.dup : message } + merged
107
- marker = InterruptedTurn.new(id: turn_id, started_at: @started_at, at: Time.now.iso8601(3), iteration: @boundaries)
112
+ marker = InterruptedTurn.new(id: turn_id, started_at: @started_at, at: Time.now.iso8601(3), iteration: @boundaries,
113
+ enqueued_id: @enqueued_id)
108
114
  @session.turn_checkpoint = { messages: messages, marker: marker }
109
115
  return false unless due? || !merged.empty?
110
116
 
@@ -136,13 +136,27 @@ module Samagotchi
136
136
  # text cut to its last STEP_CHARS. Never
137
137
  # sent: the payload builders send a system message's content, and the
138
138
  # copies for hooks, plugins and the recap drop it (AnswerDisplay.strip).
139
- def empty(retries: 0, steps: [])
139
+ # +refusal+: the provider refused the answer (ModelResult#refusal:
140
+ # {category:} or {}), kept on the marker and said in the note.
141
+ def empty(retries: 0, steps: [], refusal: nil)
140
142
  marker = { retries: retries.to_i }
141
143
  marker[:steps] = steps.map { |step| capped_step(step) } unless steps.empty?
142
- message("the previous turn ended with no visible answer (thinking only, or nothing). The user's last message is still unanswered.")
144
+ marker[:refusal] = refusal if refusal
145
+ what = if refusal
146
+ "the previous turn's answer was refused by the provider#{refusal_category(refusal)}"
147
+ else
148
+ "the previous turn ended with no visible answer (thinking only, or nothing)"
149
+ end
150
+ message("#{what}. The user's last message is still unanswered.")
143
151
  .merge(EMPTY_ANSWER => marker)
144
152
  end
145
153
 
154
+ # " (cyber)" for a refusal that names its category (either key type), else "".
155
+ def refusal_category(refusal)
156
+ category = (refusal[:category] || refusal["category"] if refusal.is_a?(Hash)).to_s.strip
157
+ category.empty? ? "" : " (#{one_line(category)})"
158
+ end
159
+
146
160
  def capped_step(step)
147
161
  step.slice(:role, :content, :thinking).to_h do |key, value|
148
162
  [key, key == :role || !value.is_a?(String) ? value : tail_of(value)]
@@ -167,9 +181,12 @@ module Samagotchi
167
181
  end
168
182
 
169
183
  # The notice every UI shows for a turn that ended with no answer:
170
- # "no answer: the model returned nothing (after 1 retry)" (turn_events.js
171
- # emptyAnswerLine words it the same).
172
- def empty_answer_line(retries)
184
+ # "no answer: the model returned nothing (after 1 retry)", or for a
185
+ # +refusal+ (the marker's) "no answer: refused by the provider (cyber)"
186
+ # (turn_events.js emptyAnswerLine words it the same).
187
+ def empty_answer_line(retries, refusal: nil)
188
+ return "no answer: refused by the provider#{refusal_category(refusal)}" if refusal
189
+
173
190
  count = retries.to_i
174
191
  after = if count.positive?
175
192
  " (after #{count} #{count == 1 ? "retry" : "retries"})"
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.63.0"
4
+ VERSION = "0.65.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
@@ -1944,7 +1944,8 @@ module Samagotchi
1944
1944
  did = step.is_a?(Hash) ? MessageParts.for_message(step, [], cwd: cwd) : nil
1945
1945
  { role: "assistant", content: "", parts: did } if did
1946
1946
  end
1947
- items << { role: "empty_answer", content: "", retries: (marker[:retries] || marker["retries"]).to_i }
1947
+ refusal = marker[:refusal] || marker["refusal"]
1948
+ items << { role: "empty_answer", content: "", retries: (marker[:retries] || marker["retries"]).to_i, refusal: refusal }.compact
1948
1949
  end
1949
1950
 
1950
1951
  # The last turn failed and its prompt went back to the user: the
@@ -103,8 +103,11 @@ export function addStarted(model, event = {}, now = null) {
103
103
  return { row, created: true };
104
104
  }
105
105
 
106
+ // A replayed running call a snapshot timed (its elapsed_ms so far) started
107
+ // that long before +now+; any other replayed start is no clock.
106
108
  function markStart(row, event, now) {
107
109
  if (now !== null && !event.replayed) row.startedAt = now;
110
+ else if (now !== null && event.elapsed_ms != null) row.startedAt = now - Number(event.elapsed_ms);
108
111
  else delete row.startedAt;
109
112
  }
110
113
 
@@ -76,6 +76,10 @@ import { createComposerCommands, routeComposerLine, routeSessionLine } from "./c
76
76
  import { composerAfterAck, dropEarlyRestores, restoreOnAck } from "./restore.js";
77
77
  import { createStreamHandlers } from "./stream_handlers.js";
78
78
  import { createListsView } from "./lists_view.js";
79
+ import { createDeliveryControl, QUEUE_NEEDS_WORKER } from "./delivery_line.js";
80
+ import { liveSlots } from "./stage_model.js";
81
+ import { currentGen } from "./turn_model.js";
82
+ import { toolName } from "./activity.js";
79
83
 
80
84
  const $ = (s) => document.querySelector(s);
81
85
  const topStripEl = $("#topStrip");
@@ -251,6 +255,28 @@ const liveTurn = createLiveTurn({
251
255
  onTick: () => updateInfoBar(),
252
256
  });
253
257
 
258
+ // The composer's delivery line while the open session's turn runs
259
+ // (delivery_line.js): where a message typed now goes in, and the cut and
260
+ // queue links.
261
+ const deliveryControl = createDeliveryControl({
262
+ el: $("#deliveryLine"),
263
+ read: () => {
264
+ const turn = turnDom.current?.().turn || null;
265
+ const running = turn ? turn.gens.flatMap((g) => g.tools).reverse().find((r) => r.status === "running") : null;
266
+ return {
267
+ phase: turnDom.phase?.() || liveSlots(turn).phase,
268
+ gen: turn ? currentGen(turn) : null,
269
+ tool: running ? { name: toolName(running), startedAt: running.startedAt ?? null } : null,
270
+ cutAfter: liveTurn.cutAfter,
271
+ serverGen: liveTurn.serverGen,
272
+ canQueue: current.workerFeatures.includes("delivery"),
273
+ model: current.model,
274
+ };
275
+ },
276
+ composerEmpty: () => !promptEl.value.trim() && !chips.length,
277
+ send: (delivery) => handleSendTurn({ delivery }),
278
+ });
279
+
254
280
  // This tab's id in the session's events: tells its own prompts from the
255
281
  // ones other clients (an attached TUI, another tab, reminders) sent.
256
282
  const clientId = newClientId();
@@ -552,8 +578,9 @@ function updateComposerMode() {
552
578
  }
553
579
  const running = liveTurn.running && !!selected;
554
580
  cancelBtn.style.display = running ? "" : "none";
581
+ syncDeliveryLine();
555
582
  if (running) {
556
- setPlaceholder("Running… a message now steers this turn · Enter to send · Shift+Enter newline", "Steer the turn…");
583
+ setPlaceholder("Running… a message goes in at the next step · Enter to send · Alt+Enter queues", "Steer the turn…");
557
584
  composeHintEl.textContent = SEND_HINT;
558
585
  actionBtn.textContent = "Send";
559
586
  emptyEl.classList.add("hidden");
@@ -574,7 +601,8 @@ function updateComposerMode() {
574
601
  actionBtn.disabled = true;
575
602
  emptyEl.classList.add("hidden");
576
603
  } else {
577
- setPlaceholder("Message chi… · Enter to send · Shift+Enter newline · ↑ history", "Message chi…");
604
+ // The keys are in the delivery line's row above.
605
+ setPlaceholder("Message chi…", "Message chi…");
578
606
  composeHintEl.textContent = SEND_HINT;
579
607
  actionBtn.textContent = "Send";
580
608
  actionBtn.disabled = false;
@@ -582,6 +610,14 @@ function updateComposerMode() {
582
610
  }
583
611
  }
584
612
 
613
+ // The delivery line's row in a session (delivery_line.js): the key hint
614
+ // while idle, where a message goes in while the turn runs.
615
+ function syncDeliveryLine() {
616
+ // A terminal owns the session: the composer is off, so is its row.
617
+ if (!selected || current.owner === "tui") deliveryControl.show("off");
618
+ else deliveryControl.show(liveTurn.running && current.owner !== "tui" ? "running" : "idle");
619
+ }
620
+
585
621
  function setTurnRunning(running) {
586
622
  liveTurn.running = !!running;
587
623
  updateComposerMode();
@@ -790,6 +826,9 @@ function renderSessionView(data, { kept = [] } = {}) {
790
826
  });
791
827
  // Marked replayed: a call's duration isn't timed from the replay's clock.
792
828
  replay.forEach((event) => streamHandlers[event.type]?.({ ...event, replayed: true }));
829
+ // A join mid-generation: the worker's word on it clocks the delivery line.
830
+ const gen = data.current_turn?.generation;
831
+ liveTurn.serverGen = gen ? { ...gen, at: Date.now() } : null;
793
832
  if (!data.current_turn) {
794
833
  setTurnRunning(current.status === "running");
795
834
  if (liveTurn.running && current.timing.activeTurn?.started_at) liveTurn.timing.start(current.timing.activeTurn.started_at, activeTurnId);
@@ -997,11 +1036,13 @@ function endTurnView(kind, endText = null, { emptyAnswer = null, stopped = false
997
1036
  // prompts are echoed at send (data-own) and tagged by their turn_enqueued.
998
1037
 
999
1038
  // The bubble a reload draws (turn_html.js), with this page's own fields.
1000
- function makeUserBubble(prompt, { state = null, label = null, enqueuedId = null, own = false, images = null } = {}) {
1039
+ // +delivery+: how it went into a running turn (next_step, cut, queue): its
1040
+ // queued badge says what it waits for.
1041
+ function makeUserBubble(prompt, { state = null, label = null, enqueuedId = null, own = false, images = null, delivery = null } = {}) {
1001
1042
  const hint = historyEl.querySelector(".hint");
1002
1043
  if (hint) hint.remove();
1003
1044
  const div = elementOf(userBubbleTemplate({ content: prompt }, {
1004
- label, state, own, enqueuedId, key: normalize(prompt), thumbs: thumbsTemplate(selected, images),
1045
+ label, state, own, enqueuedId, delivery, key: normalize(prompt), thumbs: thumbsTemplate(selected, images),
1005
1046
  }));
1006
1047
  const follow = isNearBottom();
1007
1048
  appendToHistory(div);
@@ -1018,7 +1059,17 @@ function setBubbleState(bubble, state) {
1018
1059
  }
1019
1060
  bubble.classList.add(state);
1020
1061
  bubble.dataset.userState = state;
1021
- bubble.append(elementOf(stateBadgeTemplate(state)));
1062
+ bubble.append(elementOf(stateBadgeTemplate(state, bubble.dataset.delivery || null)));
1063
+ }
1064
+
1065
+ // A bubble's delivery changed (the ack's cut: off goes in at the next
1066
+ // step; a queue to a turn that ended meanwhile is just the next turn): its
1067
+ // badge follows.
1068
+ function setBubbleDelivery(bubble, delivery) {
1069
+ if ((bubble.dataset.delivery || null) === (delivery || null)) return;
1070
+ if (delivery) bubble.dataset.delivery = delivery;
1071
+ else delete bubble.dataset.delivery;
1072
+ if (bubble.dataset.userState) setBubbleState(bubble, bubble.dataset.userState);
1022
1073
  }
1023
1074
 
1024
1075
  function bubbleById(enqueuedId) {
@@ -1047,15 +1098,18 @@ function applyPromptOps(event) {
1047
1098
  if (op.op === "report") {
1048
1099
  liveTurn.unmatchedMerge = "report";
1049
1100
  } else if (op.op === "add") {
1050
- const bubble = makeUserBubble(op.prompt, { state: op.state, label: op.label, enqueuedId: op.enqueuedId, images: op.images });
1101
+ const bubble = makeUserBubble(op.prompt, { state: op.state, label: op.label, enqueuedId: op.enqueuedId, images: op.images, delivery: op.delivery });
1051
1102
  if (op.state === "started") started = bubble;
1052
1103
  if (event.type === "pending_input_merged") liveTurn.wakeStart = false;
1053
1104
  } else if (op.op === "tag") {
1054
1105
  if (bubbleById(op.enqueuedId)) continue;
1055
1106
  const echo = untaggedOwnBubble(op.prompt);
1056
- if (echo) echo.dataset.enqueuedId = op.enqueuedId;
1107
+ if (echo) {
1108
+ echo.dataset.enqueuedId = op.enqueuedId;
1109
+ if (op.delivery) setBubbleDelivery(echo, op.delivery);
1110
+ }
1057
1111
  // Re-rendered from a snapshot: the echo is gone, the prompt still queued.
1058
- else makeUserBubble(op.prompt, { state: "queued", own: true, enqueuedId: op.enqueuedId, images: op.images });
1112
+ else makeUserBubble(op.prompt, { state: "queued", own: true, enqueuedId: op.enqueuedId, images: op.images, delivery: op.delivery });
1059
1113
  } else if (op.op === "start") {
1060
1114
  const bubble = bubbleById(op.enqueuedId) || untaggedOwnBubble(op.prompt);
1061
1115
  // A resync (a new worker woken by this page's send) re-rendered the
@@ -1683,10 +1737,14 @@ function handleCreate() {
1683
1737
  return handleSendTurn();
1684
1738
  }
1685
1739
 
1686
- async function handleSendTurn() {
1740
+ // +delivery+: the composer's choice for a running turn (Alt+Enter: queue;
1741
+ // a delivery-line link: cut or queue); else the armed one; a typed /cut or
1742
+ // /queue wins over both.
1743
+ async function handleSendTurn({ delivery: chosen = null } = {}) {
1687
1744
  if (current.owner === "tui") return;
1688
1745
  let prompt = turnText(promptEl.value, chips);
1689
1746
  if (!prompt) return;
1747
+ const armed = deliveryControl.take();
1690
1748
  // An image-only send's text is its placeholders: not for ↑.
1691
1749
  const typed = promptEl.value.trim() !== "";
1692
1750
  // The composer holds the line as typed until the ack (composerAfterAck).
@@ -1727,20 +1785,25 @@ async function handleSendTurn() {
1727
1785
  }
1728
1786
  if (route.command) return composerCommands.send(route.command);
1729
1787
  prompt = route.send;
1730
- const delivery = route.delivery;
1788
+ const running = liveTurn.running && !!selected;
1789
+ const delivery = route.delivery || (running ? chosen || armed : null);
1731
1790
  const hint = historyEl.querySelector(".hint");
1732
1791
  if (hint) hint.remove();
1733
1792
  // Sending moves a finished turn out of the stage at once (else the echo
1734
1793
  // would land among its rows); into a running one it is a queued row there.
1735
1794
  turnDom.handOff?.({ immediate: true });
1736
1795
  const sending = chips.slice();
1737
- const echo = makeUserBubble(prompt, { state: "queued", own: true, images: sending });
1796
+ // Into a running turn the echo says what it waits for (its delivery); a
1797
+ // line with images always waits for the turn's end (the worker queues it).
1798
+ const shownDelivery = running ? (sending.length ? "queue" : delivery || "next_step") : null;
1799
+ const echo = makeUserBubble(prompt, { state: "queued", own: true, images: sending, delivery: shownDelivery });
1738
1800
  if (!turnDom.holds?.()) historyEl.scrollTop = historyEl.scrollHeight;
1739
1801
  if (!current.firstPreview || current.firstPreview==="—") {
1740
1802
  current.firstPreview = firstPreview(prompt);
1741
1803
  updateInfoBar();
1742
1804
  }
1743
1805
  actionBtn.disabled = true;
1806
+ const sentTo = selected;
1744
1807
  try {
1745
1808
  for (const chip of sending) {
1746
1809
  if (!chip.ref) chip.ref = await uploadImage(selected, chip.file, chip.name);
@@ -1755,6 +1818,11 @@ async function handleSendTurn() {
1755
1818
  return;
1756
1819
  }
1757
1820
  if (ack?.enqueued_id) sentIds.add(ack.enqueued_id);
1821
+ // A cut the worker didn't take (no thinking-only step: cut off) goes in
1822
+ // at the next step; a queue whose turn ended meanwhile is the next turn.
1823
+ if (ack?.cut === "off" || (ack?.delivery === "next_step" && echo.dataset.delivery === "queue")) {
1824
+ setBubbleDelivery(echo, ack.cut === "off" ? "next_step" : null);
1825
+ }
1758
1826
  refreshHistory();
1759
1827
  // A turn that failed fast was restored before this ack: its prompt
1760
1828
  // (and images) go back into the composer, without replacing text typed
@@ -1787,6 +1855,11 @@ async function handleSendTurn() {
1787
1855
  updateComposerMode();
1788
1856
  return;
1789
1857
  }
1858
+ // A stale tab sent into a session deleted meanwhile: the start page
1859
+ // says so, and the line stays in the composer.
1860
+ if (e.code === "not_found") return leaveGoneSession(sentTo, "not_found");
1861
+ // A /queue to a worker that can't queue (it would steer): its words, the text kept.
1862
+ if (e.code === "delivery_unavailable") return addCommandBubble({ label: null, line, text: e.body?.detail || e.message, failed: true });
1790
1863
  alert(e.message);
1791
1864
  } finally {
1792
1865
  actionBtn.disabled = current.owner === "tui";
@@ -2112,10 +2185,20 @@ function historyKey(e) {
2112
2185
  promptEl.addEventListener("keydown", (e) => {
2113
2186
  if (commandList.handleKey(e)) return;
2114
2187
  if (historyKey(e)) return;
2115
- if (e.key === "Enter" && !e.shiftKey) {
2188
+ // Esc lets go of a choice armed on the delivery line.
2189
+ if (e.key === "Escape" && deliveryControl.disarm()) {
2190
+ e.preventDefault();
2191
+ return;
2192
+ }
2193
+ // An IME's Enter picks the composition, it doesn't send.
2194
+ if (e.key === "Enter" && !e.shiftKey && !e.isComposing) {
2116
2195
  e.preventDefault();
2117
2196
  if (!selected) handleCreate();
2118
- else handleSendTurn();
2197
+ // Alt/Option+Enter: queued for after the running turn (D6), only to a
2198
+ // worker that queues (else it would steer): the text stays, a note says why.
2199
+ else if (e.altKey && liveTurn.running && !current.workerFeatures.includes("delivery")) {
2200
+ addCommandBubble({ label: null, line: promptEl.value.trim(), text: QUEUE_NEEDS_WORKER, failed: true });
2201
+ } else handleSendTurn(e.altKey ? { delivery: "queue" } : {});
2119
2202
  }
2120
2203
  });
2121
2204
  $("#infoStopBtn").addEventListener("click", async () => {
@@ -23,16 +23,17 @@ export function sessionCommandLine(text, commands) {
23
23
  }
24
24
 
25
25
  // /cut TEXT and /queue TEXT: the UIs' own words for a delivery (listed
26
- // for Tab and /help, never run as commands; TerminalUI::Formatting's
27
- // STEER_CHOICE: the word, then any whitespace and the text). During a
26
+ // for Tab and /help, never run as commands; ComposerRoute::STEER_CHOICE
27
+ // (spec/fixtures/composer_routes.json): the word, then any whitespace and the text). During a
28
28
  // turn /cut TEXT is the text sent as a cut (POST /turn, delivery "cut"),
29
- // as chi send --cut does; /queue isn't in the web yet (the page says so,
30
- // as the attached TUI does). With no running turn both are the text as a
31
- // normal turn. The bare word gets the usage line.
29
+ // as chi send --cut does, and /queue TEXT the text queued to run as a turn
30
+ // of its own after this one (delivery "queue"; a worker that can't queue
31
+ // refuses it, and the page shows the refusal's detail). With no running
32
+ // turn both are the text as a normal turn. The bare word gets the usage
33
+ // line.
32
34
  // @return {{send: string, delivery?: string} | {reply: string} | null}
33
35
  // null: not a delivery line
34
36
  export const STEER_CHOICE_USAGE = "(/cut and /queue need a message: /cut TEXT, /queue TEXT)";
35
- export const QUEUE_UNAVAILABLE_WEB = "(/queue isn't available in the web yet; a plain message goes in at the running turn's next step, and /cut TEXT cuts in)";
36
37
  const STEER_CHOICE = /^\/(cut|queue)(?:\s+([\s\S]*))?$/;
37
38
 
38
39
  export function steerChoiceRoute(text, { running = false } = {}) {
@@ -41,8 +42,7 @@ export function steerChoiceRoute(text, { running = false } = {}) {
41
42
  const message = (match[2] || "").trim();
42
43
  if (!message) return { reply: STEER_CHOICE_USAGE };
43
44
  if (!running) return { send: message };
44
- if (match[1] === "queue") return { reply: QUEUE_UNAVAILABLE_WEB };
45
- return { send: message, delivery: "cut" };
45
+ return { send: message, delivery: match[1] };
46
46
  }
47
47
 
48
48
  // A line that is one word: a slash and a name, no spaces, no second slash
@@ -7,8 +7,8 @@ import { sessionCommandLine, startPageReplyOrHint, steerChoiceRoute, unknownComm
7
7
 
8
8
  // Where a composer line goes, in the order the page reads it:
9
9
  // 1. /cut TEXT, /queue TEXT (steerChoiceRoute): during a turn in a session
10
- // the text as a cut, or the page's own answer; else the text goes on
11
- // as the line.
10
+ // the text as a cut or queued for after the turn; else the text goes
11
+ // on as the line.
12
12
  // 2. On the start page (+session+ false): the page's own reply or a typo
13
13
  // hint (startPageReplyOrHint) needs no session; anything else makes one.
14
14
  // 3. In a session (routeSessionLine): a session command, the page's own
@@ -21,7 +21,7 @@ import { sessionCommandLine, startPageReplyOrHint, steerChoiceRoute, unknownComm
21
21
  // +send+ again in it (routeSessionLine, with its
22
22
  // commands)
23
23
  // {command} POST /command
24
- // {send, delivery} POST /turn (+delivery+ "cut" or null)
24
+ // {send, delivery} POST /turn (+delivery+ "cut", "queue" or null)
25
25
  export function routeComposerLine(text, { session = true, running = false, commands = [], images = 0 } = {}) {
26
26
  const choice = steerChoiceRoute(text, { running: running && session });
27
27
  if (choice?.reply) return { reply: choice.reply, line: text };