samagotchi 0.29.0 → 0.30.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 (40) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +54 -1
  3. data/docs/cli.md +4 -4
  4. data/docs/hooks.md +1 -1
  5. data/docs/plugins.md +1 -1
  6. data/docs/sessions.md +2 -2
  7. data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
  8. data/lib/samagotchi/bundles/mcp/plugin.rb +4 -3
  9. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +17 -4
  10. data/lib/samagotchi/bundles/source-links/manifest.yml +2 -2
  11. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  12. data/lib/samagotchi/command_steps.rb +35 -15
  13. data/lib/samagotchi/host_registry.rb +4 -0
  14. data/lib/samagotchi/kernel_loop.rb +19 -6
  15. data/lib/samagotchi/llm/chat_loop.rb +12 -6
  16. data/lib/samagotchi/model_list_store.rb +117 -0
  17. data/lib/samagotchi/model_profile.rb +43 -0
  18. data/lib/samagotchi/send_command.rb +5 -3
  19. data/lib/samagotchi/session.rb +5 -0
  20. data/lib/samagotchi/session_manager.rb +9 -2
  21. data/lib/samagotchi/session_metrics.rb +6 -3
  22. data/lib/samagotchi/terminal_ui/formatting.rb +7 -4
  23. data/lib/samagotchi/terminal_ui.rb +35 -5
  24. data/lib/samagotchi/tool_activity.rb +6 -2
  25. data/lib/samagotchi/tools/delegate.rb +14 -10
  26. data/lib/samagotchi/tools/execute.rb +3 -1
  27. data/lib/samagotchi/version.rb +1 -1
  28. data/lib/samagotchi/web/public/activity.js +2 -1
  29. data/lib/samagotchi/web/public/app.js +9 -7
  30. data/lib/samagotchi/web/public/command_view.js +19 -5
  31. data/lib/samagotchi/web/public/index.html +14 -6
  32. data/lib/samagotchi/web/public/stage_model.js +18 -0
  33. data/lib/samagotchi/web/public/stage_view.js +16 -11
  34. data/lib/samagotchi/web/public/task_stop.js +11 -2
  35. data/lib/samagotchi/web/public/timing.js +12 -7
  36. data/lib/samagotchi/web/public/turn_events.js +3 -1
  37. data/lib/samagotchi/web/public/turn_model.js +16 -11
  38. data/lib/samagotchi/web/public/turn_view.js +26 -3
  39. data/lib/samagotchi/worker.rb +21 -13
  40. metadata +2 -1
@@ -241,9 +241,10 @@ module Samagotchi
241
241
 
242
242
  # A worker session like the web start page's: saved as running with the
243
243
  # message before its worker spawns, so lists and the web show it at
244
- # once. The full id, so a script can pass it on. Only the model's host
245
- # is checked here (in spawn_session, as in the web): a wrong model id
246
- # fails in the worker.
244
+ # once. The full id, so a script can pass it on. The model's host is
245
+ # checked here (in spawn_session, as in the web): an unknown one is
246
+ # refused before anything is spawned; an id the host's saved model list
247
+ # doesn't have starts with a warning on stderr.
247
248
  # @return [Integer] the exit status
248
249
  def run_new(prompt, options)
249
250
  images = !@images.empty?
@@ -264,6 +265,7 @@ module Samagotchi
264
265
  error_line("chi send: could not start a session: #{e.message}")
265
266
  return 1
266
267
  end
268
+ error_line("chi send: warning: #{session.model_warning}") if session.model_warning
267
269
  if through_worker
268
270
  return 1 unless deliver_new(session, prompt)
269
271
  else
@@ -79,6 +79,11 @@ module Samagotchi
79
79
  # sets it); not saved.
80
80
  attr_accessor :seed_images_dropped
81
81
 
82
+ # The warning for a model id its host's saved list doesn't have
83
+ # (ModelProfile.model_warning; SessionManager.spawn_session sets it);
84
+ # not saved.
85
+ attr_accessor :model_warning
86
+
82
87
  def initialize(id:, mode:, model_name:, working_directory:, messages:, created_at:, updated_at:,
83
88
  metadata_version: METADATA_VERSION, status: STATUS_IDLE, last_prompt: "",
84
89
  first_preview: "", test_run: false, pending_question: nil,
@@ -130,14 +130,20 @@ module Samagotchi
130
130
  # @param images_from [String, nil] the session dir the seed's images are in
131
131
  # @param title [String, nil] what the lists show before the first turn
132
132
  # (the prompt's preview by default, else the seed's first user message)
133
- # @return [Session] with #seed_images_dropped: refs whose file was gone
133
+ # @return [Session] with #seed_images_dropped: refs whose file was gone,
134
+ # and #model_warning: an id its host's saved list doesn't have
134
135
  def self.spawn_session(prompt:, mode: "assist", working_directory: nil, model_name: nil, state_dir: nil,
135
136
  memories: [], muted_memories: [], parent_id: nil, messages: [], images_from: nil,
136
137
  title: nil)
137
138
  sd = state_dir || Session.default_state_dir
138
139
  # The resolved ref is stored (a resumed session keeps its model when an
139
- # alias is retargeted), with the name as typed beside it.
140
+ # alias is retargeted), with the name as typed beside it. A wrong host
141
+ # refuses here, before anything is saved or spawned; a model id the
142
+ # host's saved list doesn't have (ModelListStore) only warns
143
+ # (#model_warning on the session), as some hosts serve ids they don't
144
+ # list.
140
145
  typed = Samagotchi::ModelProfile.check_host!(Samagotchi::ModelProfile.required_model_name(model_name))
146
+ model_warning = Samagotchi::ModelProfile.model_warning(typed)
141
147
  ref = ConfigFile.model_ref(typed).ref
142
148
  session = Session.new_session(
143
149
  mode: mode,
@@ -149,6 +155,7 @@ module Samagotchi
149
155
  parent_id: parent_id,
150
156
  messages: messages
151
157
  )
158
+ session.model_warning = model_warning
152
159
  # With no prompt there is no first turn to run (an attaching UI sends
153
160
  # the prompts), so the session starts idle.
154
161
  session.status = prompt.to_s.strip.empty? ? Session::STATUS_IDLE : Session::STATUS_RUNNING
@@ -481,9 +481,12 @@ module Samagotchi
481
481
  # A turn that failed mid-stream never saw its generation end:
482
482
  # what it got to still counts.
483
483
  close_generation if @turn.gen_open
484
- # A call still running when the turn ends (a cancel, a failure)
485
- # never completes: its record says so, as the count already has it.
486
- @turn.tool_calls_by_id.each_value { |active| finish_tool_record(active, "canceled") }
484
+ # A call still running when the turn ends never completes: its
485
+ # record says so, as the count already has it. A cancel stopped it
486
+ # ("stopped", as ToolActivity names a call the Stop cut); a
487
+ # failure left it "canceled".
488
+ cut = status == "canceled" ? "stopped" : "canceled"
489
+ @turn.tool_calls_by_id.each_value { |active| finish_tool_record(active, cut) }
487
490
  @turn.tool_calls_by_id.clear
488
491
  finished_at = now
489
492
  record = {
@@ -226,19 +226,22 @@ module Samagotchi
226
226
  # A cancel's reason as the UIs name it (anything else as it is);
227
227
  # spec/shared/labels_matrix.json pins it for both.
228
228
  CANCEL_REASONS = { "ctrl_c" => "Ctrl-C", "user" => "stopped", "hook" => "by a hook" }.freeze
229
+ # A canceled turn's mark: a stop someone chose (Ctrl-C, Stop, a hook),
230
+ # not an error's ✕ (timing.js STOP_MARK; the labels matrix pins it).
231
+ STOP_MARK = "■"
229
232
  # Under a canceled prompt turn (a canceled continue is back where it
230
233
  # started): its partial progress stays in the conversation.
231
234
  ROLLBACK_HINT = "partial progress kept; !rollback restores the pre-turn state"
232
235
 
233
- # "✕ turn canceled (Ctrl-C) · 3.1s"; a hook's stop that names who
234
- # (+by+, turn_canceled's cancelled_by): "✕ turn stopped by loop-guard"
236
+ # "■ turn canceled (Ctrl-C) · 3.1s"; a hook's stop that names who
237
+ # (+by+, turn_canceled's cancelled_by): "■ turn stopped by loop-guard"
235
238
  def turn_canceled_line(reason, duration_ms, by: nil)
236
239
  if reason.to_s == "hook" && !by.to_s.empty?
237
- return "#{paint("✕", 33)} turn stopped by #{by}#{turn_duration_suffix(duration_ms)}"
240
+ return "#{paint(STOP_MARK, 33)} turn stopped by #{by}#{turn_duration_suffix(duration_ms)}"
238
241
  end
239
242
 
240
243
  label = reason.to_s.empty? ? nil : CANCEL_REASONS.fetch(reason.to_s, reason.to_s)
241
- "#{paint("✕", 33)} turn canceled#{" (#{label})" if label}#{turn_duration_suffix(duration_ms)}"
244
+ "#{paint(STOP_MARK, 33)} turn canceled#{" (#{label})" if label}#{turn_duration_suffix(duration_ms)}"
242
245
  end
243
246
 
244
247
  # "✕ turn failed: <summary> · 2.0s"
@@ -16,6 +16,7 @@ require_relative "host_registry"
16
16
  require_relative "session"
17
17
  require_relative "owner_lock"
18
18
  require_relative "bridge/card_store"
19
+ require_relative "bridge/pending_card"
19
20
  require_relative "engine"
20
21
  require_relative "guardrails/parent_approvals"
21
22
  require_relative "tools/memory"
@@ -298,7 +299,10 @@ module Samagotchi
298
299
  ensure
299
300
  # However it ends (the early returns too; the Engine was built in
300
301
  # #initialize): the anytime commands finish, the plugins' services
301
- # stop (a server process).
302
+ # stop (a server process). Nothing resolves an open card any more, so
303
+ # its pending_card.json goes (a scratch session's folder is deleted
304
+ # below anyway; the others stay).
305
+ clear_pending_card
302
306
  @engine&.shutdown
303
307
  # A scratch session goes on every way out: /exit, Ctrl-D, an error,
304
308
  # SIGTERM or SIGHUP (Ruby raises those here). Only kill -9 leaves it
@@ -308,6 +312,17 @@ module Samagotchi
308
312
 
309
313
  EMPTY_ANSWER_ERROR = "chi: the model gave an empty answer"
310
314
 
315
+ # The session is over for this process (its exits and the ensure below):
316
+ # nothing will resolve the open card any more, so its pending_card.json
317
+ # goes (the Engine's TURN_END would clear it, but the REPL may end
318
+ # mid-turn).
319
+ def clear_pending_card
320
+ return unless @pending_card_handle
321
+
322
+ @pending_card_handle.observer.clear
323
+ @pending_card_handle = nil
324
+ end
325
+
311
326
  # A -p --non-interactive turn that failed (bin/chi exits 1): the error,
312
327
  # then how to go on. The Engine saved the failed turn with its prompt,
313
328
  # so the session is kept and `chi --resume` takes it up again.
@@ -415,13 +430,28 @@ module Samagotchi
415
430
  @surface.commit("Session #{session.id} was not archived (#{e.message}): chi sessions archive #{session.id}")
416
431
  end
417
432
 
418
- # The session's cards.json, kept as a worker's Bridge keeps it (it owns
419
- # the session as a worker would): this REPL's kept turns count on from
420
- # the saved count, so a card an earlier worker showed stays that many
421
- # turns back, and its own cards and notices are saved for the next.
433
+ # The session's cards.json and pending_card.json, kept as a worker's
434
+ # Bridge keeps them (it owns the session as a worker would): this REPL's
435
+ # kept turns count on from the saved count, so a card an earlier worker
436
+ # showed stays that many turns back, and its own cards and notices are
437
+ # saved for the next; an open card with actions is on disk, where
438
+ # `chi web`'s session hub reads it to mark the session as waiting.
422
439
  def keep_cards(session)
423
440
  path = File.join(Session.session_dir(session.id), Bridge::CardStore::FILE)
424
441
  @cards_handle ||= @engine.subscribe(observer: Bridge::CardStore.new(path: path))
442
+ keep_pending_card(session)
443
+ end
444
+
445
+ # A card a worker that died left isn't open: the file goes before the
446
+ # observer takes over, as Bridge#start does. Subscribed once: the REPL
447
+ # keeps one session, and a second observer would fold every card twice
448
+ # and clear the file at the first turn's end.
449
+ def keep_pending_card(session)
450
+ return if @pending_card_handle
451
+
452
+ pending = Bridge::PendingCard.new(Session.session_dir(session.id))
453
+ pending.clear
454
+ @pending_card_handle = @engine.subscribe(observer: pending)
425
455
  end
426
456
 
427
457
  # Take the session's OwnerLock for this process's lifetime: the TUI runs
@@ -176,10 +176,14 @@ module Samagotchi
176
176
  # "error" for an "Error:" result, and for an execute whose command
177
177
  # exited non-zero (its last "exit: N" line; no number: killed by a
178
178
  # signal), so the turn tally's "(N failed)" counts it. "stopped" for a
179
- # task_wait the user's Stop ended (the task itself runs on), and for one
180
- # whose task was stopped (the stop-task button, or the model's task_stop).
179
+ # task_wait the user's Stop ended (the task itself runs on), for one
180
+ # whose task was stopped (the stop-task button, or the model's task_stop),
181
+ # and for a command the user's Stop killed or kept from starting: a
182
+ # cancel, not a failure (its text still starts "Error:" for the model).
181
183
  def tool_activity_status(result, tool_name = nil)
182
184
  text = result.to_s
185
+ return "stopped" if text == Tools::Execute::NOT_RUN_ON_STOP
186
+ return "stopped" if tool_name == Tools::Execute::NAME && text.start_with?(Tools::Execute::STOPPED_BY_USER)
183
187
  return "error" if text.start_with?("Error:")
184
188
  return "error" if tool_name == Tools::Execute::NAME && execute_failed?(text)
185
189
  return "stopped" if tool_name == Tools::TaskWait::NAME && text.match?(/^(wait_result: canceled|status: stopped)$/)
@@ -51,16 +51,18 @@ module Samagotchi
51
51
  wait = parse_wait(wait)
52
52
  timeout = parse_timeout(timeout)
53
53
 
54
- child_id = if session.to_s.strip.empty?
55
- start_child(task, parent: parent, model: model, state_dir: sd)
56
- else
57
- follow_up(task, session: session.to_s.strip, parent: parent, state_dir: sd)
58
- end
54
+ child_id, warning = if session.to_s.strip.empty?
55
+ start_child(task, parent: parent, model: model, state_dir: sd)
56
+ else
57
+ follow_up(task, session: session.to_s.strip, parent: parent, state_dir: sd)
58
+ end
59
59
  return child_id if child_id.start_with?("Error:")
60
- return DelegateWait.call(child_id, peers: peers, timeout: timeout) if wait
60
+
61
+ note = warning ? "Warning: #{warning}\n" : ""
62
+ return note + DelegateWait.call(child_id, peers: peers, timeout: timeout) if wait
61
63
 
62
64
  started = session.to_s.strip.empty? ? "Started a delegate session" : "Sent the follow-up to delegate #{child_id[0, 8]}"
63
- "session: #{child_id}\nstatus: running\n#{started}; delegate_result waits for its reply. " \
65
+ "#{note}session: #{child_id}\nstatus: running\n#{started}; delegate_result waits for its reply. " \
64
66
  "It shows in chi sessions list and the web as a child of this session; the user can attach to it."
65
67
  rescue ArgumentError => e
66
68
  "Error: #{e.message}"
@@ -84,7 +86,7 @@ module Samagotchi
84
86
  model_name: child_model(model, parent), memories: CHILD_MEMORIES,
85
87
  parent_id: parent.id, state_dir: state_dir)
86
88
  DelegateWait.mark_started(parent.id, child)
87
- child.id
89
+ [child.id, child.model_warning]
88
90
  end
89
91
  private_class_method :start_child
90
92
 
@@ -118,8 +120,10 @@ module Samagotchi
118
120
  private_class_method :follow_up
119
121
 
120
122
  # The model as typed (spawn_session stores its resolved ref), else the
121
- # parent's. No existence check: an unknown model fails the child's
122
- # first turn.
123
+ # parent's. No existence check here: spawn_session checks the host
124
+ # (check_host!, its UnknownHost comes back as this tool's Error: line)
125
+ # and warns about an id the host's saved model list doesn't have
126
+ # (ModelProfile.model_warning, added to this tool's result).
123
127
  def self.child_model(model, parent)
124
128
  return parent.model_name if model.to_s.strip.empty?
125
129
 
@@ -21,6 +21,8 @@ module Samagotchi
21
21
  STOP_POLL_INTERVAL_SEC = 0.05
22
22
  WAIT_SLICE_SEC = 0.2
23
23
  NOT_RUN_ON_STOP = "Error: not run, the user stopped the turn"
24
+ # The first words of a command the user's Stop killed.
25
+ STOPPED_BY_USER = "Error: command stopped by the user"
24
26
  TIMEOUT_HINT = "(killed at the limit; for a long command use task_create, then task_wait)"
25
27
  # How long the output may stay open once the shell has exited: a
26
28
  # `server &` child keeps the pipes, and the read would never end.
@@ -67,7 +69,7 @@ module Samagotchi
67
69
  # a long command, and return whatever it printed before it was killed.
68
70
  stopped_result("Error: command timed out after #{timeout_sec}s\n#{TIMEOUT_HINT}", e)
69
71
  rescue CommandCancelled => e
70
- stopped_result("Error: command stopped by the user after #{e.elapsed.round}s (killed; rerun it if still needed)", e)
72
+ stopped_result("#{STOPPED_BY_USER} after #{e.elapsed.round}s (killed; rerun it if still needed)", e)
71
73
  rescue StandardError => e
72
74
  "Error: #{e.message}"
73
75
  end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.29.0"
4
+ VERSION = "0.30.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
@@ -10,7 +10,8 @@ export const ROW_STATUS = Object.freeze({
10
10
  RUNNING: "running",
11
11
  OK: "ok",
12
12
  ERROR: "error",
13
- // A task_wait the user's Stop ended (the task itself runs on).
13
+ // A call the user's Stop cut: a task_wait (the task itself runs on), a
14
+ // killed or never-started command. Not a failure.
14
15
  STOPPED: "stopped",
15
16
  // A call a guardrail denied (a hook, the guardrails bundle, a parent's
16
17
  // or the user's Deny): it never ran, and it isn't a failed call either.
@@ -43,7 +43,7 @@ import { applySessionEvent, heldOrder, listedSessions, sortedByUpdated, stoppedB
43
43
  import { createNotifications } from "./notifications.js";
44
44
  import { restartConfirmText, versionNotice, workerBadge } from "./update_notice.js";
45
45
  import {
46
- appendAboveLiveTiming, cancelLineText, elapsedSince, formatDuration, mergeTiming, normalizeTiming, turnTimingText,
46
+ appendAboveLiveTiming, cancelLineText, elapsedSince, formatDuration, mergeTiming, normalizeTiming, STOPPED_LINE_CLASS, turnTimingText,
47
47
  } from "./timing.js";
48
48
  import { DONE_MS, applyInitEvent, dropDone, initRowHtml, newInitTasks, seedInitTasks } from "./init_row.js";
49
49
  import { clientLabel, commandView, displayWait, dropEarlyRestores, emptyAnswerLine, emptyRetryLine, hookNoticeLabel, initWaitLine, isOwn, sessionCommandLine, keepEarlyRestore, newClientId, noticeLine, promptOps, reminderText, restoreAction, composerAfterAck, restoreOnAck, replaysDrawnTurn, retryStatusLine, snapshotEvents, steerCutLine, startPageReplyOrHint, turnOutput, unknownCommandHint, webLocalReply, workerGoneText } from "./turn_events.js";
@@ -960,9 +960,11 @@ function appendChunk(text) {
960
960
  if (follow) historyEl.scrollTop = historyEl.scrollHeight;
961
961
  }
962
962
 
963
- function addEndBubble(text) {
963
+ // +stopped+: a stop someone chose (a cancel, the session's stop), drawn in
964
+ // the stopped style (timing.js STOPPED_LINE_CLASS); else a failure's line.
965
+ function addEndBubble(text, { stopped = false } = {}) {
964
966
  const div = document.createElement("div");
965
- div.className = "bubble cancel";
967
+ div.className = stopped ? STOPPED_LINE_CLASS : "bubble cancel";
966
968
  div.textContent = text;
967
969
  appendToHistory(div);
968
970
  if (!turnDom.holds?.()) historyEl.scrollTop = historyEl.scrollHeight;
@@ -980,7 +982,7 @@ function emptyAnswerEl(emptyAnswer) {
980
982
  // Everything a turn's end (completed, canceled, failed) closes: then its
981
983
  // end line (+endText+, the cancel or failed line), then the step cards that
982
984
  // left the collapsed block (card.js leavesBlock), where a reload puts them.
983
- function endTurnView(kind, endText = null, { emptyAnswer = null } = {}) {
985
+ function endTurnView(kind, endText = null, { emptyAnswer = null, stopped = false } = {}) {
984
986
  setTurnRunning(false);
985
987
  // Every ended turn has a record (completed, canceled, failed); a worker
986
988
  // gone mid-turn is re-rendered (resync), which counts again.
@@ -991,7 +993,7 @@ function endTurnView(kind, endText = null, { emptyAnswer = null } = {}) {
991
993
  const kept = turnDom.turnEnded({ kind, endNote, emptyAnswer: !!emptyAnswer }) || [];
992
994
  if (endNote && !endNote.isConnected) appendToHistory(endNote);
993
995
  finishLiveTurnTiming(null, { canceled: kind === "canceled" });
994
- if (endText) addEndBubble(endText);
996
+ if (endText) addEndBubble(endText, { stopped });
995
997
  if (kept.length) {
996
998
  kept.forEach((el) => appendToHistory(el));
997
999
  if (!turnDom.holds?.()) historyEl.scrollTop = historyEl.scrollHeight;
@@ -1653,7 +1655,7 @@ function workerGone(id, { opened = true } = {}) {
1653
1655
  const stopped = stoppingId === id;
1654
1656
  const endText = workerGoneText({ turnRunning, stopped });
1655
1657
  if (endText) {
1656
- endTurnView("gone", endText);
1658
+ endTurnView("gone", endText, { stopped });
1657
1659
  if (!stopped) setLiveStatus("idle");
1658
1660
  }
1659
1661
  if (current.owner === "worker") current.owner = null;
@@ -1770,7 +1772,7 @@ const streamHandlers = {
1770
1772
  }
1771
1773
  },
1772
1774
  turn_canceled: (data) => {
1773
- endTurnView("canceled", cancelLineText(data.cancellation_reason || "", data.cancelled_by));
1775
+ endTurnView("canceled", cancelLineText(data.cancellation_reason || "", data.cancelled_by), { stopped: true });
1774
1776
  setLiveStatus("idle");
1775
1777
  refreshTimingFromSession();
1776
1778
  },
@@ -82,6 +82,24 @@ export function stepTextHtml(text) {
82
82
  return out + escapeHtml(String(text).slice(at));
83
83
  }
84
84
 
85
+ function heredocLabel({ tag, lines }) {
86
+ return `${tag} · ${Number(lines)} ${lines === 1 ? "line" : "lines"}`;
87
+ }
88
+
89
+ // A step's heredoc chip: the first heredoc's tag and size, and "+N" for
90
+ // the others when the step has more (CommandSteps' heredocs:, as gh api
91
+ // -f body="$(cat <<EOF …)" -f title="$(cat <<EOF …)"), as a title with
92
+ // more steps reads "+N"; the hover lists them all. "" without one.
93
+ export function heredocChip(step) {
94
+ if (!step?.heredoc) return "";
95
+ const all = Array.isArray(step.heredocs) && step.heredocs.length > 1 ? step.heredocs : [step.heredoc];
96
+ const more = all.length > 1 ? ` +${all.length - 1}` : "";
97
+ const title = all.length > 1
98
+ ? `${all.length} heredocs: ${all.map(heredocLabel).join(", ")}; their text in the raw command`
99
+ : "a heredoc: its text in the raw command";
100
+ return `<span class="step-chip heredoc" title="${escapeHtml(title)}">${escapeHtml(heredocLabel(all[0]))}${more}</span>`;
101
+ }
102
+
85
103
  // The steps as a list: a label as a small heading above its step, the op
86
104
  // that links a step to the one before, its text, its limit and heredoc
87
105
  // as chips (beside the text, below it when the row is narrow).
@@ -89,11 +107,7 @@ export function stepsHtml(steps) {
89
107
  const items = steps.map((step) => {
90
108
  const label = step.label ? `<li class="step-label">${escapeHtml(step.label)}</li>` : "";
91
109
  const limit = step.limit ? `<span class="step-chip" title="output limited">${escapeHtml(step.limit)}</span>` : "";
92
- const heredoc = step.heredoc
93
- ? `<span class="step-chip heredoc" title="a heredoc: its text in the raw command">${escapeHtml(step.heredoc.tag)} · ` +
94
- `${Number(step.heredoc.lines)} ${step.heredoc.lines === 1 ? "line" : "lines"}</span>`
95
- : "";
96
- return `${label}<li class="activity-step">${opHtml(step.op)}<span class="step-body"><code>${stepTextHtml(step.text)}</code>${limit}${heredoc}</span></li>`;
110
+ return `${label}<li class="activity-step">${opHtml(step.op)}<span class="step-body"><code>${stepTextHtml(step.text)}</code>${limit}${heredocChip(step)}</span></li>`;
97
111
  });
98
112
  return `<ol class="activity-steps">${items.join("")}</ol>`;
99
113
  }
@@ -122,6 +122,8 @@ main{flex:1;display:flex;flex-direction:column;gap:16px;padding:16px;overflow:au
122
122
  .bubble.user blockquote:last-child{margin-bottom:0}
123
123
  .bubble.tool{border-left:3px solid var(--ok);background:var(--bg-panel);font-size:13px;color:var(--text-2)}
124
124
  .bubble.cancel{border-left:3px solid var(--danger);background:color-mix(in srgb,var(--danger) 8%,var(--bg-panel));color:var(--danger-text);font-size:13px}
125
+ /* A stop someone chose (a cancel, the session's stop): the ■ STOPPED rows' colours, not an error's. */
126
+ .bubble.cancel.stopped{border-left-color:var(--warn);background:color-mix(in srgb,var(--warn) 8%,var(--bg-panel));color:var(--warn-text)}
125
127
  .bubble.recap{border-left:3px solid var(--think-text);background:color-mix(in srgb,var(--think) 6%,var(--bg-panel));font-size:13px}
126
128
  .bubble.recap summary{cursor:pointer;color:var(--think-text);font-size:12px}
127
129
  .bubble.recap .recap-body{white-space:pre-wrap;margin-top:6px}
@@ -236,6 +238,8 @@ button.task-stop:hover:not(:disabled){background:var(--danger-bg-dim);filter:non
236
238
  .gen>summary::-webkit-details-marker{display:none}
237
239
  .gen>summary::before{content:"▸ ";color:var(--text-faint)}
238
240
  .gen[open]>summary::before{content:"▾ "}
241
+ /* Open, a step whose label starts with its body's first words (turn_view.js labelParts) says only its calls. */
242
+ .gen[open]>summary.repeats>:is(.gen-head,.gen-sep){display:none}
239
243
  /* A step with a failed call (status error): its chevron in the danger colour. */
240
244
  .gen.has-failed>summary::before{color:var(--danger)}
241
245
  .gen.live{border-top-color:transparent;background:var(--accent-tint);border-radius:8px;padding:6px 9px 8px calc(9px + var(--step-indent));margin-left:-9px;margin-right:-9px}
@@ -545,6 +549,7 @@ body.stage-view #dock,body.stage-view #dock:focus-within{background:transparent;
545
549
  .turn-stage[data-phase="no answer"] .ts-mark,.turn-stage[data-phase="no answer"] .ts-phase{color:var(--text-muted)}
546
550
  .turn-stage[data-phase="waiting for you"] .ts-mark{background:var(--warn)}
547
551
  .ts-phase{color:var(--accent);font-weight:600}
552
+ .ts-step:empty{display:none}
548
553
  .ts-timing{min-width:0;overflow:hidden;text-overflow:ellipsis}
549
554
  .ts-timing .turn-timing{margin:0;font-size:12px;color:var(--text-muted);display:inline}
550
555
  .ts-mini{min-width:0;overflow:hidden;text-overflow:ellipsis;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;color:var(--text-2)}
@@ -579,14 +584,13 @@ body.stage-view #dock,body.stage-view #dock:focus-within{background:transparent;
579
584
  .ts-spin{flex:none;width:11px;height:11px;border:2px solid var(--warn);border-right-color:transparent;border-radius:50%;animation:init-spin .8s linear infinite}
580
585
  .ts-idle{flex:none;width:11px;height:11px;border:2px solid var(--border-strong);border-radius:50%}
581
586
  .ts-tool{flex:none;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px;font-weight:600;color:var(--accent)}
582
- .ts-tool.exec{color:var(--think-text)}
583
- .ts-tool.edit{color:var(--ok-text)}
584
587
  .ts-title{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px;color:var(--text-2)}
585
588
  .ts-title.quiet{font-family:inherit;font-style:italic;color:var(--text-faint)}
586
589
  .ts-trail{height:18px;display:flex;gap:10px;overflow:hidden;font-size:12px;line-height:18px;color:var(--text-faint);white-space:nowrap}
587
590
  .ts-trail-item{flex:none;max-width:36ch;overflow:hidden;text-overflow:ellipsis}
588
- .ts-trail-item.error{color:var(--danger-text)}
591
+ .ts-trail-item:is(.error,.blocked){color:var(--danger-text)}
589
592
  .ts-trail-item.warn{color:var(--warn-text)}
593
+ .ts-trail-item.stopped{color:var(--warn-text);opacity:.8}
590
594
  .ts-trail-item.info,.ts-trail-item.steer{color:var(--accent)}
591
595
  .ts-trail-item.none{font-style:italic}
592
596
  /* A card waits: the tool row and trail give it their room. */
@@ -617,10 +621,14 @@ body.stage-view #dock,body.stage-view #dock:focus-within{background:transparent;
617
621
  .ts-ticks{display:flex;flex-wrap:nowrap;justify-content:flex-end;overflow:hidden;gap:2px;flex:0 1 auto;min-width:0}
618
622
  .ts-tick{flex:none}
619
623
  .ts-tick{width:5px;height:12px;border-radius:1.5px;background:var(--text-faint);opacity:.55;cursor:pointer}
620
- .ts-tick.exec{background:var(--think);opacity:.85}
621
- .ts-tick.edit{background:var(--ok);opacity:.85}
622
- .ts-tick.other{background:var(--accent);opacity:.7}
624
+ /* exec and edit are the rows' tool blue (purple is thinking's, green a done ✓'s); other a quieter blue-grey. */
625
+ .ts-tick:is(.exec,.edit){background:var(--accent);opacity:.85}
626
+ .ts-tick.other{background:var(--narr);opacity:.7}
623
627
  .ts-tick.error{background:var(--danger);opacity:1}
628
+ /* A call a guardrail blocked: its ✕ BLOCKED row's red. */
629
+ .ts-tick.blocked{background:var(--danger-text);opacity:1}
630
+ /* A call the user's Stop cut: the ■ STOPPED rows' amber, still (running blinks). */
631
+ .ts-tick.stopped{background:var(--warn-text);opacity:.8}
624
632
  .ts-tick.running{background:var(--warn);opacity:1;animation:ts-blink 1s ease-in-out infinite}
625
633
  @keyframes ts-blink{50%{opacity:.35}}
626
634
  .ts-cloud-body{max-height:calc(var(--stage-max,60vh) * .62);overflow:auto;scrollbar-width:thin;border-left:1px solid var(--border);margin-left:6px;padding-left:10px}
@@ -76,6 +76,20 @@ export function toolKind(name) {
76
76
  return "other";
77
77
  }
78
78
 
79
+ // A finished call's mark in the trail, as its row's (index.html
80
+ // .activity-status): error and blocked ✕, stopped (a Stop cut it) ■, else ✓.
81
+ export function trailMark(status) {
82
+ if (status === "error" || status === "blocked") return "\u2715";
83
+ if (status === "stopped") return "\u25A0";
84
+ return "\u2713";
85
+ }
86
+
87
+ // A finished call's tone in the trail, its class as its row's colour:
88
+ // error and blocked (a guardrail's deny) red, stopped amber, else none.
89
+ export function trailTone(status) {
90
+ return status === "error" || status === "blocked" || status === "stopped" ? status : "";
91
+ }
92
+
79
93
  function rowTitle(row) {
80
94
  return row.title || row.params || "";
81
95
  }
@@ -116,6 +130,9 @@ const NO_ANSWER = "no answer";
116
130
  // else "…";
117
131
  // tool the running call ({name, title, kind}, and command: its full
118
132
  // command when it has one; taskId and key: a task_wait's) or null;
133
+ // quiet the now row's word while no call runs: "writing…" while the
134
+ // step's text streams, else "thinking…"; "" once the turn has
135
+ // ended (nothing happens now);
119
136
  // trail the last three finished calls, newest last (a file's title
120
137
  // without its dir; command as for tool);
121
138
  // ticks one per call: {kind, status, step (the gen's index)}.
@@ -146,6 +163,7 @@ export function liveSlots(turn, { narration = "", thinking = "", pending = false
146
163
  phase,
147
164
  step: ended ? gens.filter((g) => !g.answer || g.thinking || g.tools.length).length : gens.length,
148
165
  headline,
166
+ quiet: ended ? "" : current?.text && !current.textDone ? "writing…" : "thinking…",
149
167
  tool: running ? withTask(withCommand({ name: toolName(running), title: rowTitle(running), kind: toolKind(running.tool) }, running), running) : null,
150
168
  trail,
151
169
  ticks: rows.map(({ row, step }) => ({ kind: toolKind(row.tool), status: row.status, step })),
@@ -11,7 +11,7 @@
11
11
  // reload. Slots are patched in place, never rebuilt from HTML: a selection
12
12
  // and annotate survive. The rules are pure in stage_model.js.
13
13
  import { createTurnView } from "./turn_view.js";
14
- import { flashOf, handOffDue, handOffOrder, headlineOf, inUseFrom, isPlain, liveSlots, placeFor } from "./stage_model.js";
14
+ import { flashOf, handOffDue, handOffOrder, headlineOf, inUseFrom, isPlain, liveSlots, placeFor, trailMark, trailTone } from "./stage_model.js";
15
15
  import { formatDuration } from "./timing.js";
16
16
  import { taskCommand, taskStopButton } from "./task_stop.js";
17
17
 
@@ -189,20 +189,26 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
189
189
  stage.dataset.phase = slots.phase;
190
190
  mark.textContent = MARKS[slots.phase] || "";
191
191
  phaseEl.textContent = slots.phase;
192
- stepEl.textContent = endKind ? `${slots.step} step${slots.step === 1 ? "" : "s"}` : `step ${slots.step}`;
192
+ const steps = `${slots.step} step${slots.step === 1 ? "" : "s"}`;
193
193
 
194
194
  drawHeadline(slots.headline);
195
- drawNow(slots.tool, gen);
195
+ drawNow(slots.tool, slots.quiet);
196
196
  drawTrail(slots.trail);
197
197
  drawTicks(slots.ticks);
198
198
  const calls = slots.ticks.length;
199
- const elapsed = endKind ? formatDuration(endedAt - startedAt) : "";
200
- chipLabel.textContent = `${slots.step} step${slots.step === 1 ? "" : "s"} · ${calls} tool call${calls === 1 ? "" : "s"}${elapsed ? ` · ${elapsed}` : ""}`;
199
+ // An ended turn says each thing once: its steps in the chip (the label
200
+ // of what the cloud holds), its time in the status row's timing line
201
+ // (the one that moves into the history). The chip keeps the time when
202
+ // that line is elsewhere (a turn the stage didn't hold from its start).
203
+ const elapsed = endKind && !timingSlot.firstChild ? formatDuration(endedAt - startedAt) : "";
204
+ chipLabel.textContent = `${steps} · ${calls} tool call${calls === 1 ? "" : "s"}${elapsed ? ` · ${elapsed}` : ""}`;
201
205
  miniTool.textContent = collapsed && slots.tool ? `${slots.tool.name} ${slots.tool.title}` : "";
202
206
  miniTool.title = (collapsed && slots.tool?.command) || "";
203
207
 
204
208
  live.hidden = plain || !!endKind;
205
209
  cloud.hidden = !blockEl || blockEl.parentNode !== cloudBody;
210
+ // Running: the step it is on. Ended: the count, unless the chip says it.
211
+ stepEl.textContent = !endKind ? `step ${slots.step}` : cloud.hidden ? steps : "";
206
212
  promptSlot.hidden = !promptSlot.firstChild;
207
213
  extras.hidden = !extras.firstChild;
208
214
  answer.hidden = !answer.firstChild;
@@ -225,8 +231,7 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
225
231
  headline.classList.add("fresh");
226
232
  }
227
233
 
228
- function drawNow(tool, gen) {
229
- const quiet = gen?.text && !gen.textDone ? "writing…" : "thinking…";
234
+ function drawNow(tool, quiet) {
230
235
  // A task_wait's stop button, as the row's (task_stop.js): in the key
231
236
  // with its in-flight state, so a redraw keeps a clicked one disabled.
232
237
  const stopId = tool?.taskId && taskStop?.target({ tool: "task_wait", status: "running", view: { task_id: tool.taskId } });
@@ -248,7 +253,7 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
248
253
  const command = () => taskCommand(view.current().turn?.gens, stopId);
249
254
  now.appendChild(taskStopButton(taskStop, stopId, { command, onChange: scheduleRefresh }));
250
255
  }
251
- } else {
256
+ } else if (quiet) {
252
257
  const word = node("span", "ts-title quiet", now);
253
258
  word.textContent = quiet;
254
259
  }
@@ -262,9 +267,9 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
262
267
  trail.replaceChildren();
263
268
  if (flashing) node("span", `ts-trail-item ${flashing.kind}`, trail).textContent = `${FLASH_MARKS[flashing.kind]} ${flashing.text}`;
264
269
  for (const item of items) {
265
- const failed = item.status === "error";
266
- const el = node("span", `ts-trail-item${failed ? " error" : ""}`, trail);
267
- el.textContent = `${failed ? "✕" : "✓"} ${item.name} ${item.title}`.trim();
270
+ const tone = trailTone(item.status);
271
+ const el = node("span", `ts-trail-item${tone ? ` ${tone}` : ""}`, trail);
272
+ el.textContent = `${trailMark(item.status)} ${item.name} ${item.title}`.trim();
268
273
  if (item.command) el.title = item.command;
269
274
  }
270
275
  if (!flashing && !items.length) node("span", "ts-trail-item none", trail).textContent = "no tool calls yet";
@@ -19,17 +19,26 @@ export function stopTarget(row, features) {
19
19
  return row.view?.task_id || null;
20
20
  }
21
21
 
22
+ // A task_wait row's title without its wait ("npm test · up to 600s").
23
+ const WAIT_SUFFIX = / · up to \d+s$/;
24
+
22
25
  // The command a task_create of this turn started +taskId+ with (its row's
23
- // view), or null: a task from an earlier turn has no row here.
26
+ // view), else a task_wait row's title for it (the server titles one by its
27
+ // task's command, also for a task from an earlier turn: ToolActivity.
28
+ // task_title), or null.
24
29
  export function taskCommand(gens, taskId) {
30
+ let titled = null;
25
31
  for (const gen of gens || []) {
26
32
  for (const row of gen.tools || []) {
33
+ if (row.tool === "task_wait" && row.view?.task_id === taskId && row.title) {
34
+ titled ||= row.title.replace(WAIT_SUFFIX, "");
35
+ }
27
36
  if (row.tool !== "task_create") continue;
28
37
  const id = /(?:^|\n)task_id: (\S+)/.exec(row.output || "")?.[1];
29
38
  if (id === taskId) return row.view?.command || null;
30
39
  }
31
40
  }
32
- return null;
41
+ return titled;
33
42
  }
34
43
 
35
44
  export function confirmText(taskId, command) {
@@ -24,20 +24,25 @@ export function turnTimingText(number, durationMs, { running = false, canceled =
24
24
  // CANCEL_REASONS); anything else as it is. Both sides: spec/shared/labels_matrix.json.
25
25
  const CANCEL_REASONS = { ctrl_c: "Ctrl-C", user: "stopped", hook: "by a hook" };
26
26
 
27
- // The line a canceled turn ends with: "✕ canceled (stopped)", live from
27
+ // The line a canceled turn ends with: "■ canceled (stopped)", live from
28
28
  // turn_canceled's reason and after a re-render from the turn record's
29
29
  // cancellation_reason (records saved before it existed: no reason). A
30
- // hook's stop that names who (+by+, cancelled_by): "✕ stopped by loop-guard".
30
+ // hook's stop that names who (+by+, cancelled_by): "■ stopped by loop-guard".
31
+ // Every cancel is a stop someone chose (the user, Ctrl-C, a hook), not a
32
+ // failure: the ■ mark and the stopped colours of a call the Stop cut
33
+ // (STOPPED_LINE_CLASS), not the ✕ of an error.
34
+ export const STOP_MARK = "\u25A0";
35
+ export const STOPPED_LINE_CLASS = "bubble cancel stopped";
31
36
  export function cancelLineText(reason, by) {
32
- if (reason === "hook" && by) return `\u2715 stopped by ${by}`;
33
- return "\u2715 canceled" + (reason ? ` (${CANCEL_REASONS[reason] || reason})` : "");
37
+ if (reason === "hook" && by) return `${STOP_MARK} stopped by ${by}`;
38
+ return `${STOP_MARK} canceled` + (reason ? ` (${CANCEL_REASONS[reason] || reason})` : "");
34
39
  }
35
40
 
36
- // A re-rendered turn's cancel line (a .bubble.cancel), or "" when the turn
37
- // record is not a canceled one.
41
+ // A re-rendered turn's cancel line (a .bubble.cancel.stopped), or "" when
42
+ // the turn record is not a canceled one.
38
43
  export function cancelLineHtml(record, escape) {
39
44
  if (record?.status !== "canceled") return "";
40
- return `<div class="bubble cancel">${escape(cancelLineText(record.cancellation_reason, record.cancelled_by))}</div>`;
45
+ return `<div class="${STOPPED_LINE_CLASS}">${escape(cancelLineText(record.cancellation_reason, record.cancelled_by))}</div>`;
41
46
  }
42
47
 
43
48
  export function elapsedSince(startedAt, now = Date.now()) {