samagotchi 0.13.0 → 0.14.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 +55 -1
  3. data/bin/chi +4 -7
  4. data/docs/configuration.md +50 -7
  5. data/docs/guardrails.md +2 -1
  6. data/docs/plugins.md +27 -12
  7. data/docs/sessions.md +1 -1
  8. data/docs/sub-agent.md +1 -1
  9. data/lib/samagotchi/bridge/card_store.rb +94 -6
  10. data/lib/samagotchi/bridge.rb +3 -1
  11. data/lib/samagotchi/bundles/check-in/manifest.yml +2 -2
  12. data/lib/samagotchi/bundles/check-in/plugin.rb +68 -5
  13. data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
  14. data/lib/samagotchi/bundles/mcp/plugin.rb +166 -34
  15. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  16. data/lib/samagotchi/config.rb +31 -6
  17. data/lib/samagotchi/context_window.rb +30 -5
  18. data/lib/samagotchi/engine.rb +14 -3
  19. data/lib/samagotchi/guardrails/parent_approvals.rb +5 -2
  20. data/lib/samagotchi/host_registry.rb +31 -6
  21. data/lib/samagotchi/kernel_loop.rb +1 -1
  22. data/lib/samagotchi/llm/chat_loop.rb +5 -3
  23. data/lib/samagotchi/llm/turn_settings.rb +7 -5
  24. data/lib/samagotchi/session_manager.rb +6 -0
  25. data/lib/samagotchi/system_prompt.rb +1 -1
  26. data/lib/samagotchi/terminal_ui/attached_loop.rb +37 -10
  27. data/lib/samagotchi/terminal_ui.rb +16 -3
  28. data/lib/samagotchi/tools/delegate_wait.rb +17 -10
  29. data/lib/samagotchi/tools/memory.rb +3 -3
  30. data/lib/samagotchi/utf8_default.rb +29 -0
  31. data/lib/samagotchi/version.rb +1 -1
  32. data/lib/samagotchi/web/app.rb +18 -7
  33. data/lib/samagotchi/web/public/activity.js +6 -1
  34. data/lib/samagotchi/web/public/app.js +39 -6
  35. data/lib/samagotchi/web/public/index.html +8 -1
  36. data/lib/samagotchi/web/public/notify.js +16 -3
  37. data/lib/samagotchi/web/public/question_cards.js +3 -1
  38. data/lib/samagotchi/web/public/sessions_list.js +37 -0
  39. data/lib/samagotchi/web/public/turn_view.js +6 -9
  40. metadata +2 -1
@@ -8,12 +8,14 @@ module Samagotchi
8
8
  # ChatLoop) and ToolRunner: the turn's VisionContext (nil sends no
9
9
  # images, placeholders instead), its request parameters
10
10
  # (SamplingSettings.for; empty sends none), its thinking level
11
- # (Thinking.resolve) and the bare model id the turn runs on (every
12
- # debug dump is tagged with it). The Engine sets them once per turn,
13
- # in Engine#generate, after the model's host is synced.
14
- TurnSettings = Data.define(:vision, :sampling, :thinking, :model_name) do
11
+ # (Thinking.resolve), the bare model id the turn runs on (every debug
12
+ # dump is tagged with it) and the model's or host's configured context
13
+ # window (ContextWindow.setting, nil for none). The Engine sets them
14
+ # once per turn, in Engine#generate, after the model's host is synced.
15
+ TurnSettings = Data.define(:vision, :sampling, :thinking, :model_name, :window_setting) do
15
16
  # No vision, no sampling, the default thinking, no model name.
16
- def self.none = new(vision: nil, sampling: {}.freeze, thinking: Thinking::DEFAULT, model_name: nil)
17
+ def self.none = new(vision: nil, sampling: {}.freeze, thinking: Thinking::DEFAULT, model_name: nil,
18
+ window_setting: nil)
17
19
  end
18
20
  end
19
21
  end
@@ -12,6 +12,7 @@ require_relative "config"
12
12
  require_relative "session"
13
13
  require_relative "session_inbox"
14
14
  require_relative "session_metrics"
15
+ require_relative "utf8_default"
15
16
  require_relative "turn_note"
16
17
  require_relative "owner_lock"
17
18
  require_relative "bridge_client"
@@ -552,6 +553,9 @@ module Samagotchi
552
553
  File.directory?(path) && (!EMPTY_DIRS.include?(name) || Dir.children(path).empty?)
553
554
  elsif name == "analytics.json"
554
555
  Array(JSON.parse(File.read(path))["turn_records"]).empty?
556
+ # Cards and notices from before any turn (Bridge::CardStore).
557
+ elsif name == "cards.json"
558
+ JSON.parse(File.read(path))["turns"].to_i.zero?
555
559
  else
556
560
  EMPTY_SKELETON_FILES.include?(name)
557
561
  end
@@ -631,6 +635,8 @@ module Samagotchi
631
635
  # @return [Symbol] :idle_exit or :exit_requested
632
636
  def self.run_session_loop(session_id, state_dir: nil, owner_wait: OwnerLock::DEFAULT_WAIT,
633
637
  idle_exit_minutes: nil, poll_interval: nil)
638
+ # A worker inherits chi's locale (LC_ALL=C too): read files as UTF-8.
639
+ Utf8Default.apply!
634
640
  sd = state_dir || Session.default_state_dir
635
641
  # The spawner passed the file and level through ENV; a worker's stderr
636
642
  # is /dev/null, so warnings only reach the file.
@@ -335,7 +335,7 @@ module Samagotchi
335
335
  path = agent_description_path
336
336
  return nil unless path
337
337
 
338
- content = File.read(path).strip
338
+ content = File.read(path, encoding: "UTF-8").strip
339
339
  return nil if content.empty?
340
340
 
341
341
  "Project specific description:\n#{content}"
@@ -105,6 +105,7 @@ module Samagotchi
105
105
  @running = false
106
106
  @joined_mid_turn = false
107
107
  @attached = false
108
+ @early_lines = []
108
109
  @question = nil
109
110
  @answered_ids = Set.new
110
111
  @reader = nil
@@ -162,16 +163,17 @@ module Samagotchi
162
163
  when :event
163
164
  ended = safely_handle(payload)
164
165
  return ended if %i[closed failed unanswered].include?(ended)
166
+
167
+ ended = take_early_lines
168
+ return ended if ended
165
169
  return end_of_input if @input_ended && !own_turns_pending?
166
170
  when :line
167
- if payload.nil? && @wait_at_eof && own_turns_pending?
168
- # The pipe's end: this run's turns first. An open question
169
- # has nothing left to answer it.
170
- return unanswered_question(@question_pending) if @question
171
-
172
- next @input_ended = true
173
- end
174
- return end_of_input if submit(payload) == :detach
171
+ # A pipe is read at once: its lines wait for the joining
172
+ # snapshot, which may bring the question they answer.
173
+ next @early_lines << payload if @wait_at_eof && !@attached
174
+
175
+ ended = take_line(payload)
176
+ return ended if ended
175
177
  when :interrupt then return :detached if interrupt(payload) == :detach
176
178
  end
177
179
  end
@@ -187,6 +189,30 @@ module Samagotchi
187
189
  stream&.close
188
190
  end
189
191
 
192
+ # One input line (nil: the input ended).
193
+ # @return [Symbol, nil] how the run ends, or nil to go on
194
+ def take_line(line)
195
+ if line.nil? && @wait_at_eof && own_turns_pending?
196
+ # The pipe's end: this run's turns first. An open question
197
+ # has nothing left to answer it.
198
+ return unanswered_question(@question_pending) if @question
199
+
200
+ @input_ended = true
201
+ return nil
202
+ end
203
+ end_of_input if submit(line) == :detach
204
+ end
205
+
206
+ # The lines read before the snapshot came, once it has.
207
+ # @return [Symbol, nil] how the run ends, or nil to go on
208
+ def take_early_lines
209
+ until !@attached || @early_lines.empty?
210
+ ended = take_line(@early_lines.shift)
211
+ return ended if ended
212
+ end
213
+ nil
214
+ end
215
+
190
216
  # Render one Bridge event (string keys).
191
217
  # @return [Symbol, nil] :closed when the stream ended, :failed when the
192
218
  # launch's first command didn't go through
@@ -948,7 +974,8 @@ module Samagotchi
948
974
  # The snapshot's cards and between-turns notices (Bridge CardStore):
949
975
  # on join the ones since the last turn (and the running turn's), as
950
976
  # the join header shows the last exchange; on a resync only cards not
951
- # shown here yet. A turn's own rows (notices, retries) are left out:
977
+ # shown here yet, and not the ones an earlier worker saved (a resync
978
+ # onto a new worker: earlier turns, not news). A turn's own rows (notices, retries) are left out:
952
979
  # the running turn's come with its parts, and the join shows no older
953
980
  # turn's steps.
954
981
  def render_snapshot_cards(entries, joining:)
@@ -959,7 +986,7 @@ module Samagotchi
959
986
 
960
987
  @screen.commit(EventRenderer.hook_notice_line(entry)) if joining && entry[:turns_since].to_i.zero?
961
988
  when "card"
962
- next unless joining ? entry[:turns_since].to_i.zero? : !@renderer.card_shown?(entry[:id])
989
+ next unless joining ? entry[:turns_since].to_i.zero? : !entry[:earlier] && !@renderer.card_shown?(entry[:id])
963
990
 
964
991
  @renderer.render_card(entry, updated: entry[:updated] == true)
965
992
  end
@@ -1112,6 +1112,7 @@ module Samagotchi
1112
1112
  # was cancelled).
1113
1113
  def answer_question_widget(prompt)
1114
1114
  loop do
1115
+ @choice_echoed = false
1115
1116
  raw = begin
1116
1117
  yield
1117
1118
  rescue Interrupt
@@ -1133,7 +1134,7 @@ module Samagotchi
1133
1134
  @surface.commit(answer.note) if answer.note
1134
1135
  unless answer.ok?
1135
1136
  # The read left no echo: the line shows above its error.
1136
- @surface.commit("#{paint(QUESTION_PROMPT, 33)}#{raw}")
1137
+ echo_choice(raw)
1137
1138
  @surface.commit(answer.error)
1138
1139
  next
1139
1140
  end
@@ -1146,7 +1147,7 @@ module Samagotchi
1146
1147
  rescue QuestionDesk::Refused => e
1147
1148
  # A parent's allow guardrails.parent_approvals doesn't let through:
1148
1149
  # the question stays open for a deny (or the end of the input).
1149
- @surface.commit("#{paint(QUESTION_PROMPT, 33)}#{raw}")
1150
+ echo_choice(raw)
1150
1151
  @surface.commit(Guardrails::ParentApprovals.message(e.reason, typed: true))
1151
1152
  next
1152
1153
  rescue ArgumentError => e
@@ -1173,7 +1174,19 @@ module Samagotchi
1173
1174
  # A ? read of its own, off a terminal (specs, pipes): $stdin.gets.
1174
1175
  def read_choice_line(question_prompt)
1175
1176
  @surface.set_slot(:editor, [question_prompt])
1176
- $stdin.gets
1177
+ line = $stdin.gets
1178
+ # A pipe doesn't echo: the line completes the ? prompt here, once,
1179
+ # and the widget doesn't echo it again (#echo_choice).
1180
+ unless $stdin.tty?
1181
+ @surface.commit(line.to_s.chomp)
1182
+ @choice_echoed = true
1183
+ end
1184
+ line
1185
+ end
1186
+
1187
+ # The answer line above its error, unless its read echoed it already.
1188
+ def echo_choice(raw)
1189
+ @surface.commit("#{paint(QUESTION_PROMPT, 33)}#{raw}") unless @choice_echoed
1177
1190
  end
1178
1191
 
1179
1192
  # The next line submitted at the open prompt (?), nil for Ctrl-D
@@ -9,7 +9,8 @@ require_relative "peers"
9
9
  module Samagotchi
10
10
  module Tools
11
11
  # Waiting for a delegated session's next reply, shared by delegate and
12
- # delegate_result: ReplyWait in the tools' words, with a cursor per
12
+ # delegate_result: ReplyWait in the tools' words, with the status words
13
+ # `chi send --wait` and `chi answer` use (ParentReport.status), with a cursor per
13
14
  # child (the newest reply this parent was already given) and the child
14
15
  # as it was before the message went in (ReplyWait's baseline), so a turn
15
16
  # that ends before the wait's first look (a fast failure) still ends it.
@@ -56,22 +57,23 @@ module Samagotchi
56
57
  owner_grace: owner_grace)
57
58
  # The turn sent to is handed over: a later wait looks for a later one.
58
59
  baselines.delete(key) if %i[done no_reply].include?(wait.status)
60
+ status = ParentReport.status(wait)
59
61
  case wait.status
60
62
  when :done
61
63
  seen[key] = wait.file
62
- reply_result(child_id, wait.text)
64
+ reply_result(child_id, status, wait.text)
63
65
  when :error
64
- result(child_id, "error", "the child's worker failed: #{wait.text}; its session shows what happened")
66
+ result(child_id, status, "the child's worker failed: #{wait.text}; its session shows what happened")
65
67
  when :stopped
66
- result(child_id, "stopped", "the child was stopped (chi sessions stop); delegate with session: #{child_id} starts it again with a message")
68
+ result(child_id, status, "the child was stopped (chi sessions stop); delegate with session: #{child_id} starts it again with a message")
67
69
  when :waiting_for_answer
68
- result(child_id, "waiting_for_answer", waiting_text(child_id, wait.question))
70
+ result(child_id, status, waiting_text(child_id, wait.question))
69
71
  when :canceled
70
- result(child_id, "canceled", "wait canceled; the child keeps running; delegate_result #{child_id} waits again")
72
+ result(child_id, status, "wait canceled; the child keeps running; delegate_result #{child_id} waits again")
71
73
  when :no_reply
72
- result(child_id, "no_reply", "the child's turn ended without a reply (canceled, failed or empty); its session shows what happened")
74
+ result(child_id, status, "#{no_reply_text(wait)}; its session shows what happened")
73
75
  when :worker_gone
74
- result(child_id, "worker_gone",
76
+ result(child_id, status,
75
77
  "the child's worker is gone (it stopped or crashed); delegate with session: #{child_id} starts it again with a message")
76
78
  else
77
79
  timeout_result(child_id, timeout)
@@ -95,8 +97,13 @@ module Samagotchi
95
97
  baselines[[parent_id, child.id]] = ReplyWait.baseline_of(child, question_id: nil)
96
98
  end
97
99
 
98
- def reply_result(child_id, text)
99
- "session: #{child_id}\nstatus: done\n---\n#{cut(text)}"
100
+ def reply_result(child_id, status, text)
101
+ "session: #{child_id}\nstatus: #{status}\n---\n#{cut(text)}"
102
+ end
103
+
104
+ # ParentReport's line for a turn that left no reply, about the child.
105
+ def no_reply_text(wait)
106
+ ParentReport.no_reply_line(wait).sub(/\Athe turn/, "the child's turn")
100
107
  end
101
108
 
102
109
  def result(child_id, status, text)
@@ -59,13 +59,13 @@ module Samagotchi
59
59
  resolved_scopes.each do |resolved_scope|
60
60
  path = memory_path(name, resolved_scope)
61
61
  if File.exist?(path)
62
- body = File.read(path)
62
+ body = File.read(path, encoding: "UTF-8")
63
63
  # Append model-specific overlay if key is provided and overlay exists.
64
64
  [model_key, fallback_model_key].compact.each do |key|
65
65
  overlay_path = ModelOverlay.overlay_path_for(name, key, resolved_scope)
66
66
  next unless overlay_path && File.exist?(overlay_path)
67
67
 
68
- body += SEPARATOR + "Model-specific guidance (#{key}):\n" + File.read(overlay_path)
68
+ body += SEPARATOR + "Model-specific guidance (#{key}):\n" + File.read(overlay_path, encoding: "UTF-8")
69
69
  break
70
70
  end
71
71
  results << body
@@ -114,7 +114,7 @@ module Samagotchi
114
114
  def self.scoped_index(scope)
115
115
  dir = memories_dir(scope)
116
116
  index_path = File.join(dir, "#{MEMORY_INDEX}.md")
117
- return File.read(index_path) if File.exist?(index_path)
117
+ return File.read(index_path, encoding: "UTF-8") if File.exist?(index_path)
118
118
 
119
119
  files = Dir.glob(File.join(dir, "*.md")).sort
120
120
  return "No memories stored yet." if files.empty?
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ # chi's files (memories, AGENT.md, sessions, config.yml) are UTF-8,
5
+ # whatever the locale. Under LC_ALL=C (or with no locale at all: an app
6
+ # started from Finder or launchd, `env -i`) Ruby reads files as US-ASCII,
7
+ # and a UTF-8 memory index then breaks the system prompt
8
+ # (Encoding::CompatibilityError). bin/chi and every worker call #apply!
9
+ # first.
10
+ module Utf8Default
11
+ LOCALE_KEYS = %w[LC_ALL LC_CTYPE LANG].freeze
12
+
13
+ # Files read as UTF-8 when the locale says US-ASCII. With no locale set
14
+ # at all, LANG too, so the workers and tools chi spawns get one; a
15
+ # locale the user set (LC_ALL=C) is left to them.
16
+ def self.apply!(env = ENV)
17
+ return unless Encoding.default_external == Encoding::US_ASCII
18
+
19
+ env["LANG"] = "en_US.UTF-8" if LOCALE_KEYS.none? { |key| env[key] }
20
+ verbose = $VERBOSE
21
+ begin
22
+ $VERBOSE = nil # Encoding.default_external= warns
23
+ Encoding.default_external = Encoding::UTF_8
24
+ ensure
25
+ $VERBOSE = verbose
26
+ end
27
+ end
28
+ end
29
+ end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.13.0"
4
+ VERSION = "0.14.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
@@ -11,6 +11,7 @@ require "rack/request"
11
11
 
12
12
  require_relative "../bridge_client"
13
13
  require_relative "../bridge/bounded_queue"
14
+ require_relative "../bridge/card_store"
14
15
  require_relative "../session"
15
16
  require_relative "../session_manager"
16
17
  require_relative "../session_commands"
@@ -533,7 +534,7 @@ module Samagotchi
533
534
  # ?cards=1: the page's re-read when a card arrives, for its rendered
534
535
  # body (the stream carries the card's plain text only).
535
536
  if req.params["cards"] == "1"
536
- return json_response(200, { cards: cards_for_display(turn_snapshot) })
537
+ return json_response(200, { cards: cards_for_display(turn_snapshot, id) })
537
538
  end
538
539
  snapshot = live && live["session_state_snapshot"]
539
540
  last_event_seq = snapshot ? snapshot["event_seq"] : bridge_event_seq(id)
@@ -584,7 +585,7 @@ module Samagotchi
584
585
  plugin_warning: turn_snapshot && turn_snapshot["plugin_warning"],
585
586
  # Plugins' slow setup still running (chi.init): the page's init row.
586
587
  init_tasks: turn_snapshot ? Array(turn_snapshot["init_tasks"]) : [],
587
- cards: cards_for_display(turn_snapshot),
588
+ cards: cards_for_display(turn_snapshot, id),
588
589
  # The composer's / autocomplete; the built-ins until a worker
589
590
  # names its plugins' too.
590
591
  commands: turn_snapshot&.fetch("commands", nil) || SessionCommands.builtin_registry.listing,
@@ -601,11 +602,14 @@ module Samagotchi
601
602
  end
602
603
 
603
604
  # The worker's last cards and between-turns notices (Bridge
604
- # snapshot[:cards]), a card's body as body_html: rendered markdown
605
- # when the renderer is on, else the escaped text in a <pre>.
606
- # @return [Array<Hash>] [] without a live worker
607
- def cards_for_display(turn_snapshot)
608
- Array(turn_snapshot && turn_snapshot["cards"]).filter_map do |card|
605
+ # snapshot[:cards]), else (no live worker) the ones the last worker
606
+ # saved in the session's folder (CardStore.saved), a card's body as
607
+ # body_html: rendered markdown when the renderer is on, else the
608
+ # escaped text in a <pre>.
609
+ # @return [Array<Hash>]
610
+ def cards_for_display(turn_snapshot, id)
611
+ cards = turn_snapshot ? turn_snapshot["cards"] : saved_cards(id)
612
+ Array(cards).filter_map do |card|
609
613
  next unless card.is_a?(Hash)
610
614
  next card unless card["type"].to_s == "card"
611
615
 
@@ -613,6 +617,13 @@ module Samagotchi
613
617
  end
614
618
  end
615
619
 
620
+ # As JSON has them (string keys and values), like a snapshot's.
621
+ def saved_cards(id)
622
+ JSON.parse(JSON.generate(Samagotchi::Bridge::CardStore.saved(Session.session_dir(id, state_dir: default_state_dir))))
623
+ rescue StandardError
624
+ []
625
+ end
626
+
616
627
  def card_body_html(body)
617
628
  return "" if body.strip.empty?
618
629
 
@@ -12,6 +12,9 @@ export const ROW_STATUS = Object.freeze({
12
12
  ERROR: "error",
13
13
  // A task_wait the user's Stop ended (the task itself runs on).
14
14
  STOPPED: "stopped",
15
+ // A call a guardrail denied (a hook, the guardrails bundle, a parent's
16
+ // or the user's Deny): it never ran, and it isn't a failed call either.
17
+ BLOCKED: "blocked",
15
18
  });
16
19
 
17
20
  // What a row shows for its tool: a plugin tool's label ("chrome:
@@ -101,9 +104,11 @@ export function addCompleted(model, event = {}) {
101
104
  return row;
102
105
  }
103
106
 
104
- // A completed call's row status from the server's: error, stopped, else ok.
107
+ // A completed call's row status from the server's: error, stopped,
108
+ // blocked, else ok.
105
109
  export function completedStatus(status) {
106
110
  if (status === "error") return ROW_STATUS.ERROR;
107
111
  if (status === "stopped") return ROW_STATUS.STOPPED;
112
+ if (status === "blocked") return ROW_STATUS.BLOCKED;
108
113
  return ROW_STATUS.OK;
109
114
  }
@@ -38,7 +38,7 @@ import { ALL_SESSIONS_HASH, sessionHash, sessionIdFromHash } from "./route.js";
38
38
  import { allScopeHref, cardFolder, projectScopeHref, scopeDir } from "./scope.js";
39
39
  import { shouldFollowScroll, keepFollowing } from "./scroll.js";
40
40
  import { stripColumns, stripShowParts } from "./strip.js";
41
- import { applySessionEvent, listedSessions, sortedByUpdated, withChildrenAfterParents } from "./sessions_list.js";
41
+ import { applySessionEvent, heldOrder, listedSessions, sortedByUpdated, waitingBadge, waitingFirst, withChildrenAfterParents } from "./sessions_list.js";
42
42
  import { createNotifications } from "./notifications.js";
43
43
  import {
44
44
  appendAboveLiveTiming, cancelLineText, elapsedSince, formatDuration, normalizeTiming, turnTimingText,
@@ -267,7 +267,9 @@ function filteredSessions(list) {
267
267
  }
268
268
 
269
269
  // A card: the first message as its title, then status, how long ago, owner
270
- // and memories. The id is in the tooltip (and the search) only.
270
+ // and memories. The id is in the tooltip (and the search) only. A session
271
+ // that waits on the user (a question, an approval, a card with actions)
272
+ // wears the warning colour and says what for.
271
273
  // deletable: the all-sessions view's cards get a delete button.
272
274
  function cardHtml(s, { deletable = false } = {}) {
273
275
  const ts = s.updated_at || s.created_at || "";
@@ -281,7 +283,10 @@ function cardHtml(s, { deletable = false } = {}) {
281
283
  const archivedHtml = s.archived ? `<span class="archived-badge" title="Archived: hidden from the lists, kept for good">archived</span>` : "";
282
284
  const ctx = cardCtxText(s.ctx_pct);
283
285
  const ctxHtml = ctx ? `<span class="ctx" title="Context used after the last turn">${escapeHtml(ctx)}</span>` : "";
284
- return `<div class="card${s.archived ? " archived" : ""}" 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)}" title="${escapeHtml(s.status)}" aria-label="${escapeHtml(s.status)}"></span>${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>`;
286
+ const waiting = waitingBadge(s);
287
+ const waitingHtml = waiting ? `<span class="attn ${waiting.kind}" title="${escapeHtml(waiting.title)}">${escapeHtml(waiting.text)}</span>` : "";
288
+ const dotWord = waiting ? waiting.title : s.status;
289
+ 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>`;
285
290
  }
286
291
 
287
292
  // A delegated session's `↳ <parent>` chip; the parent's preview is the tooltip.
@@ -304,9 +309,30 @@ function fitStripColumns() {
304
309
  topStripEl.style.setProperty("--strip-cols", stripCols);
305
310
  }
306
311
 
312
+ // The order a list draws its cards in: the sessions that wait on the user
313
+ // first (waitingFirst), back to their place once answered. Never under the
314
+ // pointer, though: while it is over the list the order last drawn holds,
315
+ // and its leaving draws the list again.
316
+ const drawnOrder = new Map(); // list element → ids in the order last drawn
317
+ const heldLists = new Set(); // lists drawn in a held order
318
+ const canHover = globalThis.matchMedia?.("(hover: hover)").matches ?? false;
319
+ function listOrder(el, list) {
320
+ const next = waitingFirst(list);
321
+ const held = canHover && el.matches(":hover") ? drawnOrder.get(el) : null;
322
+ const ordered = held ? heldOrder(held, next) : next;
323
+ if (held && ordered.some((s, i) => s !== next[i])) heldLists.add(el);
324
+ else heldLists.delete(el);
325
+ drawnOrder.set(el, ordered.map((s) => s.id));
326
+ return ordered;
327
+ }
328
+
329
+ function stripSessions() {
330
+ return listOrder(topStripEl, shownSessions()).slice(0, stripCols);
331
+ }
332
+
307
333
  function render() {
308
334
  fitStripColumns();
309
- renderTop(shownSessions().slice(0, stripCols));
335
+ renderTop(stripSessions());
310
336
  if (allViewOpen()) renderAll();
311
337
  }
312
338
 
@@ -316,7 +342,7 @@ window.addEventListener("resize", () => {
316
342
  stripResizeTimer = setTimeout(() => {
317
343
  const before = stripCols;
318
344
  fitStripColumns();
319
- if (stripCols !== before && allSessions.length) renderTop(shownSessions().slice(0, stripCols));
345
+ if (stripCols !== before && allSessions.length) renderTop(stripSessions());
320
346
  }, 150);
321
347
  });
322
348
 
@@ -370,7 +396,7 @@ function renderStripShow() {
370
396
  // render, since a hub snapshot hands the list back in the server's order.
371
397
  function renderAll() {
372
398
  const listed = allViewSessions();
373
- const sessions = withChildrenAfterParents(filteredSessions(listed));
399
+ const sessions = listOrder(allListEl, withChildrenAfterParents(filteredSessions(listed)));
374
400
  $("#count").textContent = sessions.length === listed.length
375
401
  ? `${listed.length}`
376
402
  : `${sessions.length} / ${listed.length}`;
@@ -2208,6 +2234,13 @@ setStripHidden(stripHiddenSaved());
2208
2234
  $("#stripShow").addEventListener("click", () => setStripHidden(false));
2209
2235
 
2210
2236
  $("#filter").addEventListener("input", renderAll);
2237
+ // A list whose order was held under the pointer (listOrder) catches up.
2238
+ topStripEl.addEventListener("pointerleave", () => {
2239
+ if (heldLists.has(topStripEl)) renderTop(stripSessions());
2240
+ });
2241
+ allListEl.addEventListener("pointerleave", () => {
2242
+ if (heldLists.has(allListEl) && allViewOpen()) renderAll();
2243
+ });
2211
2244
  // Esc leaves the all-sessions view.
2212
2245
  document.addEventListener("keydown", (e) => {
2213
2246
  if (e.key === "Escape" && allViewOpen()) closeAllView();
@@ -14,6 +14,9 @@ body{font-family: ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,Helvetic
14
14
  #dock,.turn-stage,.dock-card,#topStrip .card,#allList .card,.bubble.question,.bubble.plugin-card{background:var(--glass-bg);-webkit-backdrop-filter:blur(18px) saturate(1.35);backdrop-filter:blur(18px) saturate(1.35);border:1px solid var(--glass-border);box-shadow:inset 0 1px 0 var(--glass-highlight),var(--glass-shadow)}
15
15
  #topStrip .card:hover,#allList .card:hover{background:color-mix(in srgb,var(--bg-hover) 75%,transparent);border-color:rgba(255,255,255,.14)}
16
16
  #topStrip .card.active,#allList .card.active{background:color-mix(in srgb,var(--accent-bg) 75%,transparent);border-color:color-mix(in srgb,var(--accent) 35%,transparent)}
17
+ /* A session that waits on the user (a question, an approval, a card with actions): the warning colour of its question card, also when active or hovered. */
18
+ #topStrip .card.waiting,#allList .card.waiting{border-color:color-mix(in srgb,var(--warn) 75%,transparent)}
19
+ #topStrip .card.waiting:hover,#allList .card.waiting:hover{border-color:var(--warn)}
17
20
  @supports not ((backdrop-filter:blur(1px)) or (-webkit-backdrop-filter:blur(1px))){#dock,.turn-stage,.dock-card,#topStrip .card,#allList .card,#allHead input{background:var(--bg-panel)}.bubble.question{background:color-mix(in srgb,var(--warn) 7%,var(--bg-panel))}.bubble.plugin-card{background:color-mix(in srgb,var(--accent) 6%,var(--bg-panel))}}
18
21
  @media (prefers-reduced-transparency:reduce){#dock,.turn-stage,.dock-card,#topStrip .card,#allList .card,#allHead input{background:var(--bg-panel);-webkit-backdrop-filter:none;backdrop-filter:none}.bubble.question{background:color-mix(in srgb,var(--warn) 7%,var(--bg-panel));-webkit-backdrop-filter:none;backdrop-filter:none}.bubble.plugin-card{background:color-mix(in srgb,var(--accent) 6%,var(--bg-panel));-webkit-backdrop-filter:none;backdrop-filter:none}}
19
22
  body.no-glass #dock,body.no-glass .turn-stage,body.no-glass .dock-card,body.no-glass #topStrip .card,body.no-glass #allList .card,body.no-glass #allHead input{background:var(--bg-panel);-webkit-backdrop-filter:none;backdrop-filter:none}
@@ -111,6 +114,7 @@ main{flex:1;display:flex;flex-direction:column;gap:16px;padding:16px;overflow:au
111
114
  .activity-status.ok{color:var(--ok)}
112
115
  .activity-status.error{color:var(--danger)}
113
116
  .activity-status.stopped{color:var(--warn-text);opacity:.8}
117
+ .activity-status.blocked{color:var(--danger-text)}
114
118
  .activity-tool{color:var(--accent);font-weight:600}
115
119
  .activity-params{color:var(--text-muted);word-break:break-all}
116
120
  .activity-duration{color:var(--text-faint);font-size:11px;font-variant-numeric:tabular-nums;white-space:nowrap}
@@ -687,7 +691,10 @@ body.all-view #center, body.all-view #topStrip{display:none}
687
691
  .card .dot.completed{background:var(--accent)}
688
692
  .card .dot.error{background:var(--danger)}
689
693
  @keyframes dot-pulse{0%,100%{box-shadow:0 0 0 0 color-mix(in srgb,var(--ok) 55%,transparent)}50%{box-shadow:0 0 0 4px color-mix(in srgb,var(--ok) 0%,transparent)}}
690
- @media (prefers-reduced-motion:reduce){.card .dot.running{animation:none}}
694
+ .card .dot.waiting{background:var(--warn);animation:attn-pulse 1.6s ease-in-out infinite}
695
+ @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)}}
696
+ @media (prefers-reduced-motion:reduce){.card .dot.running,.card .dot.waiting{animation:none}}
697
+ .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)}
691
698
  .status.running{background:var(--ok-bg);color:var(--ok-text)}
692
699
  .status.idle{background:var(--bg-raised)}
693
700
  .status.completed{background:var(--accent-bg);color:var(--accent)}
@@ -15,6 +15,21 @@ export function notifyState({ hasNotification, secureContext, permission, wanted
15
15
  // A finished turn shorter than this was watched: no "done".
16
16
  export const DONE_MIN_SECONDS = 10;
17
17
 
18
+ const questionReason = (q) => (q.kind === "approval" ? "approval" : "question");
19
+
20
+ // What a session waits on the user for right now, from one summary: an open
21
+ // question or approval, or a running turn's card with actions (check-in's
22
+ // Nudge / Keep going / Stop asks as a question does). The session cards
23
+ // mark and lift these; attentionFor notifies when one opens.
24
+ // @return null | {reason: "question"|"approval"|"card", id}
25
+ export function waitingOn(summary) {
26
+ const q = summary?.pending_question;
27
+ if (q && q.id) return { reason: questionReason(q), id: q.id };
28
+ const card = summary?.pending_card;
29
+ if (card && card.id) return { reason: "card", id: card.id };
30
+ return null;
31
+ }
32
+
18
33
  // What changed between two summaries of one session that needs the user.
19
34
  // @param prev the summary held before (null for a session not seen yet)
20
35
  // @param next the new summary
@@ -24,10 +39,8 @@ export function attentionFor(prev, next) {
24
39
  if (!next || !next.id) return null;
25
40
  const q = next.pending_question;
26
41
  if (q && q.id && q.id !== prev?.pending_question?.id) {
27
- return { reason: q.kind === "approval" ? "approval" : "question", sessionId: next.id, key: `${next.id}:${q.id}` };
42
+ return { reason: questionReason(q), sessionId: next.id, key: `${next.id}:${q.id}` };
28
43
  }
29
- // A running turn's card with actions (check-in's Nudge / Keep going /
30
- // Stop) asks the user as a question does.
31
44
  const card = next.pending_card;
32
45
  if (card && card.id && card.id !== prev?.pending_card?.id) {
33
46
  return { reason: "card", sessionId: next.id, key: cardKey(next.id, card.id) };
@@ -158,9 +158,11 @@ export function createQuestionCards({
158
158
  });
159
159
  card.appendChild(dismiss);
160
160
 
161
+ // Read before the card makes the history taller.
162
+ const follow = isNearBottom();
161
163
  place(card);
162
164
  questionEl = card;
163
- if (isNearBottom()) scrollToEnd();
165
+ if (follow) scrollToEnd();
164
166
  }
165
167
 
166
168
  // The tool, the command (or paths, or args) as code, where and why; no
@@ -1,5 +1,6 @@
1
1
  // The page's session list as a projection of the hub's events
2
2
  // (GET /api/events): a pure reducer over the list the page holds.
3
+ import { waitingOn } from "./notify.js";
3
4
 
4
5
  // @param list the current list (never mutated)
5
6
  // @param type "snapshot" | "session" | "session_gone"
@@ -65,3 +66,39 @@ export function withChildrenAfterParents(list) {
65
66
  const ordered = heads.slice().sort((a, b) => familyMs(b) - familyMs(a));
66
67
  return ordered.flatMap((s) => [s, ...(children.get(s.id) || [])]);
67
68
  }
69
+
70
+ const WAITING_BADGES = {
71
+ question: { text: "question", title: "Waiting for your answer" },
72
+ approval: { text: "approval", title: "Waiting for your approval" },
73
+ card: { text: "needs you", title: "A card in the running turn waits for you" },
74
+ };
75
+
76
+ // A card's "needs you" badge: what the session waits on the user for
77
+ // (notify.js waitingOn), or null.
78
+ // @return null | {kind, text, title}
79
+ export function waitingBadge(summary) {
80
+ const waiting = waitingOn(summary);
81
+ return waiting ? { kind: waiting.reason, ...WAITING_BADGES[waiting.reason] } : null;
82
+ }
83
+
84
+ // The sessions that wait on the user (waitingOn) first, the rest after,
85
+ // each part in the list's order: an answered one goes back to its place.
86
+ // A delegated session moves with its parent when both are listed (a
87
+ // family waits when any of it does), so withChildrenAfterParents' groups
88
+ // stay together.
89
+ export function waitingFirst(list) {
90
+ const ids = new Set(list.map((s) => s.id));
91
+ const family = (s) => (s.parent_id && ids.has(s.parent_id) ? s.parent_id : s.id);
92
+ const waiting = new Set(list.filter((s) => waitingOn(s)).map(family));
93
+ if (!waiting.size) return list;
94
+ return [...list.filter((s) => waiting.has(family(s))), ...list.filter((s) => !waiting.has(family(s)))];
95
+ }
96
+
97
+ // The list in an order shown before (+ids+): what the pointer is over
98
+ // doesn't move. Sessions new since then go after, in the list's order;
99
+ // gone ones drop out.
100
+ export function heldOrder(ids, list) {
101
+ const at = new Map(ids.map((id, i) => [id, i]));
102
+ const known = list.filter((s) => at.has(s.id)).sort((a, b) => at.get(a.id) - at.get(b.id));
103
+ return [...known, ...list.filter((s) => !at.has(s.id))];
104
+ }