samagotchi 0.28.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 (49) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +84 -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 +3 -3
  7. data/lib/samagotchi/bundle_command.rb +4 -2
  8. data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
  9. data/lib/samagotchi/bundles/mcp/plugin.rb +4 -3
  10. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +17 -4
  11. data/lib/samagotchi/bundles/source-links/manifest.yml +2 -2
  12. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  13. data/lib/samagotchi/command_steps.rb +35 -15
  14. data/lib/samagotchi/engine.rb +4 -3
  15. data/lib/samagotchi/guardrails/shell_lex.rb +79 -5
  16. data/lib/samagotchi/host_registry.rb +4 -0
  17. data/lib/samagotchi/kernel_loop.rb +27 -7
  18. data/lib/samagotchi/llm/chat_loop.rb +12 -6
  19. data/lib/samagotchi/memory_bundle/installer.rb +14 -0
  20. data/lib/samagotchi/model_list_store.rb +117 -0
  21. data/lib/samagotchi/model_profile.rb +43 -0
  22. data/lib/samagotchi/send_command.rb +5 -3
  23. data/lib/samagotchi/session.rb +5 -0
  24. data/lib/samagotchi/session_commands.rb +6 -2
  25. data/lib/samagotchi/session_manager.rb +9 -2
  26. data/lib/samagotchi/session_metrics.rb +6 -3
  27. data/lib/samagotchi/terminal_ui/formatting.rb +7 -4
  28. data/lib/samagotchi/terminal_ui.rb +35 -5
  29. data/lib/samagotchi/tool_activity.rb +38 -8
  30. data/lib/samagotchi/tools/builtins.rb +10 -7
  31. data/lib/samagotchi/tools/delegate.rb +14 -10
  32. data/lib/samagotchi/tools/execute.rb +3 -1
  33. data/lib/samagotchi/tools/task_runtime.rb +21 -0
  34. data/lib/samagotchi/turn_note.rb +10 -0
  35. data/lib/samagotchi/version.rb +1 -1
  36. data/lib/samagotchi/web/public/activity.js +2 -1
  37. data/lib/samagotchi/web/public/app.js +23 -11
  38. data/lib/samagotchi/web/public/command_view.js +19 -5
  39. data/lib/samagotchi/web/public/index.html +51 -25
  40. data/lib/samagotchi/web/public/sessions_list.js +21 -4
  41. data/lib/samagotchi/web/public/stage_model.js +23 -2
  42. data/lib/samagotchi/web/public/stage_view.js +16 -11
  43. data/lib/samagotchi/web/public/task_stop.js +11 -2
  44. data/lib/samagotchi/web/public/timing.js +12 -7
  45. data/lib/samagotchi/web/public/turn_events.js +3 -1
  46. data/lib/samagotchi/web/public/turn_model.js +29 -15
  47. data/lib/samagotchi/web/public/turn_view.js +70 -11
  48. data/lib/samagotchi/worker.rb +24 -7
  49. metadata +2 -1
@@ -117,13 +117,16 @@ module Samagotchi
117
117
  peers = kctx.peers
118
118
  id = peers.respond_to?(:session_id) ? peers.session_id.to_s : ""
119
119
  model = peers.respond_to?(:model_ref) ? peers.model_ref.to_s : ""
120
- vars = { Guardrails::ParentApprovals::PARENT_SESSION_ENV => id.empty? ? "chi" : id,
121
- SESSION_MODEL_ENV => model.empty? ? nil : model }
122
- if env[ModelProfile::MODEL_FROM_CLI_ENV]
123
- vars[ModelProfile::MODEL_ENV] = nil
124
- vars[ModelProfile::MODEL_FROM_CLI_ENV] = nil
125
- end
126
- vars
120
+ { Guardrails::ParentApprovals::PARENT_SESSION_ENV => id.empty? ? "chi" : id,
121
+ SESSION_MODEL_ENV => model.empty? ? nil : model }.merge(cli_model_unset(env))
122
+ end
123
+
124
+ # The unsets that keep a `chi --model X` worker's default from passing
125
+ # for the default of a chi run here (nothing when it wasn't the CLI's).
126
+ def cli_model_unset(env = ENV)
127
+ return {} unless env[ModelProfile::MODEL_FROM_CLI_ENV]
128
+
129
+ { ModelProfile::MODEL_ENV => nil, ModelProfile::MODEL_FROM_CLI_ENV => nil }
127
130
  end
128
131
 
129
132
  SESSION_MODEL_ENV = "SAMAGOTCHI_SESSION_MODEL"
@@ -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
@@ -129,6 +129,27 @@ module Samagotchi
129
129
  end.uniq
130
130
  end
131
131
 
132
+ # A task id as task_create makes them: no path parts.
133
+ TASK_ID = /\A[A-Za-z0-9_-]+\z/
134
+
135
+ # The command of the task +task_id+ under +root+ (a session's working
136
+ # directory) as its record has it, for a UI row's title; nil for no
137
+ # such task or an unreadable record. Reads the record only (no
138
+ # refresh), so it is cheap and never fails.
139
+ # @return [String, nil]
140
+ def command_of(task_id, root: Dir.pwd)
141
+ id = task_id.to_s.strip
142
+ return nil unless id.match?(TASK_ID)
143
+
144
+ path = File.join(root || Dir.pwd, TASKS_DIR, id, METADATA_FILENAME)
145
+ return nil unless File.file?(path)
146
+
147
+ command = JSON.parse(File.read(path))["command"]
148
+ command.is_a?(String) && !command.strip.empty? ? command : nil
149
+ rescue StandardError
150
+ nil
151
+ end
152
+
132
153
  def stopped_by_user?(record) = record["stop_reason"] == "stopped_by_user"
133
154
 
134
155
  def get_record(task_id)
@@ -188,6 +188,16 @@ module Samagotchi
188
188
  list
189
189
  end
190
190
 
191
+ # A copy of +messages+ without the note at its tail when that note is a
192
+ # retry nudge (anything else stays: an earlier turn's note is what says
193
+ # why that turn ended).
194
+ def without_trailing_nudge(messages)
195
+ list = Array(messages).dup
196
+ index = trailing_index(list)
197
+ list.delete_at(index) if index && retry_nudge?(list[index])
198
+ list
199
+ end
200
+
191
201
  # The index of the note at the tail of +list+ (behind context notes at
192
202
  # most), or nil.
193
203
  def trailing_index(list)
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.28.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.
@@ -39,11 +39,11 @@ import { ALL_SESSIONS_HASH, sessionHash, sessionIdFromHash } from "./route.js";
39
39
  import { allScopeHref, cardFolder, projectScopeHref, scopeDir } from "./scope.js";
40
40
  import { shouldFollowScroll, keepFollowing } from "./scroll.js";
41
41
  import { SHORT_WINDOW_QUERY, stripAutoHidden, stripColumns, stripShowParts } from "./strip.js";
42
- import { applySessionEvent, heldOrder, listedSessions, sortedByUpdated, waitingBadge, waitingFirst, waitingSearchText, withChildrenAfterParents } from "./sessions_list.js";
42
+ import { applySessionEvent, heldOrder, listedSessions, sortedByUpdated, stoppedBadge, waitingBadge, waitingFirst, waitingSearchText, withChildrenAfterParents } from "./sessions_list.js";
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";
@@ -328,8 +328,12 @@ function cardHtml(s, { deletable = false } = {}) {
328
328
  const ctxHtml = ctx ? `<span class="ctx" title="${escapeHtml(tokensTipText(s.tokens))}">${escapeHtml(ctx)}</span>` : "";
329
329
  const waiting = waitingBadge(s);
330
330
  const waitingHtml = waiting ? `<span class="attn attn-${waiting.kind}" title="${escapeHtml(waiting.title)}">${escapeHtml(waiting.text)}</span>` : "";
331
+ // A turn a hook stopped wears its own badge, unless the session waits on
332
+ // the user (the waiting badge wins: the stop is behind the need).
333
+ const stopped = waiting ? null : stoppedBadge(s);
334
+ const stoppedHtml = stopped ? `<span class="stopped-badge" title="${escapeHtml(stopped.title)}">${escapeHtml(stopped.text)}</span>` : "";
331
335
  const dotWord = waiting ? waiting.title : s.status;
332
- return `<div class="card${s.archived ? " archived" : ""}${waiting ? " waiting" : ""}" data-id="${s.id}" title="${escapeHtml(tip)}${memTitle ? ` · ${escapeHtml(memTitle)}` : ""}"><div class="preview">${preview}</div>${s.recap ? `<div class="recap" title="${escapeHtml(s.recap)}">${escapeHtml(s.recap)}</div>` : ""}<div class="meta"><span class="status dot ${escapeHtml(s.status)}${waiting ? " waiting" : ""}" title="${escapeHtml(dotWord)}" aria-label="${escapeHtml(dotWord)}"></span>${waitingHtml}${timeHtml}${ctxHtml}${folderHtml}${parentChipHtml(s)}${ownerBadgeHtml(s)}${archivedHtml}${s.used_memory_names && s.used_memory_names.length ? `<span title="${escapeHtml(s.used_memory_names.join(", "))}">mem ${s.used_memory_names.length}</span>` : ""}</div>${deletable ? `<button type="button" class="card-del" data-del="${escapeHtml(s.id)}" title="Delete session" aria-label="Delete session ${escapeHtml(s.short_id)}">\u2715</button>` : ""}</div>`;
336
+ return `<div class="card${s.archived ? " archived" : ""}${waiting ? " waiting" : ""}" data-id="${s.id}" title="${escapeHtml(tip)}${memTitle ? ` · ${escapeHtml(memTitle)}` : ""}"><div class="preview">${preview}</div>${s.recap ? `<div class="recap" title="${escapeHtml(s.recap)}">${escapeHtml(s.recap)}</div>` : ""}<div class="meta"><span class="status dot ${escapeHtml(s.status)}${waiting ? " waiting" : ""}" title="${escapeHtml(dotWord)}" aria-label="${escapeHtml(dotWord)}"></span>${waitingHtml}${stoppedHtml}${timeHtml}${ctxHtml}${folderHtml}${parentChipHtml(s)}${ownerBadgeHtml(s)}${archivedHtml}${s.used_memory_names && s.used_memory_names.length ? `<span title="${escapeHtml(s.used_memory_names.join(", "))}">mem ${s.used_memory_names.length}</span>` : ""}</div>${deletable ? `<button type="button" class="card-del" data-del="${escapeHtml(s.id)}" title="Delete session" aria-label="Delete session ${escapeHtml(s.short_id)}">\u2715</button>` : ""}</div>`;
333
337
  }
334
338
 
335
339
  // A delegated session's `↳ <parent>` chip; the parent's preview is the tooltip.
@@ -956,9 +960,11 @@ function appendChunk(text) {
956
960
  if (follow) historyEl.scrollTop = historyEl.scrollHeight;
957
961
  }
958
962
 
959
- 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 } = {}) {
960
966
  const div = document.createElement("div");
961
- div.className = "bubble cancel";
967
+ div.className = stopped ? STOPPED_LINE_CLASS : "bubble cancel";
962
968
  div.textContent = text;
963
969
  appendToHistory(div);
964
970
  if (!turnDom.holds?.()) historyEl.scrollTop = historyEl.scrollHeight;
@@ -976,7 +982,7 @@ function emptyAnswerEl(emptyAnswer) {
976
982
  // Everything a turn's end (completed, canceled, failed) closes: then its
977
983
  // end line (+endText+, the cancel or failed line), then the step cards that
978
984
  // left the collapsed block (card.js leavesBlock), where a reload puts them.
979
- function endTurnView(kind, endText = null, { emptyAnswer = null } = {}) {
985
+ function endTurnView(kind, endText = null, { emptyAnswer = null, stopped = false } = {}) {
980
986
  setTurnRunning(false);
981
987
  // Every ended turn has a record (completed, canceled, failed); a worker
982
988
  // gone mid-turn is re-rendered (resync), which counts again.
@@ -987,7 +993,7 @@ function endTurnView(kind, endText = null, { emptyAnswer = null } = {}) {
987
993
  const kept = turnDom.turnEnded({ kind, endNote, emptyAnswer: !!emptyAnswer }) || [];
988
994
  if (endNote && !endNote.isConnected) appendToHistory(endNote);
989
995
  finishLiveTurnTiming(null, { canceled: kind === "canceled" });
990
- if (endText) addEndBubble(endText);
996
+ if (endText) addEndBubble(endText, { stopped });
991
997
  if (kept.length) {
992
998
  kept.forEach((el) => appendToHistory(el));
993
999
  if (!turnDom.holds?.()) historyEl.scrollTop = historyEl.scrollHeight;
@@ -1483,6 +1489,10 @@ function clearTimingInterval() {
1483
1489
  }
1484
1490
 
1485
1491
  let liveTurnNumber = null;
1492
+ // The live line sits in the stage's status row, which already says the turn
1493
+ // runs ("running a tool"): it drops its own "running" there. It never moves
1494
+ // mid-turn (finishLiveTurnTiming rewrites it before any hand-off).
1495
+ let liveTurnTimingInStage = false;
1486
1496
 
1487
1497
  function startLiveTurnTiming(startedAt = new Date().toISOString(), turnId = null) {
1488
1498
  clearTimingInterval();
@@ -1495,7 +1505,9 @@ function startLiveTurnTiming(startedAt = new Date().toISOString(), turnId = null
1495
1505
  liveTurnTimingEl.className = "turn-timing live";
1496
1506
  // The stage's status row while it holds the turn: the line moves into the
1497
1507
  // history with it.
1498
- (turnDom.timingHost?.() || historyEl).appendChild(liveTurnTimingEl);
1508
+ const stageHost = turnDom.timingHost?.() || null;
1509
+ liveTurnTimingInStage = !!stageHost;
1510
+ (stageHost || historyEl).appendChild(liveTurnTimingEl);
1499
1511
  updateLiveTiming();
1500
1512
  timingInterval = setInterval(updateLiveTiming, 1000);
1501
1513
  }
@@ -1509,7 +1521,7 @@ function appendToHistory(el) {
1509
1521
  function updateLiveTiming() {
1510
1522
  const duration = elapsedSince(current.timing.activeTurn?.started_at);
1511
1523
  if (liveTurnTimingEl && duration !== null) {
1512
- liveTurnTimingEl.textContent = turnTimingText(liveTurnNumber, duration, { running: true }) + (liveTurnNote ? ` · ${liveTurnNote}` : "");
1524
+ liveTurnTimingEl.textContent = turnTimingText(liveTurnNumber, duration, { running: !liveTurnTimingInStage }) + (liveTurnNote ? ` · ${liveTurnNote}` : "");
1513
1525
  }
1514
1526
  updateInfoBar();
1515
1527
  }
@@ -1643,7 +1655,7 @@ function workerGone(id, { opened = true } = {}) {
1643
1655
  const stopped = stoppingId === id;
1644
1656
  const endText = workerGoneText({ turnRunning, stopped });
1645
1657
  if (endText) {
1646
- endTurnView("gone", endText);
1658
+ endTurnView("gone", endText, { stopped });
1647
1659
  if (!stopped) setLiveStatus("idle");
1648
1660
  }
1649
1661
  if (current.owner === "worker") current.owner = null;
@@ -1760,7 +1772,7 @@ const streamHandlers = {
1760
1772
  }
1761
1773
  },
1762
1774
  turn_canceled: (data) => {
1763
- endTurnView("canceled", cancelLineText(data.cancellation_reason || "", data.cancelled_by));
1775
+ endTurnView("canceled", cancelLineText(data.cancellation_reason || "", data.cancelled_by), { stopped: true });
1764
1776
  setLiveStatus("idle");
1765
1777
  refreshTimingFromSession();
1766
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,45 +122,57 @@ 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}
128
130
  .bubble.recap.stale{opacity:.7;border-left-style:dashed}
129
131
  .activity-body{margin-top:6px}
130
- .activity-row{display:flex;gap:8px;align-items:baseline;font-size:13px;color:var(--text-2);padding:5px 2px;border-top:1px solid var(--border)}
132
+ .activity-row{--row-gutter:22px;display:flex;gap:8px;align-items:baseline;font-size:13px;color:var(--text-2);padding:5px 2px;border-top:1px solid var(--border)}
131
133
  .activity-row:first-child{border-top:none}
132
- .activity-status{font-size:11px;min-width:52px;text-transform:uppercase;letter-spacing:.03em;font-weight:600}
133
- .activity-status.running{color:var(--warn)}
134
- .activity-status.ok{color:var(--ok)}
135
- .activity-status.error{color:var(--danger)}
136
- .activity-status.stopped{color:var(--warn-text);opacity:.8}
137
- .activity-status.blocked{color:var(--danger-text)}
134
+ /* A row's status is a mark in a 14 px slot, drawn from its class (no text: a copy or an annotate quote gets
135
+ no glyph; the word is its aria-label and title). The glyphs the stage already uses: done ✓, error and
136
+ blocked ✕, stopped ■, running its border spinner (still once the turn is over: a dead worker's row).
137
+ An exception also shows its word after the title (.activity-state). */
138
+ .activity-status{width:14px;text-align:center;font-size:12px;font-weight:700}
139
+ .activity-status.ok::before{content:"\2713";opacity:.85}
140
+ .activity-status.error::before,.activity-status.blocked::before{content:"\2715"}
141
+ .activity-status.stopped::before{content:"\25A0";font-size:10px}
142
+ .activity-status.running::before{content:"";display:inline-block;box-sizing:border-box;width:10px;height:10px;vertical-align:-1px;border:1.5px solid currentColor;border-right-color:transparent;border-radius:50%}
143
+ .turn-work:not(.done) .activity-status.running::before{animation:init-spin .8s linear infinite}
144
+ .activity-state{flex:none;white-space:nowrap;font-size:11px;text-transform:uppercase;letter-spacing:.03em;font-weight:600}
145
+ :is(.activity-status,.activity-state).running{color:var(--warn)}
146
+ :is(.activity-status,.activity-state).ok{color:var(--ok)}
147
+ :is(.activity-status,.activity-state).error{color:var(--danger)}
148
+ :is(.activity-status,.activity-state).stopped{color:var(--warn-text);opacity:.8}
149
+ :is(.activity-status,.activity-state).blocked{color:var(--danger-text)}
138
150
  .activity-tool{color:var(--accent);font-weight:600}
139
151
  /* The stop-task button on a running task_wait (task_stop.js): a small ghost
140
152
  button in the danger colours, at the row's end and in the stage's now slot. */
141
153
  button.task-stop{flex:none;margin-left:auto;padding:1px 8px;border:1px solid color-mix(in srgb,var(--danger) 45%,transparent);border-radius:6px;background:transparent;color:var(--danger-text);font-size:11.5px;line-height:18px;white-space:nowrap}
142
154
  button.task-stop:hover:not(:disabled){background:var(--danger-bg-dim);filter:none}
143
155
  .ts-now button.task-stop{align-self:center}
144
- .activity-params{color:var(--text-muted);word-break:break-all}
156
+ .activity-params{color:var(--text-muted);overflow-wrap:anywhere}
145
157
  .activity-duration{color:var(--text-faint);font-size:11px;font-variant-numeric:tabular-nums;white-space:nowrap}
146
- .activity-output{margin:4px 0 2px 60px;font-size:12px;color:var(--text-muted);white-space:pre-wrap;word-break:break-word;background:var(--bg);border-radius:6px;padding:6px 8px;max-height:160px;overflow:auto}
158
+ .activity-output{margin:4px 0 2px var(--row-gutter);font-size:12px;color:var(--text-muted);white-space:pre-wrap;word-break:break-word;background:var(--bg);border-radius:6px;padding:6px 8px;max-height:160px;overflow:auto}
147
159
  /* A row's status and tool name never wrap (a phone broke them letter by
148
160
  letter); the params take what is left. The output goes on its own line
149
161
  under the call instead of a column beside it: on a desktop it lines up
150
- under the tool name (the status is 52 px + the 8 px gap), on a phone it
151
- takes the full width and the params break inside the rest of the first
152
- line rather than drop to a line of their own. */
162
+ under the tool name (--row-gutter: the 14 px mark slot + the 8 px gap), on
163
+ a phone it takes the full width and the params break inside the rest of
164
+ the first line rather than drop to a line of their own. */
153
165
  .activity-row{flex-wrap:wrap}
154
166
  .activity-status,.activity-tool{flex-shrink:0;white-space:nowrap}
155
167
  .activity-params{min-width:0}
156
168
  .activity-output{flex:1 1 100%;min-width:0}
157
169
  /* An edit/write's change: a closed "diff +3 −1" under the output, lined up with it. */
158
- .activity-diff{flex:1 1 100%;min-width:0;margin:2px 0 2px 60px}
170
+ .activity-diff{flex:1 1 100%;min-width:0;margin:2px 0 2px var(--row-gutter)}
159
171
  .activity-diff>summary{cursor:pointer;font-size:12px;color:var(--text-muted);width:max-content;font-variant-numeric:tabular-nums}
160
172
  .activity-diff>summary:hover{color:var(--text-2)}
161
173
  .activity-diff[open]>summary{margin-bottom:4px}
162
174
  /* An execute/task_create's full command, above its output and lined up with it: as written, its own scroll. */
163
- .activity-row>.activity-command{flex:1 1 100%;min-width:0;margin:4px 0 2px 60px}
175
+ .activity-row>.activity-command{flex:1 1 100%;min-width:0;margin:4px 0 2px var(--row-gutter)}
164
176
  .activity-command pre{margin:0;max-height:160px;overflow:auto;white-space:pre-wrap;word-break:break-word;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px;line-height:1.45;color:var(--text-2);background:var(--bg-inset);border:1px solid var(--border);border-radius:6px;padding:6px 34px 6px 8px}
165
177
  .activity-command-cwd,.activity-command-cut{font-size:11px;color:var(--text-faint)}
166
178
  .activity-command-cwd{margin-bottom:2px}
@@ -218,12 +230,19 @@ button.task-stop:hover:not(:disabled){background:var(--danger-bg-dim);filter:non
218
230
  .turn-work:not([open]){padding:3px 10px;background:transparent;border-color:transparent;border-left-color:var(--accent);align-self:flex-start;border-radius:6px;transition:background .12s ease}
219
231
  .turn-work:not([open]):hover{background:var(--bg-panel)}
220
232
  .turn-work:not([open])>summary{font-size:12px;font-weight:500}
221
- .gen{margin-top:8px;padding-top:6px;border-top:1px solid var(--border)}
222
- .gen>summary{cursor:pointer;color:var(--text-muted);font-size:12px;font-weight:500;user-select:none;list-style:none;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
233
+ /* A step's body (thinking, narration, rows, cards) sits --step-indent right of its summary, so the thinking
234
+ chevron and the row marks share one column under the step's own chevron. The summary takes it back with a
235
+ negative margin: a child's own margin shorthand can't undo the padding. */
236
+ .gen{--step-indent:14px;margin-top:8px;padding-top:6px;padding-left:var(--step-indent);border-top:1px solid var(--border)}
237
+ .gen>summary{margin-left:calc(-1 * var(--step-indent));cursor:pointer;color:var(--text-muted);font-size:12px;font-weight:500;user-select:none;list-style:none;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
223
238
  .gen>summary::-webkit-details-marker{display:none}
224
239
  .gen>summary::before{content:"▸ ";color:var(--text-faint)}
225
240
  .gen[open]>summary::before{content:"▾ "}
226
- .gen.live{border-top-color:transparent;background:var(--accent-tint);border-radius:8px;padding:6px 9px 8px;margin-left:-9px;margin-right:-9px}
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}
243
+ /* A step with a failed call (status error): its chevron in the danger colour. */
244
+ .gen.has-failed>summary::before{color:var(--danger)}
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}
227
246
  .gen.live>summary{color:var(--accent);white-space:normal}
228
247
  /* The live step's marker: a pulsing accent dot in place of the chevron (the block's own left bar stays the only one). */
229
248
  .gen.live>summary::before{content:"";display:inline-block;width:7px;height:7px;margin:0 8px 1px 2px;border-radius:50%;background:var(--accent);vertical-align:middle;animation:step-pulse 1.6s ease-in-out infinite}
@@ -256,6 +275,7 @@ button.task-stop:hover:not(:disabled){background:var(--danger-bg-dim);filter:non
256
275
  .turn-work:not(:has(.gen~.gen,.thinking-body,.activity-body))>summary,.turn-work:not(:has(.gen~.gen,.thinking-body,.activity-body)) .gen>summary{display:none}
257
276
  .turn-work:not(:has(.gen~.gen,.thinking-body,.activity-body)) .gen{margin:0;padding:0;border:0;background:transparent}
258
277
  .turn-work:not(:has(.gen~.gen,.thinking-body,.activity-body)) .gen-text{margin:0;background:var(--bg-panel);border:1px solid var(--border);border-left:3px solid var(--accent);border-radius:12px;padding:12px 14px;opacity:.95}
278
+ @media(max-width:600px){.gen{--step-indent:8px}}
259
279
  /* A question card asks for input like the composer card, so it wears the same glass (the recipe above); only its warm tint is its own. */
260
280
  .bubble.question{border-left:3px solid var(--warn);background:color-mix(in srgb,var(--warn) 7%,var(--glass-bg));padding:10px 12px}
261
281
  /* Collapsed (resolved) question: one line, like the thinking and activity blocks.
@@ -432,7 +452,8 @@ body.has-session #history{padding-bottom:calc(var(--dock-h,160px) + 32px)}
432
452
  .chip .chip-remove{background:transparent;color:var(--text-3);padding:0 6px;min-height:0;font-size:14px}
433
453
  .thumbs{display:flex;flex-wrap:wrap;gap:6px;margin-top:6px}
434
454
  .thumb{max-height:120px;max-width:200px;border-radius:6px;border:1px solid var(--border-strong);cursor:zoom-in;object-fit:contain;background:var(--bg)}
435
- .activity-row .thumbs{flex-basis:100%}
455
+ .activity-row .thumbs{flex-basis:100%;margin-left:var(--row-gutter)}
456
+ @media(max-width:600px){.activity-row .thumbs{margin-left:0}}
436
457
  #lightbox{position:fixed;inset:0;z-index:50;background:var(--backdrop);display:flex;align-items:center;justify-content:center;cursor:zoom-out}
437
458
  #lightbox[hidden]{display:none}
438
459
  #lightbox img{max-width:96vw;max-height:94vh;border-radius:6px}
@@ -528,6 +549,7 @@ body.stage-view #dock,body.stage-view #dock:focus-within{background:transparent;
528
549
  .turn-stage[data-phase="no answer"] .ts-mark,.turn-stage[data-phase="no answer"] .ts-phase{color:var(--text-muted)}
529
550
  .turn-stage[data-phase="waiting for you"] .ts-mark{background:var(--warn)}
530
551
  .ts-phase{color:var(--accent);font-weight:600}
552
+ .ts-step:empty{display:none}
531
553
  .ts-timing{min-width:0;overflow:hidden;text-overflow:ellipsis}
532
554
  .ts-timing .turn-timing{margin:0;font-size:12px;color:var(--text-muted);display:inline}
533
555
  .ts-mini{min-width:0;overflow:hidden;text-overflow:ellipsis;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;color:var(--text-2)}
@@ -562,14 +584,13 @@ body.stage-view #dock,body.stage-view #dock:focus-within{background:transparent;
562
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}
563
585
  .ts-idle{flex:none;width:11px;height:11px;border:2px solid var(--border-strong);border-radius:50%}
564
586
  .ts-tool{flex:none;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px;font-weight:600;color:var(--accent)}
565
- .ts-tool.exec{color:var(--think-text)}
566
- .ts-tool.edit{color:var(--ok-text)}
567
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)}
568
588
  .ts-title.quiet{font-family:inherit;font-style:italic;color:var(--text-faint)}
569
589
  .ts-trail{height:18px;display:flex;gap:10px;overflow:hidden;font-size:12px;line-height:18px;color:var(--text-faint);white-space:nowrap}
570
590
  .ts-trail-item{flex:none;max-width:36ch;overflow:hidden;text-overflow:ellipsis}
571
- .ts-trail-item.error{color:var(--danger-text)}
591
+ .ts-trail-item:is(.error,.blocked){color:var(--danger-text)}
572
592
  .ts-trail-item.warn{color:var(--warn-text)}
593
+ .ts-trail-item.stopped{color:var(--warn-text);opacity:.8}
573
594
  .ts-trail-item.info,.ts-trail-item.steer{color:var(--accent)}
574
595
  .ts-trail-item.none{font-style:italic}
575
596
  /* A card waits: the tool row and trail give it their room. */
@@ -600,10 +621,14 @@ body.stage-view #dock,body.stage-view #dock:focus-within{background:transparent;
600
621
  .ts-ticks{display:flex;flex-wrap:nowrap;justify-content:flex-end;overflow:hidden;gap:2px;flex:0 1 auto;min-width:0}
601
622
  .ts-tick{flex:none}
602
623
  .ts-tick{width:5px;height:12px;border-radius:1.5px;background:var(--text-faint);opacity:.55;cursor:pointer}
603
- .ts-tick.exec{background:var(--think);opacity:.85}
604
- .ts-tick.edit{background:var(--ok);opacity:.85}
605
- .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}
606
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}
607
632
  .ts-tick.running{background:var(--warn);opacity:1;animation:ts-blink 1s ease-in-out infinite}
608
633
  @keyframes ts-blink{50%{opacity:.35}}
609
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}
@@ -617,7 +642,7 @@ body.stage-view #dock,body.stage-view #dock:focus-within{background:transparent;
617
642
  .ts-defer{display:none}
618
643
  .ts-trail-item{max-width:24ch}
619
644
  }
620
- @media (prefers-reduced-motion:reduce){.turn-stage.enter,.ts-arrive,.ts-headline.fresh,.ts-tick.running,.ts-mark{animation:none}.turn-stage.leaving{transition:none}}
645
+ @media (prefers-reduced-motion:reduce){.turn-stage.enter,.ts-arrive,.ts-headline.fresh,.ts-tick.running,.ts-mark,.turn-work:not(.done) .activity-status.running::before{animation:none}.turn-stage.leaving{transition:none}}
621
646
  #infoBar{display:flex;padding:8px 14px 10px;border-top:1px solid var(--glass-border);background:transparent;border-radius:0 0 16px 16px;color:var(--text-2);gap:10px;align-items:flex-start;flex-wrap:wrap;flex-shrink:0;transition:opacity 280ms ease}
622
647
  #infoBar.visible{display:flex}
623
648
  #infoBar .info{flex:1;min-width:0;font-size:13px;line-height:1.4;word-break:break-word;display:flex;flex-direction:column;gap:2px}
@@ -809,6 +834,7 @@ body.all-view #center, body.all-view #topStrip{display:none}
809
834
  @keyframes attn-pulse{0%,100%{box-shadow:0 0 0 0 color-mix(in srgb,var(--warn) 60%,transparent)}50%{box-shadow:0 0 0 4px color-mix(in srgb,var(--warn) 0%,transparent)}}
810
835
  @media (prefers-reduced-motion:reduce){.card .dot.running,.card .dot.waiting{animation:none}}
811
836
  .card .attn{font-size:11px;font-weight:600;padding:1px 7px;border-radius:999px;white-space:nowrap;flex-shrink:0;background:var(--warn-bg);color:var(--warn-text);border:1px solid var(--warn-border)}
837
+ .card .stopped-badge{font-size:11px;font-weight:600;padding:1px 7px;border-radius:999px;white-space:nowrap;flex-shrink:0;background:var(--warn);color:var(--bg)}
812
838
  .status.running{background:var(--ok-bg);color:var(--ok-text)}
813
839
  .status.idle{background:var(--bg-raised)}
814
840
  .status.completed{background:var(--accent-bg);color:var(--accent)}
@@ -89,12 +89,29 @@ export function waitingBadge(summary) {
89
89
  return { kind: waiting.reason, ...WAITING_BADGES[waiting.reason] };
90
90
  }
91
91
 
92
- // What the all view's search matches for a session that waits on the user:
93
- // "waiting" and its badge's word ("question", "approval", "needs you");
94
- // "" for one that doesn't.
92
+ // A card's badge for a last turn a hook stopped (the same marker as
93
+ // `chi sessions list`'s "[looped]" / "[stopped by X]"): "looped" for
94
+ // loop-guard, "stopped by <name>" for any other hook; null when the last
95
+ // turn ended otherwise.
96
+ // @return null | {text, title}
97
+ export function stoppedBadge(summary) {
98
+ const by = summary?.last_turn?.stopped_by;
99
+ if (!by) return null;
100
+ return by === "loop-guard"
101
+ ? { text: "looped", title: "loop-guard stopped the last turn" }
102
+ : { text: `stopped by ${by}`, title: `${by} stopped the last turn` };
103
+ }
104
+
105
+ // What the all view's search matches for a session: its waiting badge's words
106
+ // ("waiting", "question", "approval", …) and/or its stopped badge's words
107
+ // ("looped", "stopped by <name>"); "" for one with neither.
95
108
  export function waitingSearchText(summary) {
109
+ const parts = [];
96
110
  const badge = waitingBadge(summary);
97
- return badge ? `waiting ${badge.text}` : "";
111
+ if (badge) parts.push(`waiting ${badge.text}`);
112
+ const stopped = stoppedBadge(summary);
113
+ if (stopped) parts.push(stopped.text);
114
+ return parts.join(" ");
98
115
  }
99
116
 
100
117
  // The sessions that wait on the user (waitingOn) first, the rest after,
@@ -11,6 +11,7 @@ const READ_LIKE = new Set(["read", "memory_read", "web_fetch", "task_get", "task
11
11
  const EDITS = new Set(["edit", "write", "memory_write"]);
12
12
  const EXECS = new Set(["execute", "task_create", "task_wait", "task_stop", "delegate", "delegate_result"]);
13
13
  const FILE_TOOLS = new Set(["read", "edit", "write"]);
14
+ const TASK_TOOLS = new Set(["task_wait", "task_get", "task_stop"]);
14
15
  const TRAIL = 3;
15
16
  // The hand-off waits this long after the stage was last in use.
16
17
  export const QUIET_MS = 1500;
@@ -75,14 +76,30 @@ export function toolKind(name) {
75
76
  return "other";
76
77
  }
77
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
+
78
93
  function rowTitle(row) {
79
94
  return row.title || row.params || "";
80
95
  }
81
96
 
82
97
  // A slot's full command (the row's view), for its hover; none for a tool
83
- // without one.
98
+ // without one. A task tool titled by its task's command (the server's
99
+ // title) hovers its params instead: the id line it replaced.
84
100
  function withCommand(slot, row) {
85
- return row.view?.command ? { ...slot, command: row.view.command } : slot;
101
+ if (row.view?.command) return { ...slot, command: row.view.command };
102
+ return TASK_TOOLS.has(row.tool) && row.title && row.params ? { ...slot, command: row.params } : slot;
86
103
  }
87
104
 
88
105
  // A running task_wait's slot: the task it waits on and the row's key, for
@@ -113,6 +130,9 @@ const NO_ANSWER = "no answer";
113
130
  // else "…";
114
131
  // tool the running call ({name, title, kind}, and command: its full
115
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);
116
136
  // trail the last three finished calls, newest last (a file's title
117
137
  // without its dir; command as for tool);
118
138
  // ticks one per call: {kind, status, step (the gen's index)}.
@@ -143,6 +163,7 @@ export function liveSlots(turn, { narration = "", thinking = "", pending = false
143
163
  phase,
144
164
  step: ended ? gens.filter((g) => !g.answer || g.thinking || g.tools.length).length : gens.length,
145
165
  headline,
166
+ quiet: ended ? "" : current?.text && !current.textDone ? "writing…" : "thinking…",
146
167
  tool: running ? withTask(withCommand({ name: toolName(running), title: rowTitle(running), kind: toolKind(running.tool) }, running), running) : null,
147
168
  trail,
148
169
  ticks: rows.map(({ row, step }) => ({ kind: toolKind(row.tool), status: row.status, step })),