samagotchi 0.168.0 → 0.169.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 (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +38 -1
  3. data/docs/architecture.md +1 -1
  4. data/docs/configuration.md +1 -1
  5. data/docs/guardrails.md +2 -2
  6. data/docs/hooks.md +2 -2
  7. data/docs/internals/block-renderers.md +1 -1
  8. data/docs/internals/bridge.md +16 -4
  9. data/docs/internals/inline-widgets.md +1 -1
  10. data/docs/internals/objectives.md +2 -1
  11. data/docs/internals/remote-sessions.md +1 -1
  12. data/docs/internals/web-frontend.md +62 -22
  13. data/docs/plugins.md +1 -1
  14. data/docs/releasing.md +10 -6
  15. data/docs/testing.md +12 -0
  16. data/lib/samagotchi/bridge.rb +12 -2
  17. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  18. data/lib/samagotchi/client_id.rb +3 -0
  19. data/lib/samagotchi/command_intake.rb +7 -7
  20. data/lib/samagotchi/compat.rb +14 -5
  21. data/lib/samagotchi/engine/objectives.rb +10 -1
  22. data/lib/samagotchi/guardrails/approvals.rb +8 -3
  23. data/lib/samagotchi/session_continue.rb +8 -11
  24. data/lib/samagotchi/session_manager/removal.rb +3 -3
  25. data/lib/samagotchi/session_stores.rb +79 -0
  26. data/lib/samagotchi/turn_stages/objectives.rb +6 -8
  27. data/lib/samagotchi/version.rb +1 -1
  28. data/lib/samagotchi/web/public/app.js +3 -1
  29. data/lib/samagotchi/web/public/block_watch.js +1 -1
  30. data/lib/samagotchi/web/public/cards_place.js +5 -1
  31. data/lib/samagotchi/web/public/index.html +58 -39
  32. data/lib/samagotchi/web/public/markdown_place.js +1 -1
  33. data/lib/samagotchi/web/public/question_cards.js +20 -6
  34. data/lib/samagotchi/web/public/stage_asking.js +283 -0
  35. data/lib/samagotchi/web/public/stage_frame.js +7 -11
  36. data/lib/samagotchi/web/public/stage_model.js +37 -11
  37. data/lib/samagotchi/web/public/stage_opened.js +28 -18
  38. data/lib/samagotchi/web/public/stage_slots.js +13 -89
  39. data/lib/samagotchi/web/public/stage_view.js +202 -160
  40. data/lib/samagotchi/web/public/stage_waiting.js +180 -0
  41. data/lib/samagotchi/web/public/stream_handlers.js +1 -1
  42. data/lib/samagotchi/web/public/turn_model.js +1 -1
  43. data/lib/samagotchi/web/session_show.rb +14 -4
  44. data/lib/samagotchi/worker.rb +3 -4
  45. metadata +4 -1
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "rubygems/version"
4
+ require "date"
4
5
  require_relative "version"
5
6
 
6
7
  module Samagotchi
@@ -11,23 +12,31 @@ module Samagotchi
11
12
  # decided (kept until then). spec/compat_spec.rb fails once VERSION
12
13
  # passes a shim's +remove_after+, naming it. Each Ruby file names its
13
14
  # shim's id in a comment ("compat shim <id>").
15
+ # A shim stays at least 14 days after the release that added it.
14
16
  # @!attribute files [Array<String>] where the shim lives (from the repository root)
15
- Shim = Data.define(:id, :added_in, :remove_after, :files, :what)
17
+ Shim = Data.define(:id, :added_in, :added_on, :remove_after, :files, :what)
18
+ MIN_AGE_DAYS = 14
16
19
 
17
20
  SHIMS = [
18
- Shim.new(id: :cards_v1_seed, added_in: "0.151.0", remove_after: "0.170.0",
21
+ Shim.new(id: :cards_v1_seed, added_in: "0.151.0", added_on: "2026-11-05", remove_after: "0.170.0",
19
22
  files: ["lib/samagotchi/bridge/card_store.rb"],
20
23
  what: "CardStore#seed_v1 reads a cards.json from before the turn log; its comment says a retention " \
21
- "period after the release that wrote version 2 (0.151.0, 2026-11-05)")
24
+ "period after the release that wrote version 2 (0.151.0, 2026-11-05)"),
25
+ Shim.new(id: :approvals_bare_list, added_in: "0.169.0", added_on: "2026-11-12", remove_after: "0.172.0",
26
+ files: ["lib/samagotchi/guardrails/approvals.rb"],
27
+ what: "Approvals reads the bare list written by chi 0.166 and before, converting it on write")
22
28
  ].freeze
23
29
 
24
30
  module_function
25
31
 
26
32
  # The shims +version+ has passed the remove_after of.
27
33
  # @return [Array<Shim>]
28
- def overdue_shims(version: VERSION, shims: SHIMS)
34
+ def overdue_shims(version: VERSION, shims: SHIMS, today: Date.today)
29
35
  current = Gem::Version.new(version)
30
- shims.select { |shim| shim.remove_after && current > Gem::Version.new(shim.remove_after) }
36
+ shims.select do |shim|
37
+ shim.remove_after && current > Gem::Version.new(shim.remove_after) &&
38
+ Date.iso8601(shim.added_on) + MIN_AGE_DAYS <= today
39
+ end
31
40
  end
32
41
 
33
42
  def verdict(worker_version:, version: VERSION, worker_protocol: nil, protocol: nil)
@@ -19,13 +19,22 @@ module Samagotchi
19
19
  return unless session
20
20
 
21
21
  synchronize_events do
22
- event = @objectives_stage.announce(session, state_dir: session_state_dir, running: live_conversation)
22
+ event = @objectives_stage.announce(session, view: objectives_view(session.id, session.messages))
23
23
  announce(event) if event
24
24
  end
25
25
  rescue StandardError => e
26
26
  Log.warn(:turn, "objectives_announce_failed", error: e.class.name, msg: e.message)
27
27
  end
28
28
 
29
+ # The view the worker announces and includes in its snapshots.
30
+ # @param session_id [String] the session whose objectives to read
31
+ # @param messages [Array<Hash>] the messages held by the caller's snapshot
32
+ # @param state_dir [String] the sessions dir
33
+ # @return [Hash, nil]
34
+ def objectives_view(session_id, messages, state_dir: session_state_dir)
35
+ Objectives.view(session_id, messages, state_dir: state_dir, running: live_conversation&.dup)
36
+ end
37
+
29
38
  # Check this (ObjectivesCheck)'s one side request. Its usage is in the
30
39
  # session's side line. Raises as ctx.ask_model's request does.
31
40
  # @param request [Array<Hash>] chat messages
@@ -30,8 +30,8 @@ module Samagotchi
30
30
  #
31
31
  # The file is a versioned store, {"version", "approvals": [...]}
32
32
  # (FORMAT); a write keeps its version and the keys this chi doesn't
33
- # know. A bare list of entries (what 0.166 and before wrote) is not
34
- # a versioned store: it is set aside like an unreadable file.
33
+ # know. A bare list of entries (what 0.166 and before wrote) is read and
34
+ # becomes versioned on the next write.
35
35
  class Approvals
36
36
  FILE = "approvals.json"
37
37
  COLLECTION = "approvals"
@@ -192,6 +192,10 @@ module Samagotchi
192
192
  def parse(text)
193
193
  data = JSON.parse(text)
194
194
  case data
195
+ when Array
196
+ # compat shim approvals_bare_list
197
+ document = FORMAT.read({ COLLECTION => data }, collection: COLLECTION)
198
+ [document.entries.map(&:raw).select { |e| e.is_a?(Hash) }, document]
195
199
  when Hash
196
200
  raise JSON::ParserError, "#{COLLECTION} is not a list" unless data.fetch(COLLECTION, []).is_a?(Array)
197
201
 
@@ -199,7 +203,8 @@ module Samagotchi
199
203
  # An entry it can't read (no scope) is kept as it is: it matches
200
204
  # nothing, and a write keeps it for the chi that wrote it.
201
205
  [document.entries.map(&:raw).select { |e| e.is_a?(Hash) }, document]
202
- else raise JSON::ParserError, "not a versioned store"
206
+ else
207
+ raise JSON::ParserError, "not a versioned store"
203
208
  end
204
209
  end
205
210
 
@@ -21,6 +21,7 @@ require_relative "worker_link"
21
21
  require_relative "model_profile"
22
22
  require_relative "log"
23
23
  require_relative "session_manager"
24
+ require_relative "session_stores"
24
25
 
25
26
  module Samagotchi
26
27
  # The next link of a session chain: chi send --new --continues and web
@@ -76,7 +77,7 @@ module Samagotchi
76
77
  # (taking an UndoContext) that gives it back when the continue fails;
77
78
  # empty when there is nothing to undo (CARRY's comment says why).
78
79
  # +store+ is the SessionManager::SESSION_STATE_STORES store it carries.
79
- CarryStep = Data.define(:name, :store, :carry, :undo)
80
+ CarryStep = Data.define(:name, :store, :carry, :undo, :files)
80
81
  # What a carry step works on: the previous link (a Session), the new
81
82
  # link's id, the delegates moving (ChildMove::Plan#move).
82
83
  CarryContext = Data.define(:previous, :to, :state_dir, :move)
@@ -100,15 +101,16 @@ module Samagotchi
100
101
  # Its open delegates (ChildMove): given back to the previous link
101
102
  # first, then the rings one rang the link with just before the undo.
102
103
  CarryStep.new(name: :children, store: :session_files, carry: :carry_children,
103
- undo: { moved_back: :undo_move, link_stopped: :return_rings }),
104
+ undo: { moved_back: :undo_move, link_stopped: :return_rings },
105
+ files: %w[delegates.json parent.json]),
104
106
  # Its movable context too, with a note saying what moved and stayed.
105
107
  # No undo: ContextSources.move copies (the previous link keeps its
106
108
  # sources), and the link's copies go with its delete (remove_files).
107
- CarryStep.new(name: :context, store: :context_sources, carry: :carry_context, undo: {}),
109
+ CarryStep.new(name: :context, store: :context_sources, carry: :carry_context, undo: {}, files: ["left.json"]),
108
110
  # Its objectives: the list reads as the latest word. No undo: a note
109
111
  # written into the link only (NoteMove::CONTINUE_SOURCES: never given
110
112
  # back), which goes with its delete.
111
- CarryStep.new(name: :objectives, store: :session_files, carry: :carry_objectives, undo: {}),
113
+ CarryStep.new(name: :objectives, store: :session_files, carry: :carry_objectives, undo: {}, files: []),
112
114
  # Its waiting notes last: the carry above read a pending /objectives
113
115
  # edit from them first. They keep their names, so they still sort
114
116
  # ahead of the link's own notes. A note sent to the previous link
@@ -117,17 +119,12 @@ module Samagotchi
117
119
  # once the link is out of the chain, and again for a note an older chi
118
120
  # wrote after its folder went.
119
121
  CarryStep.new(name: :notes, store: :session_files, carry: :carry_notes,
120
- undo: { link_unlinked: :give_back_notes, link_deleted: :give_back_strays })
122
+ undo: { link_unlinked: :give_back_notes, link_deleted: :give_back_strays }, files: [])
121
123
  ].freeze
122
124
 
123
125
  # The SessionManager::SESSION_STATE_STORES a continue deliberately
124
126
  # leaves with the previous link, and why: every store is in CARRY or here.
125
- NOT_CARRIED = {
126
- plugin_session_state: "a bundle's per-session state (ctx.session_dir) stays with the archived link",
127
- participants: "the archive ends the previous link's review (D8); a continue copies no grants",
128
- system_one_runs: "System 1's saved runs record the previous link's own turns",
129
- context_candidates_log: "the candidates log records the previous link's own turns"
130
- }.freeze
127
+ NOT_CARRIED = SessionStores::STORES.reject(&:carried?).to_h { |store| [store.file, store.continue] }.freeze
131
128
 
132
129
  # The model a new link runs on (#model_for): the name to spawn it with,
133
130
  # and why it isn't the previous link's (nil when it is, or was given).
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../session_stores"
4
+
3
5
  module Samagotchi
4
6
  class SessionManager
5
7
  # A delete that would pull the session from under its live worker.
@@ -61,9 +63,7 @@ module Samagotchi
61
63
 
62
64
  # Every store with session-scoped state. Deletion, retention and failed
63
65
  # continuation cleanup all pass through this registry.
64
- SESSION_STATE_STORES = %i[
65
- session_files plugin_session_state context_sources participants system_one_runs context_candidates_log
66
- ].freeze
66
+ SESSION_STATE_STORES = SessionStores.removal_stores
67
67
 
68
68
  # Removal: archive, unarchive and delete a session (an archive takes its
69
69
  # delegates too), and the retention sweep's entry. Calls LifecyclePart's
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ # The files that hold state for a session, including stores outside its
5
+ # sidecar directory. `corpus_dir` is relative to the compatibility corpus.
6
+ # A Symbol in `continue` names the carry step; a String explains why it stays.
7
+ module SessionStores
8
+ Store = Data.define(:file, :owner, :removal, :cross_version, :continue, :corpus_dir, :corpus_exception) do
9
+ def initialize(file:, owner:, removal:, cross_version:, continue:, corpus_dir:, corpus_exception: nil)
10
+ super
11
+ end
12
+
13
+ def carried? = continue.is_a?(Symbol)
14
+
15
+ def corpus_exception? = !corpus_exception.nil?
16
+ end
17
+
18
+ SIDE_CAR = "sessions/compat-session"
19
+ CONTEXT = "context/sessions/compat-session"
20
+
21
+ STORES = [
22
+ Store.new(file: "archived", owner: "ArchiveStore", removal: :session_files, cross_version: true,
23
+ continue: "the archived link remains its own session", corpus_dir: SIDE_CAR),
24
+ Store.new(file: "state.json", owner: "PluginSessionState", removal: :plugin_session_state, cross_version: false,
25
+ continue: "bundle state stays with the archived link", corpus_dir: nil),
26
+ Store.new(file: "left.json", owner: "ContextSources", removal: :context_sources, cross_version: true,
27
+ continue: :context, corpus_dir: CONTEXT),
28
+ Store.new(file: "participants.json", owner: "Participants", removal: :participants, cross_version: true,
29
+ continue: "a continue ends the previous link's review", corpus_dir: SIDE_CAR),
30
+ Store.new(file: "judgments.jsonl", owner: "SystemOne::RunsStore", removal: :system_one_runs, cross_version: false,
31
+ continue: "saved judgments belong to the previous link", corpus_dir: nil),
32
+ Store.new(file: "context_candidates.jsonl", owner: "ContextCandidatesLog", removal: :context_candidates_log,
33
+ cross_version: false, continue: "candidate records belong to the previous link", corpus_dir: nil),
34
+ Store.new(file: "bridge.json", owner: "WorkerSidecar", removal: :session_files, cross_version: true,
35
+ continue: "the bridge endpoint belongs to the old worker", corpus_dir: SIDE_CAR),
36
+ Store.new(file: "cards.json", owner: "Bridge::CardStore", removal: :session_files, cross_version: true,
37
+ continue: "cards describe turns in the previous link", corpus_dir: SIDE_CAR),
38
+ Store.new(file: "pending_card.json", owner: "Bridge::PendingCard", removal: :session_files, cross_version: true,
39
+ continue: "an open card belongs to its running turn", corpus_dir: SIDE_CAR),
40
+ Store.new(file: "turn_drawings.jsonl", owner: "Bridge::TurnDrawings", removal: :session_files, cross_version: true,
41
+ continue: "drawings describe turns in the previous link", corpus_dir: "sessions/compat-drawn"),
42
+ Store.new(file: "move.json", owner: "ChildMove", removal: :session_files, cross_version: true,
43
+ continue: "an interrupted move belongs to the previous continue", corpus_dir: SIDE_CAR),
44
+ Store.new(file: "continue.json", owner: "ContinueIntent", removal: :session_files, cross_version: true,
45
+ continue: "the intent records the previous link's continue", corpus_dir: SIDE_CAR),
46
+ Store.new(file: "objectives.lock", owner: "Objectives", removal: :session_files, cross_version: false,
47
+ continue: "a lock is not session data", corpus_dir: SIDE_CAR),
48
+ Store.new(file: "objectives_cards.json", owner: "Objectives", removal: :session_files, cross_version: true,
49
+ continue: "proposal answers belong to the previous link", corpus_dir: SIDE_CAR),
50
+ Store.new(file: "objective_checks.jsonl", owner: "ObjectivesCheck", removal: :session_files, cross_version: true,
51
+ continue: "checks belong to the previous link", corpus_dir: SIDE_CAR),
52
+ Store.new(file: "objective_tracks.jsonl", owner: "ObjectivesTrack", removal: :session_files, cross_version: true,
53
+ continue: "tracks belong to the previous link", corpus_dir: SIDE_CAR),
54
+ Store.new(file: "owner.lock", owner: "OwnerLock", removal: :session_files, cross_version: false,
55
+ continue: "the owner lock is not session data", corpus_dir: SIDE_CAR),
56
+ Store.new(file: "reminders.json", owner: "ReminderCarry", removal: :session_files, cross_version: true,
57
+ continue: "a restart hand-off is not a continue carry", corpus_dir: SIDE_CAR),
58
+ Store.new(file: "recap.json", owner: "RecapStore", removal: :session_files, cross_version: true,
59
+ continue: "the recap remains with the previous link", corpus_dir: SIDE_CAR),
60
+ Store.new(file: "stopped", owner: "Session", removal: :session_files, cross_version: true,
61
+ continue: "the stop marker belongs to the previous link", corpus_dir: SIDE_CAR),
62
+ Store.new(file: "parent.json", owner: "Session", removal: :session_files, cross_version: true,
63
+ continue: :children, corpus_dir: SIDE_CAR),
64
+ Store.new(file: "starting", owner: "StartingMarker", removal: :session_files, cross_version: true,
65
+ continue: "a start marker belongs to its spawn", corpus_dir: nil,
66
+ corpus_exception: "the corpus marker would always be stale, so it cannot pin the live-start handshake"),
67
+ Store.new(file: "thinking_tails.jsonl", owner: "ThinkingTails", removal: :session_files, cross_version: true,
68
+ continue: "generation tails remain with the previous link", corpus_dir: SIDE_CAR),
69
+ Store.new(file: "delegates.json", owner: "Tools::DelegateCursors", removal: :session_files, cross_version: true,
70
+ continue: :children, corpus_dir: SIDE_CAR),
71
+ Store.new(file: "delegates.lock", owner: "Tools::DelegateCursors", removal: :session_files, cross_version: false,
72
+ continue: "a lock is not session data", corpus_dir: SIDE_CAR)
73
+ ].freeze
74
+
75
+ def self.removal_stores = STORES.map(&:removal).uniq.freeze
76
+
77
+ def self.corpus_stores = STORES.select(&:cross_version).freeze
78
+ end
79
+ end
@@ -97,18 +97,16 @@ module Samagotchi
97
97
  # @param state_dir [String] the sessions dir
98
98
  def note(session, note, recorder:, state_dir:) = audit(state_dir, recorder).note(session, note)
99
99
 
100
- # The session's objectives as the page draws them (Objectives.view), when
101
- # that differs from the last announced: after an edit (its file written,
102
- # pending), once a list note is in, at a turn's end (its moves) and after
103
- # a rollback. A session with no list announces nothing. The caller
100
+ # The session's objectives as the page draws them (Engine#objectives_view),
101
+ # when that differs from the last announced: after an edit (its file
102
+ # written, pending), once a list note is in, at a turn's end (its moves)
103
+ # and after a rollback. A session with no list announces nothing. The caller
104
104
  # (Engine#announce_objectives) holds the event lock, the one guard of
105
105
  # the last view, announces the event and logs a failure: it only
106
106
  # informs, never fails a turn.
107
- # @param state_dir [String] the sessions dir
108
- # @param running [Array<Hash>, nil] the running turn's conversation (Engine#live_conversation), nil between turns
107
+ # @param view [Hash, nil] the view from Engine#objectives_view
109
108
  # @return [Hash, nil] the :objectives_updated event to announce, nil when the view is the same
110
- def announce(session, state_dir:, running:)
111
- view = Samagotchi::Objectives.view(session.id, session.messages, state_dir: state_dir, running: running&.dup)
109
+ def announce(session, view:)
112
110
  return if view.nil? || view == @announced
113
111
 
114
112
  @announced = view
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.168.0"
4
+ VERSION = "0.169.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
@@ -448,12 +448,14 @@ const questionCards = createQuestionCards({
448
448
  isNearBottom: () => isNearBottom(),
449
449
  scrollToEnd: () => { historyEl.scrollTop = historyEl.scrollHeight; },
450
450
  removeHintIfEmpty: () => removeHintIfEmpty(),
451
- askingText: () => turnDom.askingText(),
451
+ askingText: (pq) => turnDom.askingText(pq),
452
452
  renderMarkdown,
453
453
  linkRefs: linkRefsIn,
454
454
  pairRefs: pairRefsIn,
455
455
  // The bar's chip reads questionCards.pendingQuestion() (sessionWaits).
456
456
  onPending: () => store.touch("infoBar"),
457
+ // The stage's waiting cards as data (stage_waiting.js).
458
+ onSettled: (key) => turnDom.settleWaiting?.(key),
457
459
  });
458
460
  // The history's bubbles (bubbles.js): output, end lines, every client's
459
461
  // prompts, the recap, warnings and notices, command and note bubbles.
@@ -2,7 +2,7 @@
2
2
  // One observer per turn root, filtered to TEXTS, as copy.js and answer_fold.js:
3
3
  // every path that fills a root (a reload, a session switch, a turn's end, an
4
4
  // answer_display, Revise's clones, a step's swap) arrives the same way, and
5
- // cards, the asking slot and question replies are never touched. A block is
5
+ // cards, a lead-in bubble and question replies are never touched. A block is
6
6
  // drawn when it comes near its scroller's view (a closed <details> opening is
7
7
  // such a coming near), and one that moves to another scroller while it waits
8
8
  // (the stage's hand-off to #history) is watched there.
@@ -15,6 +15,7 @@ import { stepText } from "./turn_block.js";
15
15
  import { llmContextNotice, noticeLine } from "./turn_events.js";
16
16
  import { hookNoticeTemplate, TURN_PROMPT, turnPromptBubbles } from "./bubbles.js";
17
17
  import { stillOn } from "./latest.js";
18
+ import { pluginCardKey } from "./stage_waiting.js";
18
19
 
19
20
  // The elements of the reloaded turn that ends before +before+ (the next
20
21
  // turn's prompt, or null for the last turn), its prompt first.
@@ -71,15 +72,18 @@ export function createCardsPlace({
71
72
  if (!card || !card.id) return;
72
73
  turnDom.settleFold?.();
73
74
  const existing = cardElement(card.id);
75
+ const asks = (card.actions || []).some((a) => a && a.command);
74
76
  if (existing) {
75
77
  const drawn = drawCard(existing, card);
76
78
  drawn.classList.add("card-updated");
77
79
  setTimeout(() => drawn.classList.remove("card-updated"), 900);
80
+ // Redrawn without its actions (check-in's card turned into its notice
81
+ // row): it no longer asks (stage_waiting.js).
82
+ if (!asks) turnDom.settleWaiting?.(pluginCardKey(card.id), { only: "asks" });
78
83
  return;
79
84
  }
80
85
  bubbles.removeHintIfEmpty();
81
86
  const el = drawCard(document.createElement(isNoticeCard(card) ? "details" : "div"), card);
82
- const asks = (card.actions || []).some((a) => a && a.command);
83
87
  if (card.in_turn && !before && !asks && turnDom.card(el)) return;
84
88
  bubbles.placeFollowing(el, before);
85
89
  }
@@ -49,10 +49,10 @@ body{font-family: ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,Helvetic
49
49
  /* 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. */
50
50
  #topStrip .card.waiting,#allList .card.waiting{border-color:color-mix(in srgb,var(--warn) 75%,transparent)}
51
51
  @media(hover:hover){#topStrip .card.waiting:hover,#allList .card.waiting:hover{border-color:var(--warn)}}
52
- @supports not ((backdrop-filter:blur(1px)) or (-webkit-backdrop-filter:blur(1px))){.turn-stage,#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))}}
53
- @media (prefers-reduced-transparency:reduce){.turn-stage,#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}}
52
+ @supports not ((backdrop-filter:blur(1px)) or (-webkit-backdrop-filter:blur(1px))){.turn-stage,#topStrip .card,#allList .card,#allHead input{background:var(--bg-panel)}.bubble.question{background:var(--bg-panel)}.bubble.plugin-card{background:color-mix(in srgb,var(--accent) 6%,var(--bg-panel))}}
53
+ @media (prefers-reduced-transparency:reduce){.turn-stage,#topStrip .card,#allList .card,#allHead input{background:var(--bg-panel);-webkit-backdrop-filter:none;backdrop-filter:none}.bubble.question{background: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}}
54
54
  body.no-glass .turn-stage,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}
55
- body.no-glass .bubble.question{background:color-mix(in srgb,var(--warn) 7%,var(--bg-panel));-webkit-backdrop-filter:none;backdrop-filter:none}
55
+ body.no-glass .bubble.question{background:var(--bg-panel);-webkit-backdrop-filter:none;backdrop-filter:none}
56
56
  body.no-glass .bubble.plugin-card{background:color-mix(in srgb,var(--accent) 6%,var(--bg-panel));-webkit-backdrop-filter:none;backdrop-filter:none}
57
57
  button,input,textarea,select{font:inherit}
58
58
  button{background:var(--primary);border:0;color:var(--on-solid);padding:8px 12px;border-radius:8px;cursor:pointer;transition:filter .12s ease,background .12s ease}
@@ -426,24 +426,26 @@ button.system-one-wrong,.system-one-wrong.marked{flex:none;padding:1px 6px;borde
426
426
  .gen:not(.live) .gen-text{font-size:14px;color:var(--text-2)}
427
427
  .gen-text:empty{display:none}
428
428
  .gen .activity-body{margin-top:6px}
429
- /* One gen, no thinking, no tool rows: nothing to stack yet, so the narration streams like a plain answer bubble. A step that closed with text (a line steered in after it, its answer gone from the block) is a row: its summary is the way to its text. */
430
- .turn-work:not(:has(.gen~.gen,.thinking-body,.activity-body,.gen:not(.live)>.gen-text)){border:0;padding:0;background:transparent}
431
- .turn-work:not(:has(.gen~.gen,.thinking-body,.activity-body,.gen:not(.live)>.gen-text))>summary,.turn-work:not(:has(.gen~.gen,.thinking-body,.activity-body,.gen:not(.live)>.gen-text)) .gen>summary{display:none}
432
- .turn-work:not(:has(.gen~.gen,.thinking-body,.activity-body,.gen:not(.live)>.gen-text)) .gen{margin:0;padding:0;border:0;background:transparent}
433
- .turn-work:not(:has(.gen~.gen,.thinking-body,.activity-body,.gen:not(.live)>.gen-text)) .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}
429
+ /* One gen, no thinking, no tool rows: nothing to stack yet, so the narration streams like a plain answer bubble. A step that closed with text (a line steered in after it, its answer gone from the block) is a row: its summary is the way to its text. So is a running turn's block opened in the
430
+ history (data-opened-turn, stage_asking.js openTurn): the step's text is in it while a card without a lead-in waits. */
431
+ .turn-work:not([data-opened-turn]):not(:has(.gen~.gen,.thinking-body,.activity-body,.gen:not(.live)>.gen-text)){border:0;padding:0;background:transparent}
432
+ .turn-work:not([data-opened-turn]):not(:has(.gen~.gen,.thinking-body,.activity-body,.gen:not(.live)>.gen-text))>summary,.turn-work:not([data-opened-turn]):not(:has(.gen~.gen,.thinking-body,.activity-body,.gen:not(.live)>.gen-text)) .gen>summary{display:none}
433
+ .turn-work:not([data-opened-turn]):not(:has(.gen~.gen,.thinking-body,.activity-body,.gen:not(.live)>.gen-text)) .gen{margin:0;padding:0;border:0;background:transparent}
434
+ .turn-work:not([data-opened-turn]):not(:has(.gen~.gen,.thinking-body,.activity-body,.gen:not(.live)>.gen-text)) .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}
434
435
  @media(max-width:600px){.gen{--step-indent:8px}}
435
- /* 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. */
436
- .bubble.question{border-left:3px solid var(--warn);background:color-mix(in srgb,var(--warn) 7%,var(--glass-bg));padding:10px 12px}
436
+ /* A question card asks for input like the composer card, so it wears the same glass (the recipe above), no amber: what
437
+ waits says so in the stage's status row ("waiting for you", its dot) and on a waiting lead-in bubble. */
438
+ .bubble.question{border-left:3px solid var(--border-strong);padding:10px 12px}
437
439
  /* Collapsed (resolved) question: one line, like the thinking and activity blocks.
438
- The left bar keeps --warn here; the existing answered/cancelled/denied classes
440
+ The left bar is neutral here; the existing answered/cancelled/denied classes
439
441
  (which apply whether open or not) recolour it once resolved. */
440
442
  .bubble.question:not([open]){padding:3px 10px;background:transparent;border-color:transparent;box-shadow:none;-webkit-backdrop-filter:none;backdrop-filter:none;align-self:flex-start;border-radius:6px;transition:background .12s ease}
441
- .bubble.question:not([open]){border-left-color:var(--warn)}
443
+ .bubble.question:not([open]){border-left-color:var(--border-strong)}
442
444
  @media(hover:hover){.bubble.question:not([open]):hover{background:var(--bg-panel)}}
443
445
  .bubble.question:not([open])>summary{font-size:12px;font-weight:500}
444
446
  .bubble.question>summary{cursor:pointer;color:var(--text-muted);font-size:13px;font-weight:600;user-select:none;list-style:none}
445
447
  .bubble.question>summary::-webkit-details-marker{display:none}
446
- .bubble.question>summary::before{content:"▸ ";color:var(--warn)}
448
+ .bubble.question>summary::before{content:"▸ ";color:var(--text-faint)}
447
449
  .bubble.question[open]>summary::before{content:"▾ "}
448
450
  /* An answered card keeps the reply that asked it (question_cards.js): muted under its "Q → A" line, two lines while
449
451
  closed (newlines as spaces); open, the summary's copy goes and the body's starts the card, whole, as written and
@@ -456,6 +458,9 @@ button.system-one-wrong,.system-one-wrong.marked{flex:none;padding:1px 6px;borde
456
458
  .bubble.said+.bubble.question :is(.question-said,.question-said-full){display:none}
457
459
  /* Its asking step in the steps block doesn't repeat the reason either (stage_opened.js data-said, until the hand-off). */
458
460
  .turn-work>details.gen[data-said]>.gen-text{display:none}
461
+ /* A card that waits in the stage has no toggle of its own (stage_view.js): the stage's fold is the one. */
462
+ .turn-stage .bubble.question:not(.answered):not(.cancelled)>summary{cursor:default}
463
+ .turn-stage .bubble.question:not(.answered):not(.cancelled)>summary::before{content:none}
459
464
  .question-reason-link{float:right;margin-left:12px;padding:0;border:0;background:none;color:var(--accent);font-size:12.5px;font-weight:500;line-height:inherit}
460
465
  @media(hover:hover){.question-reason-link:hover:not(:disabled){filter:none;text-decoration:underline}}
461
466
  /* The reply that asked the model's own question, a conversation bubble while its turn runs (live only): a label line ("chi ·
@@ -479,19 +484,13 @@ button.system-one-wrong,.system-one-wrong.marked{flex:none;padding:1px 6px;borde
479
484
  --md-strong-color:currentColor;--md-blockquote-color:currentColor;--md-code-color:currentColor;
480
485
  --md-pre-size:12px;--md-cell-size:12.5px
481
486
  }
482
- .ts-asking.md-compact{
483
- --md-heading-size:14.5px;--md-heading-color:var(--text-bright);
484
- --md-subheading-size:14px;--md-subheading-color:var(--text-2);
485
- --md-strong-color:var(--text-bright);--md-blockquote-color:var(--text-muted);--md-code-color:var(--text);
486
- --md-pre-size:12.5px;--md-cell-size:13px
487
- }
488
487
  .gen-text.md-compact{
489
488
  --md-heading-size:14.5px;--md-heading-color:var(--text);
490
489
  --md-subheading-size:14px;--md-subheading-color:var(--text-2);
491
490
  --md-strong-color:var(--text);--md-blockquote-color:var(--text-muted);--md-code-color:var(--text);
492
491
  --md-pre-size:12.5px;--md-cell-size:13px
493
492
  }
494
- .md-compact:is(.ts-asking,.question-said-full,.gen-text,.said-text){white-space:normal}
493
+ .md-compact:is(.question-said-full,.gen-text,.said-text){white-space:normal}
495
494
  .md-compact :is(h1,h2,h3,h4,h5,h6){font-size:var(--md-heading-size);font-weight:600;line-height:1.4;margin:8px 0 2px;color:var(--md-heading-color)}
496
495
  .md-compact :is(h4,h5,h6){font-size:var(--md-subheading-size);color:var(--md-subheading-color)}
497
496
  .md-compact :is(p,ul,ol,blockquote,table,pre){margin:0 0 6px}
@@ -541,7 +540,7 @@ button.system-one-wrong,.system-one-wrong.marked{flex:none;padding:1px 6px;borde
541
540
  .question-option{display:flex;gap:8px;align-items:center;font-size:13px;color:var(--text-2);padding:6px 8px;border:1px solid var(--border);border-radius:8px;cursor:pointer}
542
541
  @media(hover:hover){.question-option:hover{background:var(--bg-hover)}}
543
542
  .question-option.selected{border-color:var(--ok);color:var(--text)}
544
- .question-option input{margin:0;accent-color:var(--warn)}
543
+ .question-option input{margin:0;accent-color:var(--accent)}
545
544
  /* The model's recommended option (a label ending in "(Recommended)"): an accent row and a small tag. */
546
545
  .question-option.recommended{border-color:var(--accent-border);background:var(--accent-bg);color:var(--text)}
547
546
  .question-option.recommended.selected{border-color:var(--ok)}
@@ -558,7 +557,7 @@ button.system-one-wrong,.system-one-wrong.marked{flex:none;padding:1px 6px;borde
558
557
  .bubble.question.cancelled .question-result{color:var(--text-muted)}
559
558
  .question-submit{align-self:flex-end;font-size:13px}
560
559
  .question-dismiss{font-size:13px;margin-left:8px}
561
- .bubble.question.approval{border-left-color:var(--warn);background:color-mix(in srgb,var(--warn) 7%,var(--glass-bg))}
560
+ .bubble.question.approval{border-left-color:var(--border-strong)}
562
561
  .bubble.question.approval.answered:not(.denied){border-left-color:var(--ok)}
563
562
  .bubble.question.approval:not([open]){background:transparent}
564
563
  @media(hover:hover){.bubble.question.approval:not([open]):hover{background:var(--bg-panel)}}
@@ -575,9 +574,9 @@ button.system-one-wrong,.system-one-wrong.marked{flex:none;padding:1px 6px;borde
575
574
  .question-relayed.hidden{display:none}
576
575
  .question-relayed a{color:var(--text);font-weight:600}
577
576
  .approval-tool{font-weight:700;color:var(--text);margin-bottom:4px}
578
- .approval-what{margin:0 0 6px;padding:8px 10px;background:var(--bg-inset);border:1px solid var(--warn-border);border-radius:6px;white-space:pre-wrap;word-break:break-all;color:var(--warn-bright);font-size:12.5px}
577
+ /* What it approves, neutral like the card (no amber on question cards): the command in the code's own box. */
578
+ .approval-what{margin:0 0 6px;padding:8px 10px;background:var(--bg-inset);border:1px solid var(--border);border-radius:6px;white-space:pre-wrap;word-break:break-all;color:var(--text-2);font-size:12.5px}
579
579
  .approval-where,.approval-why{font-size:12px;color:var(--text-muted);overflow-wrap:anywhere}
580
- .approval-why{color:var(--warn-text)}
581
580
  .approval-label{color:var(--text-faint)}
582
581
  /* A diff (diff_view.js): the approval card's dry run and a tool row's change. */
583
582
  .approval-diff{margin:0 0 6px}
@@ -841,7 +840,9 @@ body.stage-view .turn-stage{position:absolute;left:0;right:0;bottom:100%;margin-
841
840
  .turn-stage[hidden]{display:none}
842
841
  .turn-stage.ended{border-left-color:var(--border-strong)}
843
842
  .turn-stage[data-phase="answered"]{border-left-color:var(--ok)}
844
- .turn-stage[data-phase="canceled"],.turn-stage[data-phase="waiting for you"]{border-left-color:var(--warn)}
843
+ .turn-stage[data-phase="canceled"]{border-left-color:var(--warn)}
844
+ /* A card waits: the box has no amber edge (the status row's word and dot say it). */
845
+ .turn-stage[data-phase="waiting for you"]{border-left-color:var(--border-strong)}
845
846
  .turn-stage[data-phase="failed"]{border-left-color:var(--danger)}
846
847
  .turn-stage[data-phase="no answer"]{border-left-color:var(--warn)}
847
848
  .turn-stage.enter{animation:ts-rise .35s cubic-bezier(.3,.7,.2,1)}
@@ -900,21 +901,36 @@ body.stage-view .turn-stage{position:absolute;left:0;right:0;bottom:100%;margin-
900
901
  .ts-headline.thinking{font-size:13.5px;font-style:italic;color:var(--text-muted)}
901
902
  .ts-think-tag{font-style:normal;font-size:12.5px;color:var(--think-text);margin-right:6px}
902
903
  .ts-headline.fresh{animation:ts-fade .3s ease}
903
- /* While a question or approval waits: the whole reply that asked it, in the headline's place; raw as a closed step's
904
- text, then its markdown when web.markdown is on (.markdown, stage_slots.js drawAsking); capped so the card keeps
905
- its room, read from the top. */
906
- .ts-asking{max-height:calc(var(--stage-max,60vh) * .35);overflow:auto;scrollbar-width:thin;white-space:pre-wrap;overflow-wrap:anywhere;font-size:14px;line-height:1.55;color:var(--narr);border-left:2px solid var(--border-strong);padding-left:10px}
907
- /* The asking form (stage_view.js, a turn opened in the history while the model's own question waits): its reply is a
908
- bubble in the conversation (.bubble.said), the stage shows the status row and the card alone; the prompt, the live
904
+ /* The asking form (stage_view.js, a card docked: its turn opened in the history, a reply before chi's own question or
905
+ approval a bubble there, .bubble.said): the stage shows the status row and the card alone; the prompt, the live
909
906
  slots (the working set's rail with them) and the steps chip come back with the working form after the answer. */
910
907
  .turn-stage.asking-form :is(.ts-prompt,.ts-live,.ts-foot){display:none}
911
- /* It is one box (the mock-up's option A): the stage's frame takes the card's look (the card's tint, no glass; its amber edge
912
- is the waiting phase's), the status row is the box's first line, and the card inside drops its own box, so there is no
913
- box in a box and no second amber edge. */
914
- .turn-stage.asking-form{background:color-mix(in srgb,var(--warn) 7%,var(--bg-panel));-webkit-backdrop-filter:none;backdrop-filter:none;border-radius:12px}
908
+ /* It is one box (the mock-up's option A): the stage's frame takes the card's look (opaque, no glass, no amber: the status
909
+ row's "waiting for you" and its dot say it waits), the status row is the box's first line, and the card inside drops
910
+ its own box, so there is no box in a box. */
911
+ .turn-stage.asking-form{background:var(--bg-panel);-webkit-backdrop-filter:none;backdrop-filter:none;border-radius:12px}
915
912
  .turn-stage.asking-form .ts-scroll{padding-top:6px}
916
- .turn-stage.asking-form .ts-extras>.bubble.question{border:0;border-radius:0;background:transparent;padding:0}
917
- .turn-stage.asking .ts-headline{display:none}
913
+ .turn-stage.asking-form .ts-extras>:is(.bubble.question,.bubble.plugin-card:not(.warn)){border:0;border-radius:0;background:transparent;padding:0}
914
+ /* The asking form folded (stage_view.js, its fold is that question's only): the status row says "waiting for you" and the
915
+ question (the mini tool's slot, in the UI font, right after the phase), then the step and the timing. On a phone those
916
+ two leave the row and the question takes a second line under it. */
917
+ .ts-mini.ts-mini-question{order:1;font-family:inherit;font-size:12.5px;color:var(--text)}
918
+ /* Two cards docked (stage_view.js drawDocked): one at a time, the one that stops chi in front; the pager after the phase
919
+ says where it is ("‹ 1 of 2 ›"), the other waits behind it. */
920
+ .turn-stage .ts-extras>.ts-behind{display:none}
921
+ .ts-pager{flex:none;display:inline-flex;align-items:center;gap:2px;color:var(--text-2)}
922
+ .ts-pager[hidden]{display:none}
923
+ .ts-page{flex:none;min-width:22px;height:22px;padding:0 4px;border-radius:6px;background:transparent;color:var(--text-muted);font-size:14px;line-height:1}
924
+ .ts-page:disabled{opacity:.35}
925
+ @media(hover:hover){.ts-page:hover:not(:disabled){filter:none;background:var(--bg-hover);color:var(--text)}}
926
+ .ts-page-label{font-variant-numeric:tabular-nums}
927
+ /* check-in docked while nothing blocks (stage_view.js .asks-live): the row says the running call right after the phase,
928
+ then the turn's time; the step count is the history's steps line. The live step's text streams under the card. */
929
+ .turn-stage.asks-live .ts-status>:not(.ts-mark,.ts-phase,.ts-mini){order:2}
930
+ .turn-stage.asks-live .ts-mini{order:1}
931
+ .turn-stage.asks-live .ts-step{display:none}
932
+ .ts-asks-text{padding-top:8px;border-top:1px dashed var(--border-strong);white-space:pre-wrap;overflow-wrap:anywhere;font-size:14px;line-height:1.55;color:var(--text)}
933
+ .turn-stage.asking-form.collapsed .ts-status>:not(.ts-mark,.ts-phase,.ts-mini){order:2}
918
934
  @keyframes ts-fade{from{opacity:.35}}
919
935
  .ts-now{height:var(--ts-now-h);display:flex;align-items:center;gap:8px;min-width:0;padding:0 9px;border-radius:8px;background:var(--accent-tint);font-size:13px}
920
936
  .ts-spin{flex:none;width:11px;height:11px;border:2px solid var(--warn);border-right-color:transparent;border-radius:50%;animation:init-spin .8s steps(16) infinite}
@@ -974,7 +990,7 @@ body.stage-view .turn-stage{position:absolute;left:0;right:0;bottom:100%;margin-
974
990
  :is(.ts-extras>.bubble.user,.ts-prompt:not(.full)>.bubble.user)>.user-message.markdown :is(hr,img,.copy-btn){display:none}
975
991
  /* Inside the dock's own blur a second backdrop-filter sees nothing: the cards are opaque here (as #commandList is). */
976
992
  .turn-stage .bubble.question,.turn-stage .bubble.plugin-card{-webkit-backdrop-filter:none;backdrop-filter:none;box-shadow:none}
977
- .turn-stage .bubble.question{background:color-mix(in srgb,var(--warn) 7%,var(--bg-panel))}
993
+ .turn-stage .bubble.question{background:var(--bg-panel)}
978
994
  .turn-stage .bubble.plugin-card{background:color-mix(in srgb,var(--accent) 6%,var(--bg-panel))}
979
995
  .turn-stage .bubble.plugin-card.warn{background:color-mix(in srgb,var(--warn) 7%,var(--bg-panel))}
980
996
  .ts-answer{display:flex;flex-direction:column;gap:8px}
@@ -1194,7 +1210,10 @@ button.ws-row:hover:not(:disabled){filter:none}@media(hover:hover){button.ws-row
1194
1210
  /* The quiet signal's short text on a phone; in the status row it takes the step's and the mini tool's room. */
1195
1211
  .ts-stall .long{display:none}
1196
1212
  .ts-stall .short{display:inline}
1197
- .turn-stage.stalled:is(.collapsed,.plain) :is(.ts-step,.ts-mini){display:none}
1213
+ .turn-stage.stalled:is(.collapsed,.plain) :is(.ts-step,.ts-mini:not(.ts-mini-question)){display:none}
1214
+ .turn-stage.asking-form.collapsed .ts-status{flex-wrap:wrap;row-gap:4px}
1215
+ .turn-stage.asking-form.collapsed :is(.ts-step,.ts-timing){display:none}
1216
+ .ts-mini.ts-mini-question{order:3;flex-basis:100%}
1198
1217
  /* An approval's Submit in sight on a phone: its folder, reason and rows a little tighter. */
1199
1218
  .approval-where,.approval-why{line-height:1.35}
1200
1219
  .bubble.question.approval .question-option{padding:5px 8px}
@@ -36,7 +36,7 @@ export function createMarkdownPlace({
36
36
  pairAnswerRefs(el);
37
37
  }
38
38
 
39
- // The refs in one rendered text (the stage's asking slot, a kept reply),
39
+ // The refs in one rendered text (a lead-in bubble, a kept reply),
40
40
  // as linkTextRefs links the answers': a plugin's +refs+ first
41
41
  // (pairRefsIn), then the `path:line` ones.
42
42
  function linkRefsIn(el, refs) {