samagotchi 0.64.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 (57) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +41 -1
  3. data/docs/architecture.md +20 -3
  4. data/docs/cli.md +25 -1
  5. data/docs/configuration.md +128 -3
  6. data/docs/internals/engine.md +15 -3
  7. data/docs/sessions.md +3 -2
  8. data/lib/samagotchi/bridge.rb +11 -1
  9. data/lib/samagotchi/bundles/system/config_modification_protocol.md +2 -2
  10. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  11. data/lib/samagotchi/client.rb +4 -2
  12. data/lib/samagotchi/config.rb +5 -4
  13. data/lib/samagotchi/engine.rb +39 -7
  14. data/lib/samagotchi/generation_phase.rb +17 -0
  15. data/lib/samagotchi/host_config.rb +27 -6
  16. data/lib/samagotchi/host_registry.rb +34 -11
  17. data/lib/samagotchi/idle_client.rb +62 -25
  18. data/lib/samagotchi/idle_recap.rb +5 -2
  19. data/lib/samagotchi/idle_target.rb +30 -7
  20. data/lib/samagotchi/llm/adapters.rb +41 -0
  21. data/lib/samagotchi/llm/anthropic_assembly.rb +210 -0
  22. data/lib/samagotchi/llm/anthropic_messages.rb +416 -0
  23. data/lib/samagotchi/llm/anthropic_wire.rb +238 -0
  24. data/lib/samagotchi/llm/api_key.rb +26 -9
  25. data/lib/samagotchi/llm/chat_loop.rb +113 -14
  26. data/lib/samagotchi/llm/dialect.rb +57 -0
  27. data/lib/samagotchi/llm/errors.rb +81 -12
  28. data/lib/samagotchi/llm/model_listing.rb +111 -0
  29. data/lib/samagotchi/llm/model_result.rb +9 -2
  30. data/lib/samagotchi/llm/openai_chat.rb +46 -93
  31. data/lib/samagotchi/model_profile.rb +1 -1
  32. data/lib/samagotchi/reasoning_history.rb +14 -4
  33. data/lib/samagotchi/recap_setup.rb +2 -1
  34. data/lib/samagotchi/self_report.rb +18 -28
  35. data/lib/samagotchi/session.rb +3 -2
  36. data/lib/samagotchi/session_metrics.rb +6 -11
  37. data/lib/samagotchi/steer_cut.rb +4 -0
  38. data/lib/samagotchi/terminal_ui/attached_loop.rb +8 -2
  39. data/lib/samagotchi/terminal_ui/event_renderer.rb +2 -1
  40. data/lib/samagotchi/terminal_ui/formatting.rb +4 -3
  41. data/lib/samagotchi/terminal_ui/status_row.rb +24 -3
  42. data/lib/samagotchi/turn_note.rb +22 -5
  43. data/lib/samagotchi/version.rb +1 -1
  44. data/lib/samagotchi/web/app.rb +2 -1
  45. data/lib/samagotchi/web/public/activity.js +3 -0
  46. data/lib/samagotchi/web/public/app.js +90 -13
  47. data/lib/samagotchi/web/public/delivery_line.js +228 -0
  48. data/lib/samagotchi/web/public/index.html +29 -0
  49. data/lib/samagotchi/web/public/live_turn.js +8 -0
  50. data/lib/samagotchi/web/public/stage_model.js +27 -7
  51. data/lib/samagotchi/web/public/stage_view.js +32 -10
  52. data/lib/samagotchi/web/public/stream_handlers.js +8 -2
  53. data/lib/samagotchi/web/public/turn_events.js +22 -8
  54. data/lib/samagotchi/web/public/turn_html.js +20 -4
  55. data/lib/samagotchi/web/public/turn_model.js +16 -9
  56. data/lib/samagotchi/web/public/turn_view.js +6 -4
  57. metadata +8 -1
@@ -1,8 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "json"
4
- require "net/http"
5
- require "uri"
6
3
  require_relative "version"
7
4
  require_relative "config"
8
5
  require_relative "context_window"
@@ -16,8 +13,7 @@ require_relative "thinking"
16
13
  require_relative "reasoning_history"
17
14
  require_relative "served_model"
18
15
  require_relative "host_registry"
19
- require_relative "llm/http"
20
- require_relative "llm/api_key"
16
+ require_relative "llm/adapters"
21
17
  require_relative "tools/memory"
22
18
  require_relative "hooks/loader"
23
19
  require_relative "memory_bundle/provenance"
@@ -273,7 +269,9 @@ module Samagotchi
273
269
  entry = HostRegistry.new(env: env).resolve(model).entry
274
270
  return "-" unless entry
275
271
 
276
- entry.chat? ? "chat (api: openai)" : "native (raw prompt)"
272
+ return "native (raw prompt)" unless entry.chat?
273
+
274
+ entry.dialect ? "chat (api: #{entry.api}, dialect: #{entry.dialect})" : "chat (api: #{entry.api})"
277
275
  end
278
276
 
279
277
  NO_PROPS = "reported per turn (the server has no /props)"
@@ -281,7 +279,7 @@ module Samagotchi
281
279
  # chi self's one server call: a single-model llama.cpp answers any name
282
280
  # with the model it loaded, and its /props names it (model_alias). One
283
281
  # GET with the probe's short timeouts; a remote host isn't asked. A local
284
- # chat host (api: openai) has no /props, so it gets a GET <base>/models
282
+ # chat host (api: openai or anthropic) has no /props, so it gets its model list
285
283
  # with the same timeouts: up/down and the ids it serves.
286
284
  def served_model_for(model, env)
287
285
  registry = HostRegistry.new(env: env)
@@ -306,36 +304,28 @@ module Samagotchi
306
304
  "unknown (#{e.class})"
307
305
  end
308
306
 
309
- # A local chat host's reachability: one GET <base>/models with the
310
- # native probe's short timeouts (Client's /props probe), no retry. Up
311
- # with the ids it serves, down when it doesn't answer.
307
+ # A local chat host's reachability: its model list through its own
308
+ # adapter (LLM::Adapters, not the registry's cached one) with the native
309
+ # probe's short timeouts (Client's /props probe), no retry. Up with the
310
+ # ids it serves, down when it doesn't answer.
312
311
  def chat_served_model_for(entry, bare, env)
313
- uri = URI("#{entry.openai_base_url}/models")
314
- request = Net::HTTP::Get.new(uri)
315
- http = LLM::HTTP.new(label: entry.name, open_timeout: Client::CONTEXT_WINDOW_PROBE_OPEN_TIMEOUT,
316
- read_timeout: Client::CONTEXT_WINDOW_PROBE_READ_TIMEOUT,
317
- retry_policy: LLM::HTTP::RetryPolicy.none,
318
- api_key: LLM::ApiKey.for(entry.api_key_env, host: entry.host, env: env))
319
- response = http.fetch(uri, request, retries: false, check_status: false, log_fields: { purpose: "models" })
320
- return "down (HTTP #{response.code})" unless response.code.to_s == "200"
321
-
322
- ids = chat_model_ids(response.body)
312
+ adapter = LLM::Adapters.for(entry, open_timeout: Client::CONTEXT_WINDOW_PROBE_OPEN_TIMEOUT,
313
+ timeout: Client::CONTEXT_WINDOW_PROBE_READ_TIMEOUT, retries: false, env: env)
314
+ ids = adapter.list_models.map(&:id).reject(&:empty?)
323
315
  return "up (no models listed)" if ids.empty?
324
316
 
325
317
  note = ids.any? { |id| id.casecmp?(bare.to_s) } ? "" : " (not #{bare})"
326
318
  "up: #{ids.join(", ")}#{note}"
319
+ rescue LLM::ProviderError => e
320
+ # A status (an error, or a 3xx such as a proxy's login page) is down;
321
+ # a 200 whose body isn't a model list (ProtocolError) is up.
322
+ return "down (HTTP #{e.status})" if e.status
323
+
324
+ e.is_a?(LLM::ProtocolError) ? "up (no models listed)" : "down (the server didn't answer; is it running?)"
327
325
  rescue StandardError
328
326
  "down (the server didn't answer; is it running?)"
329
327
  end
330
328
 
331
- def chat_model_ids(body)
332
- parsed = JSON.parse(body.to_s)
333
- data = parsed.is_a?(Hash) ? parsed["data"] : parsed
334
- Array(data).filter_map { |raw| raw.is_a?(Hash) ? (raw["id"] || raw[:id]).to_s : raw.to_s }.reject(&:empty?)
335
- rescue JSON::ParserError
336
- []
337
- end
338
-
339
329
  # Offline, so no /props probe: where nothing is configured, a native
340
330
  # llama.cpp host's chat template decides at runtime.
341
331
  def profile_for(model, env)
@@ -722,14 +722,15 @@ module Samagotchi
722
722
  symbolize_empty_answer(message)
723
723
  end
724
724
 
725
- # An empty-answer note's marker (TurnNote.empty) and its steps, as
726
- # they were saved.
725
+ # An empty-answer note's marker (TurnNote.empty), its steps and its
726
+ # refusal, as they were saved.
727
727
  def symbolize_empty_answer(message)
728
728
  marker = message[:empty_answer]
729
729
  return message unless marker.is_a?(Hash)
730
730
 
731
731
  marker = marker.transform_keys(&:to_sym)
732
732
  marker[:steps] = marker[:steps].map { |step| step.is_a?(Hash) ? step.transform_keys(&:to_sym) : step } if marker[:steps].is_a?(Array)
733
+ marker[:refusal] = marker[:refusal].transform_keys(&:to_sym) if marker[:refusal].is_a?(Hash)
733
734
  message.merge(empty_answer: marker)
734
735
  end
735
736
 
@@ -883,7 +883,7 @@ module Samagotchi
883
883
  payload = event[:payload]
884
884
  payload = {} unless payload.is_a?(Hash)
885
885
 
886
- @mutex.synchronize { note_first_chunk(payload, event) }
886
+ @mutex.synchronize { note_first_chunk(event) }
887
887
  usage = TokenUsage.from_payload(payload)
888
888
  if usage
889
889
  @mutex.synchronize { note_server_usage(usage) if @turn }
@@ -915,21 +915,16 @@ module Samagotchi
915
915
  turn.gen_had_server = true
916
916
  end
917
917
 
918
- # The first chunk with content, reasoning or a tool call starts the
919
- # generation's decode clock (an estimated speed runs from it). Caller
920
- # holds the mutex.
921
- def note_first_chunk(payload, event)
918
+ # The first chunk with content, reasoning or a tool call (the loop marks
919
+ # its event tool_call: true, whatever the wire) starts the generation's
920
+ # decode clock (an estimated speed runs from it). Caller holds the mutex.
921
+ def note_first_chunk(event)
922
922
  return unless @turn && @turn.gen_open && @turn.gen_first_chunk_at.nil?
923
- return unless !event[:content].to_s.empty? || tool_call_delta?(payload)
923
+ return unless !event[:content].to_s.empty? || event[:tool_call]
924
924
 
925
925
  @turn.gen_first_chunk_at = monotonic_time
926
926
  end
927
927
 
928
- def tool_call_delta?(payload)
929
- choice = payload["choices"].is_a?(Array) ? payload["choices"].first : nil
930
- choice.is_a?(Hash) && choice["delta"].is_a?(Hash) && !Array(choice["delta"]["tool_calls"]).empty?
931
- end
932
-
933
928
  def event_content_chars(payload, event)
934
929
  len = payload_content(payload, event).length
935
930
  len.positive? ? len : nil
@@ -82,6 +82,10 @@ module Samagotchi
82
82
  @waiting.epoch
83
83
  end
84
84
 
85
+ # The running generation's phase (GenerationPhase::Snapshot), nil with
86
+ # none running.
87
+ def generation_snapshot = @phase.snapshot
88
+
85
89
  # The turn's stream: the generation's phase follows it, and a thinking
86
90
  # chunk rechecks a waiting message. +progress+: a chunk's lanes
87
91
  # (.lanes), read from it when not given.
@@ -260,13 +260,17 @@ module Samagotchi
260
260
  take_session_state(event[:session_state_snapshot] || {})
261
261
  return render_snapshot(event[:snapshot] || {}, reset: event[:type] == :reset)
262
262
  when :turn_enqueued then show_enqueued(event)
263
- when :turn_started then start_turn(event)
263
+ when :turn_started
264
+ @status.take_event(event)
265
+ start_turn(event)
264
266
  when :turn_completed
267
+ @status.take_event(event)
265
268
  complete_turn(event)
266
269
  refresh_children
267
270
  own_turn_ended(event)
268
271
  # The renderer says how it ended (the REPL's words too).
269
272
  when :turn_canceled, :turn_failed
273
+ @status.take_event(event)
270
274
  @renderer.call(event)
271
275
  end_turn
272
276
  refresh_children
@@ -1239,7 +1243,7 @@ module Samagotchi
1239
1243
  # A turn that ended with no answer: its notice, as live.
1240
1244
  empty = messages.drop(from + 1).filter_map { |m| TurnNote.empty_answer(m) }.last
1241
1245
  if empty
1242
- @screen.commit(format_empty_answer_line(empty[:retries] || empty["retries"]))
1246
+ @screen.commit(format_empty_answer_line(empty[:retries] || empty["retries"], empty[:refusal] || empty["refusal"]))
1243
1247
  elsif failed
1244
1248
  # A failed turn whose steps stayed: the line the live view left,
1245
1249
  # and no step text as if it were the answer (the web keeps a
@@ -1379,6 +1383,8 @@ module Samagotchi
1379
1383
  def render_current_turn(turn)
1380
1384
  @running = !turn.nil?
1381
1385
  @joined_mid_turn = @running
1386
+ # A turn running at the join: the status row's steer hint.
1387
+ @status.update(running: @running)
1382
1388
  return unless turn
1383
1389
 
1384
1390
  continues = turn[:prompt].nil?
@@ -179,7 +179,8 @@ module Samagotchi
179
179
  end
180
180
  # No answer: chi's muted notice, never an empty answer line.
181
181
  if summary[:empty_answer]
182
- @view.print_line(@view.format_empty_answer_line(summary.dig(:empty_answer, :retries)))
182
+ empty = summary[:empty_answer]
183
+ @view.print_line(@view.format_empty_answer_line(empty[:retries], empty[:refusal]))
183
184
  else
184
185
  @view.print_line(summary[:output])
185
186
  end
@@ -135,9 +135,10 @@ module Samagotchi
135
135
  end
136
136
 
137
137
  # "no answer: the model returned nothing (after 1 retry)", dim like the
138
- # retry row: a turn that ended with no answer (TurnNote.empty_answer_line).
139
- def format_empty_answer_line(retries)
140
- paint(TurnNote.empty_answer_line(retries), 90)
138
+ # retry row: a turn that ended with no answer, or "no answer: refused
139
+ # by the provider (cyber)" for a +refusal+ (TurnNote.empty_answer_line).
140
+ def format_empty_answer_line(retries, refusal = nil)
141
+ paint(TurnNote.empty_answer_line(retries, refusal: refusal), 90)
141
142
  end
142
143
 
143
144
  # "↻ retrying (503) in 4.0s, 2/5": the live status while the provider
@@ -28,7 +28,15 @@ module Samagotchi
28
28
  # preloaded its --memory list (shown until a turn
29
29
  # records them as used)
30
30
  # muted its --mute list
31
- FIELDS = %i[model default_model served parent_id children context used_memories preloaded muted].freeze
31
+ # running a turn runs: the row trails STEER_HINT
32
+ FIELDS = %i[model default_model served parent_id children context used_memories preloaded muted running].freeze
33
+
34
+ # The tail while a turn runs: where a typed line goes (the next step)
35
+ # and the two other choices. Dropped whole first when the row is cut.
36
+ STEER_HINT = " · typing goes in at the next step (/cut, /queue)"
37
+
38
+ # The turn events that end a turn (the hint goes).
39
+ TURN_END_EVENTS = %i[turn_completed turn_canceled turn_failed].freeze
32
40
 
33
41
  # @param surface [Surface] with #columns
34
42
  def initialize(surface)
@@ -77,13 +85,16 @@ module Samagotchi
77
85
 
78
86
  # Take what a turn event says about the row (the Engine's events in
79
87
  # the REPL, the Bridge's in attached mode): ctx, the memories used,
80
- # the model the server served. Other events change nothing.
88
+ # the model the server served, whether a turn runs (the steer
89
+ # hint). Other events change nothing.
81
90
  def take_event(event)
82
91
  case event[:type]
83
92
  when :context_status then update(context: { est_pct: event.dig(:usage, :estimated_pct), bucket: event[:bucket] })
84
93
  when :used_memories_updated then update(used_memories: Array(event[:used_memory_names]))
85
94
  when :generation_completed
86
95
  update(served: [event[:served_model], event[:requested_model], event[:served_expected_by]]) if event[:served_model]
96
+ when :turn_started then update(running: true)
97
+ when *TURN_END_EVENTS then update(running: false)
87
98
  end
88
99
  end
89
100
 
@@ -119,11 +130,21 @@ module Samagotchi
119
130
  status_context_text(estimate: @values[:context]),
120
131
  status_memory_text(Array(@values[:used_memories]) | Array(@values[:preloaded]), MEMORY_LIMIT),
121
132
  status_memory_text(@values[:muted], MEMORY_LIMIT, label: "muted")].reject(&:empty?)
122
- status_rows(segments, width)
133
+ status_rows(with_steer_hint(segments, width), width)
123
134
  end
124
135
 
125
136
  private
126
137
 
138
+ # +segments+ with STEER_HINT after the last while a turn runs, when
139
+ # the whole row fits +width+ with it: on a narrow terminal the hint
140
+ # goes first, whole, before anything else is cut.
141
+ def with_steer_hint(segments, width)
142
+ return segments unless @values[:running] && segments.any?
143
+ return segments if "status> #{segments.join(" | ")}#{STEER_HINT}".length > width
144
+
145
+ segments[0..-2] + ["#{segments.last}#{STEER_HINT}"]
146
+ end
147
+
127
148
  # `⑂ 2 running · 1 waiting`, the counts there are; "" with neither (no
128
149
  # children, or all ended). Not "not reported": a REPL parent is never
129
150
  # given its children's replies (no wakes there), and a worker moves
@@ -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.64.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,14 +1785,18 @@ 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);
@@ -1756,6 +1818,11 @@ async function handleSendTurn() {
1756
1818
  return;
1757
1819
  }
1758
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
+ }
1759
1826
  refreshHistory();
1760
1827
  // A turn that failed fast was restored before this ack: its prompt
1761
1828
  // (and images) go back into the composer, without replacing text typed
@@ -2118,10 +2185,20 @@ function historyKey(e) {
2118
2185
  promptEl.addEventListener("keydown", (e) => {
2119
2186
  if (commandList.handleKey(e)) return;
2120
2187
  if (historyKey(e)) return;
2121
- 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) {
2122
2195
  e.preventDefault();
2123
2196
  if (!selected) handleCreate();
2124
- 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" } : {});
2125
2202
  }
2126
2203
  });
2127
2204
  $("#infoStopBtn").addEventListener("click", async () => {