samagotchi 0.127.0 → 0.128.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 (36) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +25 -1
  3. data/docs/architecture.md +3 -0
  4. data/docs/guardrails.md +5 -0
  5. data/docs/internals/participants.md +6 -2
  6. data/docs/testing.md +3 -2
  7. data/lib/samagotchi/bridge.rb +2 -0
  8. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  9. data/lib/samagotchi/engine.rb +56 -0
  10. data/lib/samagotchi/guardrails/read_only_shell.rb +48 -1
  11. data/lib/samagotchi/session_manager.rb +5 -0
  12. data/lib/samagotchi/terminal_ui/attach_launcher.rb +9 -5
  13. data/lib/samagotchi/terminal_ui/attached_loop/queueing.rb +6 -12
  14. data/lib/samagotchi/terminal_ui/attached_loop/sending.rb +23 -35
  15. data/lib/samagotchi/terminal_ui/attached_loop.rb +10 -18
  16. data/lib/samagotchi/terminal_ui/own_turns.rb +81 -0
  17. data/lib/samagotchi/verify_anchor.rb +8 -0
  18. data/lib/samagotchi/version.rb +1 -1
  19. data/lib/samagotchi/web/participant_review.rb +4 -2
  20. data/lib/samagotchi/web/public/annotate_bar.js +14 -2
  21. data/lib/samagotchi/web/public/app.js +17 -4
  22. data/lib/samagotchi/web/public/composer.css +1 -0
  23. data/lib/samagotchi/web/public/notify.js +2 -1
  24. data/lib/samagotchi/web/public/participant.html +3 -2
  25. data/lib/samagotchi/web/public/participant.js +16 -7
  26. data/lib/samagotchi/web/public/participant_model.js +3 -2
  27. data/lib/samagotchi/web/public/participant_names.js +2 -2
  28. data/lib/samagotchi/web/public/question_card.js +4 -1
  29. data/lib/samagotchi/web/public/session_state.js +2 -0
  30. data/lib/samagotchi/web/public/stream_handlers.js +4 -2
  31. data/lib/samagotchi/web/public/verdict_card.js +18 -3
  32. data/lib/samagotchi/web/public/verdict_fresh.js +11 -0
  33. data/lib/samagotchi/web/public/verify_card.css +1 -0
  34. data/lib/samagotchi/web/public/verify_preset.js +6 -0
  35. data/lib/samagotchi/web/verify_fresh_routes.rb +12 -7
  36. metadata +2 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d35df5e2cd9639c1da812c221e1c256567df7e2f9986df32c6cbc49cc9d6d431
4
- data.tar.gz: 7e98badd5615015b9841a9885404c130ff46fddefd05dc70bafd051ae37f92ef
3
+ metadata.gz: 9b175cb8468dd1b6dccbb74c3c372b0796aa18126b47034d07d11d6ead1e1e22
4
+ data.tar.gz: e6b6ea98bf78a7a43962a70ab6daf3cd35519c7db1cefdae44ad8021d4274b75
5
5
  SHA512:
6
- metadata.gz: 3f5eaf2d9dca71dd6951989506b64c67ce905396c3af824521aa6fcc8e974d7a4f7ca6843f307f9915b5b5986b3ab87e2707ca76a771e985fa095a99acf9cedf
7
- data.tar.gz: 65b7f35bcd7372d6c51a438c3808937266b035e8e67ac6c48511734d00f78ac12b9de3536359c67ad8797a4dc542e16a04649ece8d0dc7e8e729e6b7f60dd0a2
6
+ metadata.gz: adcfe4e020e6829703e1b9e1d2836d53bb3ddc336da1001c598853352eef75a3ca3df25b7e4d07d292bdaf8b413be228e57487a8b570365e2fba4cecd98490f0
7
+ data.tar.gz: d4b57b741cd271740cb0826e0ff50845a5213c1de30b28c9477ba1b126e5448511f5b73861a3380ae2a93e4102e41df9d4093b14a193a61dcbdd7ed6894a11cd
data/CHANGELOG.md CHANGED
@@ -7,6 +7,29 @@ and commands may change between minor versions. How releases are made:
7
7
  [docs/releasing.md](docs/releasing.md).
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.128.0] - 2026-10-28
11
+
12
+ ### Changed
13
+
14
+ - Verify needs a worker that knows Verify turns: on an older worker chi web disables Verify and Verify fresh with
15
+ "restart this session to use Verify", and refuses anchored turns and fresh checks sent to it.
16
+ - `sqlite3 -readonly -safe …` (only with both flags and read/display options) and `gh`'s reading subcommands
17
+ (`run`/`pr`/`issue` list and view, `pr diff`/`checks`, `repo view`; never `--web`, `api` or `--watch`) count as
18
+ read-only shell commands, so they no longer ask.
19
+ - The shared composer card footer has less vertical padding on phones.
20
+
21
+ ### Fixed
22
+
23
+ - A Verify turn records a verdict only when its quote is in the reply of the turn it points at; otherwise it runs
24
+ as a plain turn.
25
+ - A verdict's View check opens the earlier session that holds the check (after a continue), or says the check is
26
+ no longer on the page, instead of doing nothing.
27
+ - An attached piped prompt that this terminal removed before it ran says so on stderr (still exit 1) instead of
28
+ "the model gave an empty answer".
29
+ - A participant's key hint wraps to a second line in a narrow comment box instead of being cut after one.
30
+
31
+ Update with `chi update`.
32
+
10
33
  ## [0.127.0] - 2026-10-28
11
34
 
12
35
  ### Added
@@ -4787,7 +4810,8 @@ and long-lived sessions.
4787
4810
  - A macOS desktop helper (`chi desktop install`): a "Send to chi" Service and a
4788
4811
  hotkey panel that send selected text or the clipboard to your sessions.
4789
4812
 
4790
- [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.127.0...HEAD
4813
+ [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.128.0...HEAD
4814
+ [0.128.0]: https://github.com/dm1try/samagotchi/compare/v0.127.0...v0.128.0
4791
4815
  [0.127.0]: https://github.com/dm1try/samagotchi/compare/v0.126.0...v0.127.0
4792
4816
  [0.126.0]: https://github.com/dm1try/samagotchi/compare/v0.125.0...v0.126.0
4793
4817
  [0.125.0]: https://github.com/dm1try/samagotchi/compare/v0.124.0...v0.125.0
data/docs/architecture.md CHANGED
@@ -173,6 +173,9 @@ agent logic and can be used without any terminal rendering; the UI is a thin lay
173
173
  but `RelineSeam` (prepended to `Reline::LineEditor`) sends its drawing to the
174
174
  `Screen`. The saved history and cards in a join or resync are rendered by
175
175
  `TerminalUI::JoinReplay` with the loop's screen, view, link, gap clock and renderer.
176
+ With input from a pipe (`chi -p X </dev/null`), `TerminalUI::OwnTurns` keeps this
177
+ run's own prompts until their turns end; `#run` distinguishes an empty answer from a
178
+ prompt this terminal removed before it ran (both exit 1), as well as chi's other outcomes.
176
179
  Without a capable terminal it falls back to `PlainSurface` (append-only).
177
180
 
178
181
  #### Using the core
data/docs/guardrails.md CHANGED
@@ -557,6 +557,11 @@ every command in it (split at `;`, `&&`, `||`, `|`) is `ls`, `cat`, `head`,
557
557
  writes or runs something (`sed -i`, `sed 's/a/b/w f'`, `find -delete`/`-exec`,
558
558
  `rg --pre`, `sort -o`, `awk` with `>`/`|`/`system(`, `git -c`, `git diff
559
559
  --ext-diff`, …) and no redirection other than to `/dev/null` or another fd.
560
+ `sqlite3` is read-only only with both `-readonly` and `-safe`; options are
561
+ checked even after the database name, and only its read/display options are
562
+ allowed (unknown options and dot-command arguments are rejected). `gh` allows
563
+ only `run list|view`, `pr list|view|diff|checks`, `issue list|view` and
564
+ `repo view`; `--web`/`-w`, `gh api` and `pr checks --watch` are not read-only.
560
565
  `$(…)`, backticks, `$VAR` (other than `$HOME`, `$XDG_*`, `$TMPDIR` and `$PWD`
561
566
  at a word's start), an env prefix, `sh -c`, `xargs`, `eval`, `sudo` and the
562
567
  like make a command not read-only. It errs towards asking.
@@ -362,7 +362,7 @@ fields: a field added to an event later reaches nobody until it is named there.
362
362
  owner's participant view has none. The page draws it as one line in the comment box card's footer
363
363
  (`.composer-card-foot`, `usageItems`): "$0.42 of
364
364
  $2.00 cap · 1/3 queued · link: 6 d left · model", each with a title in words; warn colours from 80% of the cap, a
365
- full queue and a link's last day, "over" at the cap. The key hint is the line above the text field (hidden on a
365
+ full queue and a link's last day, "over" at the cap. The key hint is the line above the text field, wrapping to at most two (hidden on a
366
366
  phone). The usage is cut at the end (the model first); folded, it stays as the card's footer under the grip. It is redrawn at each re-read (a
367
367
  turn's end, a queued or taken-back comment), not while a turn streams.
368
368
  - **A fork's note.** A review fork (`chi review --from`, `/coordinate review`) starts with a note holding the source's
@@ -420,7 +420,11 @@ fields: a field added to an event later reaches nobody until it is named there.
420
420
  - **Verify verdict cards** (`verdict_card.js`). The built-in Verify preset carries the selected source turn id and quote
421
421
  with the submitted turn, without changing its prompt text. A Verify turn that fails fast and is rolled back hands
422
422
  the claim back with its prompt (`prompt_restored`'s `verify`), so the owner's resend from the composer is a Verify
423
- turn again. A completed Verify reply whose final non-empty line has
423
+ turn again. Before the Engine stamps the anchor or records a verdict, it checks that the source turn exists and
424
+ its assistant reply contains the quote. Matching is case-sensitive, collapses whitespace, and strips common
425
+ Markdown presentation (links, headings, list/blockquote prefixes, emphasis and code delimiters) from both sides;
426
+ this matches the rendered text the page selects without treating different casing as the same claim. A fresh child
427
+ checks the same anchor against its parent's history. A completed Verify reply whose final non-empty line has
424
428
  a valid `Verdict: confirmed|refuted|unclear — <reason>` appends a verdict to the session. Both pages place its card
425
429
  after the source turn, name its author, show the reason and quote, and link to the checking turn; a live completion
426
430
  adds the card without a reload. The participant view receives only the card fields through its allowlist, with the
data/docs/testing.md CHANGED
@@ -255,14 +255,15 @@ well formed (unique ids, known keys and labels, a rule that exists in its set).
255
255
  ## While you work: affected specs
256
256
 
257
257
  ```sh
258
- script/affected-specs # the specs for this branch's change, best first, at most 10
258
+ script/affected-specs # the specs for this branch's change, best first, at most 16
259
259
  bundle exec rspec $(script/affected-specs)
260
260
  script/affected-specs --why # with the reason for each pick; --all: no cap; --run: runs them
261
261
  script/affected-specs A..B # another range (A...B: from their merge-base); a single REV: vs the working tree
262
262
  ```
263
263
 
264
264
  A builder runs these while working, not the full suite: the specs the change touched, the specs named after the
265
- files it changed (`lib/samagotchi/foo/bar.rb` → `spec/foo/bar_spec.rb`, `bar_*_spec.rb`…), the specs that read
265
+ files it changed (`lib/samagotchi/foo/bar.rb` → `spec/foo/bar_spec.rb`, `bar_*_spec.rb`…;
266
+ `lib/samagotchi/foo/bar/baz.rb` → `spec/foo/bar_baz_spec.rb`), the specs that read
266
267
  docs or source as text (its `RULES`: add one when a full run catches what the names missed) and the golden of a changed
267
268
  file's layer (any `terminal_ui/*.rb` → `spec/terminal_ui_golden_spec.rb`; `turn_flow.rb` and `turn_note.rb`, whose
268
269
  words the REPL prints, pick it by a rule). Changes to `tool_declarations.rb` and `system_prompt.rb` also pick the
@@ -11,6 +11,7 @@ require_relative "atomic_file"
11
11
  require_relative "bridge_secret"
12
12
  require_relative "delivery"
13
13
  require_relative "session_inbox"
14
+ require_relative "verify_anchor"
14
15
 
15
16
  require_relative "bridge/bounded_queue"
16
17
  require_relative "bridge/card_store"
@@ -633,6 +634,7 @@ module Samagotchi
633
634
  # turn (Guardrails::PairBoundary); a worker without it runs it as a
634
635
  # review turn, and chi web says so (Delivery::PAIR_FEATURE).
635
636
  FEATURES = ["restart", "task_stop", "reparent", Delivery::QUEUE_FEATURE, ReplyWait::TURN_IDS_FEATURE,
637
+ VerifyAnchor::FEATURE,
636
638
  SessionInbox::UNQUEUE_FEATURE, Delivery::REVIEW_FEATURE, Delivery::PAIR_FEATURE].freeze
637
639
 
638
640
  # The routes, by method and what follows /session/:id/: a handler takes
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: samagotchi-system
3
- version: 0.127.0
3
+ version: 0.128.0
4
4
  scope: system
5
5
  description: Default system memories — identity, self map, config modification protocol, memory guide and the delegated-session rules
6
6
  files:
@@ -1620,6 +1620,61 @@ module Samagotchi
1620
1620
  end
1621
1621
  private :stamp_verify_anchor
1622
1622
 
1623
+ def verified_anchor(session, anchor)
1624
+ return unless anchor
1625
+
1626
+ source = verify_anchor_source_session(session, anchor)
1627
+ return unless source
1628
+
1629
+ turn = AnswerTail.turn_slice(Array(source.messages), anchor.turn_id)
1630
+ return reject_verify_anchor(anchor, :turn_missing) unless turn
1631
+
1632
+ reply = turn.reverse.find { |message| AnswerTail.answer?(message) }
1633
+ return reject_verify_anchor(anchor, :reply_missing) unless reply
1634
+
1635
+ quote_text = verify_comparison_text(anchor.quote)
1636
+ answer_text = verify_comparison_text(reply[:content] || reply["content"])
1637
+ return reject_verify_anchor(anchor, :quote_missing) if quote_text.empty? || !answer_text.include?(quote_text)
1638
+
1639
+ anchor
1640
+ end
1641
+ private :verified_anchor
1642
+
1643
+ def verify_anchor_source_session(session, anchor)
1644
+ return session unless session.verify_fresh
1645
+
1646
+ fresh = session.verify_fresh
1647
+ return reject_verify_anchor(anchor, :fresh_anchor_mismatch) unless fresh.anchor == anchor
1648
+ return reject_verify_anchor(anchor, :fresh_parent_missing) unless session.parent_id
1649
+
1650
+ Session.load(session.parent_id, state_dir: session_state_dir)
1651
+ rescue ArgumentError, SystemCallError, IOError => e
1652
+ Log.warn(:turn, "verify_anchor_rejected", turn_id: anchor.turn_id, reason: :source_session_unavailable,
1653
+ error: e.class.name)
1654
+ nil
1655
+ end
1656
+ private :verify_anchor_source_session
1657
+
1658
+ def verify_comparison_text(text)
1659
+ OutputFormatter.strip_markup(text)
1660
+ .gsub(/!\[([^\]]*)\]\([^)]+\)/, '\1')
1661
+ .gsub(/\[([^\]]+)\]\([^)]+\)/, '\1')
1662
+ .gsub(/^[ \t]*(?:\#{1,6}[ \t]+|>[ \t]?|[-+*][ \t]+|\d+[.)][ \t]+)/, "")
1663
+ .gsub(/(\*\*|__|~~|`+)(.+?)\1/m, '\2')
1664
+ .gsub(/(?<!\w)\*(\S(?:.*?\S)?)\*(?!\w)/m, '\1')
1665
+ .gsub(/(?<!\w)_(\S(?:.*?\S)?)_(?!\w)/m, '\1')
1666
+ .tr("|", " ") # a table row's cells; the page copies them without the pipes
1667
+ .gsub(/\s+/, " ")
1668
+ .strip
1669
+ end
1670
+ private :verify_comparison_text
1671
+
1672
+ def reject_verify_anchor(anchor, reason)
1673
+ Log.warn(:turn, "verify_anchor_rejected", turn_id: anchor.turn_id, reason:)
1674
+ nil
1675
+ end
1676
+ private :reject_verify_anchor
1677
+
1623
1678
  def record_verdict(turn, outcome)
1624
1679
  return unless outcome == "completed" && turn.verify
1625
1680
 
@@ -2170,6 +2225,7 @@ module Samagotchi
2170
2225
  # A participant's Verify fresh child: every turn is theirs (their
2171
2226
  # author, boundary and cap), whoever started it.
2172
2227
  origin = session.verify_fresh.origin_for(origin) if session.respond_to?(:verify_fresh) && session.verify_fresh
2228
+ verify = verified_anchor(session, VerifyAnchor.from(verify))
2173
2229
  # Track the active session for recap and status snapshot.
2174
2230
  @session = session
2175
2231
  # status is turn state: running now, idle again before the turn's end
@@ -130,6 +130,53 @@ module Samagotchi
130
130
  # uniq IN OUT writes OUT.
131
131
  def uniq(args) = args.reject { |a| a.start_with?("-") }.size <= 1
132
132
 
133
+ SQLITE_OPTIONS = %w[-readonly -safe -json -csv -header -noheader -line -column -box -list -separator -bail
134
+ -batch -noinit].freeze
135
+
136
+ # sqlite3 can still write or run programs unless both safety flags are
137
+ # present. Its options may follow the database name, so check every word.
138
+ def sqlite3(args)
139
+ options = []
140
+ positional = []
141
+ index = 0
142
+ while index < args.length
143
+ arg = args[index]
144
+ if arg == "-separator"
145
+ return false if index + 1 == args.length
146
+
147
+ index += 2
148
+ elsif SQLITE_OPTIONS.include?(arg)
149
+ options << arg
150
+ index += 1
151
+ elsif arg.start_with?("-", ".")
152
+ return false
153
+ else
154
+ positional << arg
155
+ index += 1
156
+ end
157
+ end
158
+
159
+ options.include?("-readonly") && options.include?("-safe") && positional.size >= 2
160
+ end
161
+
162
+ GH_READS = {
163
+ "run" => %w[list view],
164
+ "pr" => %w[list view diff checks],
165
+ "issue" => %w[list view],
166
+ "repo" => %w[view]
167
+ }.freeze
168
+
169
+ def gh(args)
170
+ # -w alone or in a bundle of short flags (-cw) opens a browser.
171
+ return false if args.any? { |arg| arg.match?(/\A-[a-zA-Z]*w/) || arg.start_with?("--web") }
172
+
173
+ group, action = args
174
+ return false unless GH_READS.fetch(group, []).include?(action)
175
+ return false if group == "pr" && action == "checks" && args.any? { |arg| arg.start_with?("--watch") }
176
+
177
+ true
178
+ end
179
+
133
180
  SED_FLAGS = /\A-[nErsuz]+\z/
134
181
  SED_LONG = %w[--quiet --silent --regexp-extended --separate --unbuffered --null-data --posix --debug
135
182
  --sandbox].freeze
@@ -228,7 +275,7 @@ module Samagotchi
228
275
  CHECKS = {
229
276
  "rg" => method(:rg), "find" => method(:find), "tree" => method(:tree), "file" => method(:file),
230
277
  "sort" => method(:sort), "uniq" => method(:uniq), "sed" => method(:sed), "awk" => method(:awk),
231
- "git" => method(:git)
278
+ "git" => method(:git), "sqlite3" => method(:sqlite3), "gh" => method(:gh)
232
279
  }.freeze
233
280
 
234
281
  # A sed script parsed far enough to tell that it only reads: its
@@ -1408,6 +1408,7 @@ module Samagotchi
1408
1408
  end
1409
1409
  manager.resume_session(session_id, state_dir: state_dir)
1410
1410
  if (client = bridge.call)
1411
+ return verify_unavailable if verify && !worker_has?(session_dir, VerifyAnchor::FEATURE)
1411
1412
  # A worker from before queueing (no "delivery" feature) ignores the
1412
1413
  # field or refuses it; either way a file would steer the running turn.
1413
1414
  return queue_unavailable if Delivery.queue?(delivery) && !worker_queues?(session_dir)
@@ -1533,6 +1534,10 @@ module Samagotchi
1533
1534
  DeliverResult.new(status: :refused, code: 409, ack: Delivery::REVIEW_UNAVAILABLE)
1534
1535
  end
1535
1536
 
1537
+ private_class_method def self.verify_unavailable
1538
+ DeliverResult.new(status: :refused, code: 409, ack: VerifyAnchor::UNAVAILABLE)
1539
+ end
1540
+
1536
1541
  private_class_method def self.turn_timeout
1537
1542
  DeliverResult.new(status: :timeout, via: :bridge,
1538
1543
  ack: { "error" => "worker_timeout",
@@ -71,7 +71,8 @@ module Samagotchi
71
71
  # TUI's streams and requests there (#client_id)
72
72
  # @return [Symbol] :detached, :closed when the worker went away,
73
73
  # :failed when the --model switch didn't go through, or (input from a
74
- # pipe) :turn_failed / :empty_answer / :unanswered (AttachedLoop#run)
74
+ # pipe) :turn_failed / :empty_answer / :removed / :unanswered
75
+ # (AttachedLoop#run)
75
76
  def run(attach: nil, shared: false, resume: nil, prompt: nil, model: nil, no_interrupt: false, default_input: true,
76
77
  memories: [], muted_memories: [], llm_context: {}, thinking: nil, appended_prompt_files: [], toolkit: nil,
77
78
  toolkit_flags: [], settings: nil, parent_answers: nil, state_dir: nil, connector: nil)
@@ -146,11 +147,14 @@ module Samagotchi
146
147
  err.flush
147
148
  end
148
149
 
149
- # A turn of ours that ended with no answer (input from a pipe): one
150
- # line on stderr, as the in-process `chi -p --non-interactive` says
151
- # it, so a script doesn't take the silence for an answer (exit 1).
150
+ # An own turn with no answer, or a prompt removed before it ran
151
+ # (input from a pipe): one line on stderr, so a script doesn't take
152
+ # the silence for an answer (exit 1).
152
153
  def report_ended(ended, err: $stderr)
153
- err.puts(TerminalUI::EMPTY_ANSWER_ERROR) if ended == :empty_answer
154
+ case ended
155
+ when :empty_answer then err.puts(TerminalUI::EMPTY_ANSWER_ERROR)
156
+ when :removed then err.puts("chi: the prompt was removed before it ran")
157
+ end
154
158
  end
155
159
 
156
160
  # chi's exit status after #run: 0 detached, 3 a question left waiting
@@ -7,8 +7,8 @@ module Samagotchi
7
7
  class AttachedLoop
8
8
  # Queue and unqueue handling for the attached loop.
9
9
  # Reads @client, @client_id, @first_command_id, @link, @my_queued,
10
- # @open_ids, @reader, @screen, @shown_enqueued, @wait_at_eof; writes
11
- # @my_queued, @open_ids, @own_empty, @own_failed, @prefilled, @queued_ids,
10
+ # @own_turns, @reader, @screen, @shown_enqueued, @wait_at_eof; writes
11
+ # @my_queued, @prefilled, @queued_ids,
12
12
  # @shown_enqueued.
13
13
  # Calls the loop helpers #author_of, #first_command_flag, #mark_activity,
14
14
  # #own?, #prompt_line, #send_prompt, #via, and #worker_down.
@@ -131,19 +131,13 @@ module Samagotchi
131
131
 
132
132
  # A queued prompt was taken back before it ran (the web's ×, /unqueue
133
133
  # in a terminal). One of this run's (wait_at_eof) will have no turn:
134
- # it ends the wait, as a failed one does. Another client's shown
135
- # line says so.
134
+ # it ends the wait as :removed. Another client's shown line says so.
136
135
  def turn_dequeued(event)
137
136
  id = event[:enqueued_id]
138
137
  @my_queued.delete(id)
139
- if @open_ids.delete?(id)
140
- if own?(event[:client_id])
141
- @own_empty = true
142
- @screen.commit("(removed before it ran)")
143
- else
144
- @own_failed = true
145
- @screen.commit("(your queued message was removed before it ran)")
146
- end
138
+ by_us = own?(event[:client_id])
139
+ if @own_turns.dequeued(id, by_us: by_us)
140
+ @screen.commit(by_us ? "(removed before it ran)" : "(your queued message was removed before it ran)")
147
141
  elsif @shown_enqueued.delete?(id)
148
142
  @screen.commit("(a queued message was removed)")
149
143
  end
@@ -10,19 +10,16 @@ require_relative "../image_input"
10
10
  module Samagotchi
11
11
  class TerminalUI
12
12
  class AttachedLoop
13
- # Bridge sending, first-command launch, and this run's turn ledger.
14
- # Reads @as_parent, @client, @client_id, @display_pending, @first_command_id,
15
- # @first_command_line, @first_commands, @first_prompt, @input_ended, @link,
16
- # @merged_ids, @my_queued, @no_interrupt, @open_ids, @own_empty, @own_failed,
17
- # @queued_ids, @reader, @running, @screen, @sent_ids, @status, @wait_at_eof;
18
- # writes @display_pending, @first_command_id, @first_command_line,
19
- # @first_commands, @first_prompt, @merged_ids, @my_queued, @open_ids,
20
- # @own_empty, @own_failed, @queued_ids, @sent_ids.
21
- # Calls the loop helpers #client_id_for, #command_registry, #default_model_name,
22
- # #detach, #mark_activity, #older_worker_command, #origin_of, #own?, #paint,
23
- # #persist_recent_history, #prompt_line, #put_back_busy_command, #prefill_confirm,
24
- # #queue_needs_newer_worker, #send_command, #send_prompt, #stale_worker,
25
- # #steer_cut_note, #too_late?, #via, and #worker_down.
13
+ # Bridge sending and first-command launch.
14
+ # Reads @as_parent, @client, @client_id, @first_command_id, @first_command_line,
15
+ # @first_commands, @first_prompt, @link, @my_queued, @no_interrupt, @own_turns,
16
+ # @queued_ids, @reader, @running, @screen, @status;
17
+ # writes @first_command_id, @first_command_line, @first_commands, @first_prompt,
18
+ # @my_queued, @queued_ids.
19
+ # Calls the loop helpers #command_registry, #default_model_name, #detach,
20
+ # #mark_activity, #older_worker_command, #origin_of, #own?, #paint,
21
+ # #persist_recent_history, #prompt_line, #queue_needs_newer_worker,
22
+ # #stale_worker, #steer_cut_note, #too_late?, #via, and #worker_down.
26
23
  module Sending
27
24
  private
28
25
 
@@ -33,7 +30,7 @@ module Samagotchi
33
30
  if reply.status == 202
34
31
  # Input from a pipe ends once its output is in (#command_ran).
35
32
  command_id = reply.json&.fetch("command_id", nil)
36
- @open_ids << command_id if command_id && @wait_at_eof
33
+ @own_turns.command_sent(command_id) if command_id
37
34
  return
38
35
  end
39
36
  return @screen.commit(stale_worker("run commands")) if reply.status == 404
@@ -49,7 +46,7 @@ module Samagotchi
49
46
  end
50
47
 
51
48
  def command_ran(event)
52
- @open_ids.delete(event[:command_id])
49
+ @own_turns.command_ran(event[:command_id])
53
50
  @queued_ids.delete(event[:command_id])
54
51
  unless own?(event[:client_id]) || event[:anytime] || event[:card] || event[:queued]
55
52
  @screen.commit(prompt_line(event[:client_id], event[:line]))
@@ -147,8 +144,7 @@ module Samagotchi
147
144
  reply = @client.post_turn(**options)
148
145
  if reply.status == 202
149
146
  enqueued_id = reply.json&.fetch("enqueued_id", nil)
150
- @sent_ids << enqueued_id if enqueued_id
151
- @open_ids << enqueued_id if enqueued_id && @wait_at_eof
147
+ @own_turns.sent(enqueued_id) if enqueued_id
152
148
  @my_queued << enqueued_id if enqueued_id && reply.json&.fetch("delivery", nil) == Delivery::QUEUE
153
149
  note = sent_note(reply.json, delivery: delivery, images: images, running: running, choice: choice)
154
150
  @screen.commit(note) if note
@@ -172,21 +168,15 @@ module Samagotchi
172
168
  # Whether the input's end must wait: the -p prompt isn't sent yet, a
173
169
  # prompt this run sent hasn't had its turn, or its turn's after_turn
174
170
  # hooks still run (their notices, source-links' sources:, come next).
175
- def own_turns_pending? = !@first_prompt.to_s.strip.empty? || !@open_ids.empty? || @display_pending == true
171
+ def own_turns_pending? = !@first_prompt.to_s.strip.empty? || @own_turns.pending?
176
172
 
177
173
  # A turn ended: this run's prompt (and ours merged into it) had its turn.
178
174
  def own_turn_ended(event)
179
- return unless @wait_at_eof
180
-
181
- id = origin_of(event[:origin]).enqueued_id
182
- ours = @open_ids.include?(id) || !@merged_ids.empty?
183
- @own_failed = true if ours && event[:type] == :turn_failed
184
- @own_empty = true if ours && event[:type] == :turn_completed && event.dig(:turn_summary, :empty_answer)
185
- # The worker sends answer_display once its after_turn hooks ran.
186
- @display_pending = true if ours && event[:type] == :turn_completed && event[:display_pending]
187
- @open_ids.delete(id)
188
- @open_ids.subtract(@merged_ids)
189
- @merged_ids.clear
175
+ completed = event[:type] == :turn_completed
176
+ @own_turns.ended(origin_of(event[:origin]).enqueued_id,
177
+ failed: event[:type] == :turn_failed,
178
+ empty: completed && event.dig(:turn_summary, :empty_answer),
179
+ display_pending: completed && event[:display_pending])
190
180
  end
191
181
 
192
182
  # Ours merged into the running turn: they end with it.
@@ -194,22 +184,20 @@ module Samagotchi
194
184
  Array(event[:origins]).each do |value|
195
185
  origin = origin_of(value)
196
186
  id = origin.enqueued_id
197
- @merged_ids << id if own?(origin.client_id) && @open_ids.include?(id)
187
+ @own_turns.merged(id) if own?(origin.client_id)
198
188
  end
199
189
  nil
200
190
  end
201
191
 
202
192
  def own_prompt_failed
203
- @own_failed = true if @wait_at_eof
193
+ @own_turns.prompt_failed
204
194
  nil
205
195
  end
206
196
 
207
197
  # The input ended and nothing of ours is left to wait for.
208
198
  def end_of_input
209
- detach("Detached; the session keeps running. Re-attach with: #{via.attach(@client.session_id)}") if @input_ended
210
- return :turn_failed if @own_failed
211
-
212
- @own_empty ? :empty_answer : :detached
199
+ detach("Detached; the session keeps running. Re-attach with: #{via.attach(@client.session_id)}") if @own_turns.input_ended?
200
+ @own_turns.outcome
213
201
  end
214
202
 
215
203
  # The prompt's `@path` images, stored where the worker reads them
@@ -14,6 +14,7 @@ require_relative "image_input"
14
14
  require_relative "line_reader"
15
15
  require_relative "question_prompt"
16
16
  require_relative "join_replay"
17
+ require_relative "own_turns"
17
18
  require_relative "exit_lines"
18
19
  require_relative "attached_loop/exit_commands"
19
20
  require_relative "attached_loop/questions"
@@ -68,8 +69,7 @@ module Samagotchi
68
69
  attr_reader :client_id
69
70
 
70
71
  # @return [String, nil] the model the worker's turns run on, as the
71
- # last command said
72
- # @return [String, nil] the worker's model
72
+ # last snapshot or model change said (the status row's)
73
73
  def model_name = @status[:model]
74
74
 
75
75
  # @return [QuestionPrompt, nil] the question waiting for an answer
@@ -152,21 +152,12 @@ module Samagotchi
152
152
  @join_replay = JoinReplay.new(screen: @screen, view: @view, link: @link, gaps: @gaps, renderer: @renderer)
153
153
  @last_idle_interrupt_at = nil
154
154
  @next_input_prefill = nil
155
- # Prompts this run sent, by enqueued_id: only those come back into
156
- # the input when their turn fails (a replayed event must not).
157
- @sent_ids = Set.new
158
155
  # This run's prompts queued for after the running turn (their ack
159
156
  # said queue), oldest first, until their turn starts or they are
160
157
  # removed: /unqueue takes back the newest.
161
158
  @my_queued = []
162
159
  @wait_at_eof = wait_at_eof
163
- # Sent and not ended yet (by enqueued_id), the ones merged into the
164
- # running turn, whether the input has ended, and whether one of this
165
- # run's prompts failed (wait_at_eof only).
166
- @open_ids = Set.new
167
- @merged_ids = Set.new
168
- @input_ended = false
169
- @own_failed = false
160
+ @own_turns = OwnTurns.new(active: wait_at_eof)
170
161
  # A continue offer is pending: the prompt asks for the answer.
171
162
  @continue_offer = nil
172
163
  # The /exit ask is open: the next line is its answer (#answer_exit_ask).
@@ -188,7 +179,8 @@ module Samagotchi
188
179
  # :failed when the first command (--model) didn't go through, or
189
180
  # (wait_at_eof) :turn_failed when one of this run's prompts failed,
190
181
  # :empty_answer when one ended with no answer, and :unanswered when
191
- # a question came after the input ended
182
+ # a question came after the input ended; :removed when this run took
183
+ # back its only queued prompt before it ran
192
184
  # @param input [#call, nil] prompt -> line (nil = Ctrl-D, raising
193
185
  # Interrupt = Ctrl-C); defaults to Reline
194
186
  def run(input: nil)
@@ -225,7 +217,7 @@ module Samagotchi
225
217
 
226
218
  ended = take_early_lines
227
219
  return ended if ended
228
- return end_of_input if @input_ended && !own_turns_pending?
220
+ return end_of_input if @own_turns.input_ended? && !own_turns_pending?
229
221
  when :line
230
222
  # A pipe is read at once: its lines wait for the joining
231
223
  # snapshot, which may bring the question they answer.
@@ -256,7 +248,7 @@ module Samagotchi
256
248
  # has nothing left to answer it.
257
249
  return unanswered_question(@question_pending) if @question
258
250
 
259
- @input_ended = true
251
+ @own_turns.end_input
260
252
  return nil
261
253
  end
262
254
  end_of_input if submit(line) == :detach
@@ -303,7 +295,7 @@ module Samagotchi
303
295
  own_turn_ended(event)
304
296
  when :prompt_restored then restore_prompt(event)
305
297
  # The after_turn hooks are done (their notices came before it).
306
- when :answer_display then @display_pending = false
298
+ when :answer_display then @own_turns.displayed
307
299
  when :context_status, :used_memories_updated then @status.take_event(event)
308
300
  when :participants_changed then participants_changed(event)
309
301
  # Another client's anytime command: its line now, before the cards
@@ -333,7 +325,7 @@ module Samagotchi
333
325
  refresh_children if Array(event[:origins]).any? { |origin| report_origin?(origin_of(origin)) }
334
326
  when :question_requested
335
327
  # Nothing is left to answer it with.
336
- return unanswered_question(event[:pending_question]) if @input_ended
328
+ return unanswered_question(event[:pending_question]) if @own_turns.input_ended?
337
329
 
338
330
  ask(event[:pending_question])
339
331
  when :question_answered then question_answered(event)
@@ -515,7 +507,7 @@ module Samagotchi
515
507
  # goes back into the input, as the REPL restores it for a retry.
516
508
  def restore_prompt(event)
517
509
  origin = origin_of(event[:origin])
518
- return unless own?(origin.client_id) && @sent_ids.include?(origin.enqueued_id)
510
+ return unless own?(origin.client_id) && @own_turns.sent?(origin.enqueued_id)
519
511
  # Lines from a pipe: nobody to retry it (the REPL's rule).
520
512
  return if @wait_at_eof
521
513
 
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ class TerminalUI
5
+ # This run's own prompts and commands. Sent prompts are always recorded so
6
+ # only this run's failed prompts can return to the input. With pipe input
7
+ # (+active+), this also tracks turns still open, prompts merged into a
8
+ # running turn, whether input ended, and whether a turn failed, was empty, was removed,
9
+ # or still has an answer display pending. #outcome is what #run returns
10
+ # once those turns finish.
11
+ class OwnTurns
12
+ def initialize(active:)
13
+ @active = active
14
+ @sent = Set.new
15
+ @open = Set.new
16
+ @merged = Set.new
17
+ @input_ended = false
18
+ @failed = false
19
+ @empty = false
20
+ @removed = false
21
+ @display_pending = false
22
+ end
23
+
24
+ def sent(enqueued_id)
25
+ @sent << enqueued_id
26
+ @open << enqueued_id if @active
27
+ end
28
+
29
+ def sent?(enqueued_id) = @sent.include?(enqueued_id)
30
+
31
+ def command_sent(command_id)
32
+ @open << command_id if @active
33
+ end
34
+
35
+ def command_ran(command_id) = @open.delete(command_id)
36
+
37
+ def merged(enqueued_id)
38
+ @merged << enqueued_id if @open.include?(enqueued_id)
39
+ end
40
+
41
+ def ended(enqueued_id, failed:, empty:, display_pending:)
42
+ return unless @active
43
+
44
+ ours = @open.include?(enqueued_id) || !@merged.empty?
45
+ @failed = true if ours && failed
46
+ @empty = true if ours && empty
47
+ @display_pending = true if ours && display_pending
48
+ @open.delete(enqueued_id)
49
+ @open.subtract(@merged)
50
+ @merged.clear
51
+ end
52
+
53
+ def displayed = (@display_pending = false)
54
+
55
+ def prompt_failed
56
+ @failed = true if @active
57
+ end
58
+
59
+ def dequeued(enqueued_id, by_us:)
60
+ return false unless @open.delete?(enqueued_id)
61
+
62
+ by_us ? (@removed = true) : (@failed = true)
63
+ true
64
+ end
65
+
66
+ def end_input = (@input_ended = true)
67
+
68
+ def input_ended? = @input_ended
69
+
70
+ def pending? = !@open.empty? || @display_pending
71
+
72
+ def outcome
73
+ return :turn_failed if @failed
74
+ return :empty_answer if @empty
75
+ return :removed if @removed
76
+
77
+ :detached
78
+ end
79
+ end
80
+ end
81
+ end
@@ -25,4 +25,12 @@ module Samagotchi
25
25
 
26
26
  def to_file = { "turn_id" => turn_id, "quote" => quote }
27
27
  end
28
+
29
+ VerifyAnchor::FEATURE = "verify"
30
+ VerifyAnchor::UNAVAILABLE = {
31
+ "error" => "verify_unavailable",
32
+ "detail" => "this session's worker predates Verify turns; restart this session to use Verify"
33
+ }.freeze
34
+
35
+ def VerifyAnchor.worker_unavailable?(features) = !features.nil? && !features.include?(VerifyAnchor::FEATURE)
28
36
  end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.127.0"
4
+ VERSION = "0.128.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
@@ -51,7 +51,8 @@ module Samagotchi
51
51
  # current_turn, waiting_for_owner, queued: {mine, others}, last_event_id}
52
52
  def handle_review(req, id)
53
53
  session = @session_class.load(id, state_dir: default_state_dir)
54
- mode = ParticipantMode.live(session, state_dir: default_state_dir).to_s
54
+ features = ParticipantMode.live_features(session, default_state_dir)
55
+ mode = ParticipantMode.of(session, features: features).to_s
55
56
  viewer = participant_viewer(req, session)
56
57
  live = bridge_get_json(id, "snapshot")
57
58
  turn_snapshot = live && live["snapshot"]
@@ -67,7 +68,8 @@ module Samagotchi
67
68
  status = Session::STATUS_IDLE if status == Session::STATUS_RUNNING && current.nil?
68
69
  json_response(200, {
69
70
  viewer: review_viewer(req, id, session: session, state: state, queued: queued, viewer: viewer, mode: mode),
70
- session: { id: session.id, title: participant_title(session), status: status },
71
+ session: { id: session.id, title: participant_title(session), status: status,
72
+ verify_unavailable: VerifyAnchor.worker_unavailable?(features) },
71
73
  subjects: review_subjects(session, viewer),
72
74
  participants: participant_names(id).map { |entry| entry.slice(:id, :name, :kind) },
73
75
  messages: with_reply_html(ParticipantView.messages(shown, viewer, automatic: automatic)),
@@ -32,6 +32,7 @@ import { actionEnabled, actionTitle, sameActions, selectionFields, splitInline }
32
32
  // until its step closes)
33
33
  // @param onQuote (text) runs with the quote (and a preset's note) to append
34
34
  // to the composer; the selection is cleared and the bar hidden first
35
+ // @param disabledPreset (preset) → a reason to disable it, else null
35
36
  // @param actions () => the open session's annotation actions
36
37
  // (annotate_actions.js annotationActions), read on every selection
37
38
  // @param running () => whether a turn runs (a send pill may wait for it)
@@ -56,7 +57,8 @@ import { actionEnabled, actionTitle, sameActions, selectionFields, splitInline }
56
57
  // @return {schedule(), hide(), el}; schedule() re-reads the selection on the
57
58
  // next frame (call it on scroll)
58
59
  export function createAnnotateBar({
59
- presets, canAnnotate, roots, isLive, onQuote, onPreset = () => {}, actions = () => [], running = () => false, onAction = () => {},
60
+ presets, canAnnotate, roots, isLive, onQuote, onPreset = () => {}, disabledPreset = () => null,
61
+ actions = () => [], running = () => false, onAction = () => {},
60
62
  dock = () => null, source: sourceOf = annotationSource, quote = quoteBlock, link = () => null, onLink = () => {},
61
63
  doc = globalThis.document, win = globalThis.window,
62
64
  }) {
@@ -80,6 +82,14 @@ export function createAnnotateBar({
80
82
  btn.title = text;
81
83
  bar.appendChild(btn);
82
84
  });
85
+ const presetButtons = [...bar.children].slice(1, presets.length + 1);
86
+ function markPresets() {
87
+ presetButtons.forEach((btn, i) => {
88
+ const reason = disabledPreset(presets[i]);
89
+ btn.disabled = !!reason;
90
+ btn.title = reason || presetParts(presets[i]).text;
91
+ });
92
+ }
83
93
  // The plugin pills: a separator, the inline ones, then ⋯ and the list
84
94
  // it opens. Redrawn only when the session's actions change.
85
95
  const group = doc.createElement("span");
@@ -175,6 +185,7 @@ export function createAnnotateBar({
175
185
  linkBtn.title = pendingLink?.title || "";
176
186
  const list = actions() || [];
177
187
  if (!sameActions(list, drawn)) drawActions(list);
188
+ markPresets();
178
189
  markActions();
179
190
  bar.hidden = false;
180
191
  if (touch?.matches) return placeDocked();
@@ -248,6 +259,7 @@ export function createAnnotateBar({
248
259
  return;
249
260
  }
250
261
  const preset = btn.dataset.index === undefined ? null : presets[Number(btn.dataset.index)];
262
+ if (btn.disabled || (preset && disabledPreset(preset))) return;
251
263
  const text = preset ? presetNote(pendingQuote, presetParts(preset).text) : pendingQuote;
252
264
  const fields = { ...pendingFields, source: pendingSource, range: pendingRange };
253
265
  win.getSelection()?.removeAllRanges();
@@ -256,7 +268,7 @@ export function createAnnotateBar({
256
268
  onQuote(text);
257
269
  });
258
270
 
259
- return { schedule, hide, el: bar };
271
+ return { schedule, hide, refresh: markPresets, el: bar };
260
272
  }
261
273
 
262
274
  // A preset's button label and note: a text is both.
@@ -59,12 +59,12 @@ import { createTaskStopper } from "./task_stop.js";
59
59
  import { createSystemOneWrongAction } from "./system_one_wrong.js";
60
60
  import { appendQuote } from "./annotations.js";
61
61
  import { applyComposerText, applyRowTemplate, fetchApply } from "./review_apply.js";
62
- import { nameVerdictCards } from "./verdict_card.js";
62
+ import { CHECK_NOT_ON_PAGE, nameVerdictCards, ownerCheckHash } from "./verdict_card.js";
63
63
  import { freshURL, placeVerdictWithFresh, settleRunning } from "./verdict_fresh.js";
64
64
  import { authorBadge, nameUnnamedBadges } from "./participant_names.js";
65
65
  import { parsePresets } from "./annotate_presets.js";
66
66
  import { createAnnotateBar } from "./annotate_bar.js";
67
- import { VERIFY_PRESET, withVerify } from "./verify_preset.js";
67
+ import { VERIFY_PRESET, VERIFY_UNAVAILABLE, verifyWorkerUnavailable, withVerify } from "./verify_preset.js";
68
68
  import { actionLine, annotationActions, composerWithAction } from "./annotate_actions.js";
69
69
  import { copyText, installCopy } from "./copy.js";
70
70
  import { createQuestionCards } from "./question_cards.js";
@@ -235,6 +235,7 @@ const composeHintEl = $("#composeHint");
235
235
 
236
236
  let selected = null;
237
237
  let pendingVerifyAnchor = null;
238
+ let annotateBar = null;
238
239
  // The session the composer's text was last written in ("" the start page,
239
240
  // undefined unknown): the composer is one for every session, and continue →
240
241
  // keeps only a draft of its own (fillOpener).
@@ -590,6 +591,7 @@ function followSelectedSession(sess) {
590
591
  current.owner = sess.owner || null;
591
592
  current.workerVersion = sess.worker_version || null;
592
593
  current.workerFeatures = Array.isArray(sess.worker_features) ? sess.worker_features : [];
594
+ current.bridgeUp = sess.bridge_up === true;
593
595
  const streaming = !!(streamControl && streamControl.live);
594
596
  if (!streaming) current.status = sess.status;
595
597
  updateInfoBar();
@@ -1344,6 +1346,11 @@ function placeVerdict(record) {
1344
1346
  placeVerdictWithFresh(historyEl, record, {
1345
1347
  who: badge ? badge.name : "You",
1346
1348
  author: record.by || null,
1349
+ onMissingCheck: (missing) => {
1350
+ const hash = ownerCheckHash(allSessions, selected, missing.check_turn_id);
1351
+ if (hash) location.hash = hash;
1352
+ else showToast(CHECK_NOT_ON_PAGE);
1353
+ },
1347
1354
  }, verifyFreshState());
1348
1355
  }
1349
1356
 
@@ -1351,11 +1358,13 @@ function placeVerdict(record) {
1351
1358
  // always theirs to start; its running check links to the child's session.
1352
1359
  function verifyFreshState() {
1353
1360
  const id = selected;
1361
+ const unavailable = verifyWorkerUnavailable(current.workerFeatures, current.bridgeUp);
1354
1362
  if (!Array.isArray(current.verifyFresh)) current.verifyFresh = [];
1355
1363
  return {
1356
1364
  verdicts: current.verdicts || [],
1357
1365
  running: current.verifyFresh,
1358
- canStart: !!id,
1366
+ canStart: !!id && !unavailable,
1367
+ unavailableReason: unavailable ? VERIFY_UNAVAILABLE : null,
1359
1368
  start: async (of) => {
1360
1369
  try {
1361
1370
  const body = await api(freshURL(id, of), { method: "POST", body: "{}" });
@@ -1421,6 +1430,7 @@ async function restartWorker() {
1421
1430
  // The session bar (info_bar_model.js sessionBar): its buttons, and the
1422
1431
  // info line rendered by info_bar.js.
1423
1432
  function updateInfoBar() {
1433
+ annotateBar?.refresh();
1424
1434
  const { buttons, bar } = sessionBar({
1425
1435
  id: selected, state: current, sessions: allSessions, versions, cardTitle: (s) => lists.cardTitle(s),
1426
1436
  continuing: sessionActions.continuing, restarting: restartingIds, streaming: !!streamControl, running: liveTurn.running,
@@ -1574,6 +1584,7 @@ function workerGone(id, { opened = true } = {}) {
1574
1584
  if (!stopped) setLiveStatus("idle");
1575
1585
  }
1576
1586
  if (current.owner === "worker") current.owner = null;
1587
+ current.bridgeUp = false;
1577
1588
  // Its card says "live" (the list's owner, or setLiveStatus's) until the
1578
1589
  // hub's event says otherwise: the worker is gone now, as far as this
1579
1590
  // tab can tell.
@@ -1956,8 +1967,10 @@ function fillComposer(text) {
1956
1967
  promptEl.scrollTop = promptEl.scrollHeight;
1957
1968
  }
1958
1969
 
1959
- const annotateBar = createAnnotateBar({
1970
+ annotateBar = createAnnotateBar({
1960
1971
  presets: withVerify(parsePresets(document.body.dataset.annotatePresets)),
1972
+ disabledPreset: (preset) => preset === VERIFY_PRESET
1973
+ && verifyWorkerUnavailable(current.workerFeatures, current.bridgeUp) ? VERIFY_UNAVAILABLE : null,
1961
1974
  canAnnotate: () => !!selected && current.owner !== "tui",
1962
1975
  roots: turnRoots,
1963
1976
  isLive: (root) => liveTurn.running && turnDom.containsLive(root),
@@ -53,6 +53,7 @@ body.has-session.composer-folded #composeGrip::after{opacity:1;width:48px;margin
53
53
  info bar's buttons while folded, the stage card's bottom in the stage view); it reaches 7 px below the edge, the info bar's
54
54
  border and padding, so it never covers a button of the bar. */
55
55
  @media(max-width:600px){
56
+ .composer-card-foot{padding-top:4px;padding-bottom:4px}
56
57
  #composeGrip{width:96px;height:26px;margin:-19px auto -7px}
57
58
  #composeGrip::after{top:17px;opacity:1}
58
59
  }
@@ -3,6 +3,7 @@
3
3
  // title badge for what it returns, while the tab is not in front.
4
4
 
5
5
  import { clip } from "./format.js";
6
+ import { isParticipantAuthor } from "./participant_model.js";
6
7
 
7
8
  // The bell's state. OS notifications need a secure context: on chi web
8
9
  // over http at a LAN address the API is missing or always denied, so the
@@ -164,7 +165,7 @@ export function attentionText(session, attention) {
164
165
  const seconds = Math.round(Number(session?.last_turn?.seconds) || 0);
165
166
  const verdict = session?.last_verdict;
166
167
  // An owner's verdict has no participant author (none, "" or "owner").
167
- const byParticipant = !!verdict?.by && verdict.by !== "owner";
168
+ const byParticipant = isParticipantAuthor(verdict?.by);
168
169
  const who = verdict?.by_name || (byParticipant ? "A participant" : "You");
169
170
  const verdictReason = clip(String(verdict?.reason || ""), VERDICT_REASON_MAX);
170
171
  const verdictIcon = { confirmed: "✅", refuted: "❌", unclear: "❓" }[verdict?.verdict] || "";
@@ -122,9 +122,10 @@ main{flex:1;min-height:0;overflow-y:auto;padding:16px 16px 8px}
122
122
  #composer textarea{font:inherit;line-height:1.45;padding:2px 0}
123
123
  #composer textarea:disabled{cursor:not-allowed}
124
124
  #composer .actions-col #send{height:30px;min-height:30px;line-height:30px;padding:0 16px;font-weight:600}
125
+ /* The key hint wraps to at most two lines (a narrow card cuts the second's end); the whole text is its title. */
126
+ .p-hint.composer-line{color:var(--text-faint);height:auto;max-height:36px;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;line-clamp:2}
125
127
  /* The participant's own usage (participant_model.js usageItems: the fields their grant's exposure shows), one line in
126
128
  the card's footer, the end cut. Folded, it stays under the grip. */
127
- .p-hint.composer-line{color:var(--text-faint);white-space:nowrap;text-overflow:ellipsis}
128
129
  .p-foot{min-width:0}
129
130
  .p-foot:has(> .p-usage[hidden]){display:none}
130
131
  /* Folded before the usage loads, the card keeps a footer's height under its grip, not a 2 px sliver. */
@@ -151,7 +152,7 @@ button.small{font-size:12px;padding:2px 8px}
151
152
  .p-people .p-person:nth-child(n+4){display:none}
152
153
  main{padding:12px 16px 6px}
153
154
  .p-bubble{padding:9px 12px}
154
- .p-hint,.p-note{display:none}
155
+ .p-hint.composer-line,.p-note{display:none}
155
156
  }
156
157
  /* Under 16px iOS Safari zooms the page on focus (and a Home Screen app never zooms back). */
157
158
  @media (max-width:600px),(pointer:coarse){#composer textarea{font-size:16px}}
@@ -12,7 +12,7 @@
12
12
  // (spend vs cap, queue, link expiry, model). The owner's participant view
13
13
  // mounts none.
14
14
  import {
15
- authorColor, participantAuthor, authorLabel, toolLine, refusalText, dequeueText, endedText, streamURL, reloads, endsLiveTurn, liveFrom, applyLive,
15
+ authorColor, participantAuthor, isParticipantAuthor, authorLabel, toolLine, refusalText, dequeueText, endedText, streamURL, reloads, endsLiveTurn, liveFrom, applyLive,
16
16
  liveText, withToolSteps, pickSubject, subjectBody, subjectsKey, narrowView, retryDelay, retries, threadGaps, usageItems,
17
17
  modeCopy,
18
18
  } from "./participant_model.js";
@@ -22,10 +22,11 @@ import { createAuthLost, lostAnswer } from "./auth_lost.js";
22
22
  import { REPLACE_SESSION_TEXT, fragmentCode, tradeFragmentCode } from "./token_fragment.js";
23
23
  import { createAnnotateBar } from "./annotate_bar.js";
24
24
  import { parsePresets } from "./annotate_presets.js";
25
- import { VERIFY_PRESET, withVerify } from "./verify_preset.js";
25
+ import { VERIFY_PRESET, VERIFY_UNAVAILABLE, withVerify } from "./verify_preset.js";
26
26
  import { appendQuote } from "./annotations.js";
27
27
  import { messageAuthorData, participantQuote, participantSource } from "./participant_annotate.js";
28
28
  import { claimFromHash, selectionClaim, showClaim } from "./claim_link.js";
29
+ import { CHECK_NOT_ON_PAGE } from "./verdict_card.js";
29
30
  import { freshURL, placeVerdictWithFresh, settleRunning } from "./verdict_fresh.js";
30
31
  import { mountComposer } from "./composer_mount.js";
31
32
  import { PROMPT_LINE_PX } from "./composer_size.js";
@@ -234,7 +235,7 @@ function render() {
234
235
  const copy = modeCopy(viewer.mode);
235
236
  $("note").textContent = ownerView ? "" : copy.note;
236
237
  $("hint").textContent = `⏎ sends, ⇧⏎ a new line, ↑ an earlier comment. ${copy.hint}`;
237
- $("hint").title = $("hint").textContent; // one line in the card: the whole text on hover
238
+ $("hint").title = $("hint").textContent; // at most two lines in the card: the whole text on hover
238
239
  $("head").hidden = false;
239
240
  $("main").hidden = false;
240
241
  renderPlan();
@@ -343,7 +344,7 @@ function messageNode(message) {
343
344
  const who = el("div", "p-who");
344
345
  who.append(el("b", "", authorLabel(message)));
345
346
  if (message.merged) who.append(el("span", "p-tag", "added mid-turn"));
346
- if (message.author) who.append(el("span", "p-tag", modeCopy(message.participant_mode).tag));
347
+ if (isParticipantAuthor(message.author)) who.append(el("span", "p-tag", modeCopy(message.participant_mode).tag));
347
348
  node.append(who);
348
349
  const text = message.content + (message.images ? `\n[${message.images} image${message.images > 1 ? "s" : ""}]` : "");
349
350
  node.append(el("div", "p-bubble", text));
@@ -413,13 +414,17 @@ function renderThread() {
413
414
  const author = record.by || null;
414
415
  const viewer = data.viewer || {};
415
416
  let who = ownerView ? "You" : "Owner";
416
- if (author && author !== "owner") {
417
+ if (isParticipantAuthor(author)) {
417
418
  if (!ownerView && author === participantAuthor(viewer.id)) who = "You";
418
419
  else {
419
420
  who = (data.participants || []).find((participant) => participantAuthor(participant.id) === author)?.name || "A participant";
420
421
  }
421
422
  }
422
- placeVerdictWithFresh(thread, record, { who, author }, verifyFreshState());
423
+ placeVerdictWithFresh(thread, record, {
424
+ who,
425
+ author,
426
+ onMissingCheck: () => showClaimNote(CHECK_NOT_ON_PAGE),
427
+ }, verifyFreshState());
423
428
  }
424
429
  if (atBottom || !thread.dataset.drawn) main.scrollTop = main.scrollHeight;
425
430
  thread.dataset.drawn = "1";
@@ -431,10 +436,12 @@ function renderThread() {
431
436
  // does, and no one here gets a link to the child's session.
432
437
  function verifyFreshState() {
433
438
  if (!Array.isArray(data.fresh_running)) data.fresh_running = [];
439
+ const unavailable = data.session?.verify_unavailable === true;
434
440
  return {
435
441
  verdicts: data.verdicts || [],
436
442
  running: data.fresh_running,
437
- canStart: !ownerView && !ended && !!data.viewer?.can_comment,
443
+ canStart: !unavailable && !ownerView && !ended && !!data.viewer?.can_comment,
444
+ unavailableReason: unavailable ? VERIFY_UNAVAILABLE : null,
438
445
  start: async (of) => {
439
446
  const { ok, body } = await api(freshURL(sessionId, of), {
440
447
  method: "POST", body: "{}", headers: { "Content-Type": "application/json" },
@@ -650,6 +657,8 @@ for (const button of $("viewSeg").querySelectorAll("button")) {
650
657
  // while this participant can't comment.
651
658
  const annotateBar = createAnnotateBar({
652
659
  presets: withVerify(parsePresets(body.annotatePresets)),
660
+ disabledPreset: (preset) => preset === VERIFY_PRESET
661
+ && data?.session?.verify_unavailable === true ? VERIFY_UNAVAILABLE : null,
653
662
  canAnnotate: () => !ownerView && !ended && !!data?.viewer?.can_comment,
654
663
  roots: () => [$("planDoc"), $("thread")],
655
664
  isLive: (root) => !!root.closest?.(".p-msg.live"),
@@ -9,6 +9,7 @@ import { fullTime, gapText } from "./format.js";
9
9
  // (Steer.author_for / PARTICIPANT_PREFIX): the one place the web spells it.
10
10
  export const PARTICIPANT_PREFIX = "p:";
11
11
  export const participantAuthor = (id) => `${PARTICIPANT_PREFIX}${id}`;
12
+ export const isParticipantAuthor = (by) => typeof by === "string" && by.startsWith(PARTICIPANT_PREFIX);
12
13
 
13
14
  // Six author colours (tokens.css --author-1..6), one per participant id,
14
15
  // the same on every page that shows them (the owner's badges too).
@@ -22,13 +23,13 @@ export function authorSlot(author) {
22
23
 
23
24
  // The CSS colour of an author: a participant's slot, else the owner's.
24
25
  export function authorColor(author) {
25
- return author ? `var(--author-${authorSlot(author)})` : "var(--author-owner)";
26
+ return isParticipantAuthor(author) ? `var(--author-${authorSlot(author)})` : "var(--author-owner)";
26
27
  }
27
28
 
28
29
  // Who wrote a user message or started a turn, as the page names them.
29
30
  export function authorLabel(item) {
30
31
  if (item?.mine) return "You";
31
- if (item?.author) return item.author_name || "A participant";
32
+ if (isParticipantAuthor(item?.author)) return item.author_name || "A participant";
32
33
  if (item?.automatic) return "Automatic";
33
34
  return "Owner";
34
35
  }
@@ -4,7 +4,7 @@
4
4
  // prompt's own author_name (a saved message, turn_started) comes first; a
5
5
  // badge drawn before the list came, with neither, says UNNAMED until
6
6
  // nameUnnamedBadges names it.
7
- import { authorColor, participantAuthor } from "./participant_model.js";
7
+ import { authorColor, isParticipantAuthor, participantAuthor } from "./participant_model.js";
8
8
 
9
9
  export const UNNAMED = "a participant";
10
10
 
@@ -28,7 +28,7 @@ export function participantName(author) {
28
28
  // +mode+: the turn's participant_mode stamp ("review", "pair"; the
29
29
  // message's, or turn_started's), kept as mode: when known.
30
30
  export function authorBadge(author, name = null, mode = null) {
31
- if (!author || author === "owner") return null;
31
+ if (!isParticipantAuthor(author)) return null;
32
32
  const known = name || participantName(author);
33
33
  return { author, name: known || UNNAMED, color: authorColor(author), ...(known ? {} : { unnamed: true }), ...(mode ? { mode } : {}) };
34
34
  }
@@ -3,6 +3,7 @@
3
3
  // command or paths, where and why. Its options end in "Deny"; the answer's
4
4
  // last option denies. A page that doesn't know approvals still shows the
5
5
  // plain question text, which says the same.
6
+ import { isParticipantAuthor, PARTICIPANT_PREFIX } from "./participant_model.js";
6
7
 
7
8
  export function isApproval(pq) {
8
9
  return !!pq && pq.kind === "approval";
@@ -68,7 +69,9 @@ export function relayView(pq) {
68
69
  export function approvalAsker(pq) {
69
70
  const a = (pq && pq.approval) || {};
70
71
  if (!a.author) return null;
71
- return a.author_name ? String(a.author_name) : String(a.author).replace(/^p:/, "");
72
+ const author = String(a.author);
73
+ return a.author_name ? String(a.author_name)
74
+ : isParticipantAuthor(author) ? author.slice(PARTICIPANT_PREFIX.length) : author;
72
75
  }
73
76
 
74
77
  // @return {null | {tool, what, isCommand, where, why, paths?, preview?, delegate?, asker?}};
@@ -53,6 +53,7 @@ export function emptySessionState() {
53
53
  // worker_version / worker_features): the info bar's stale-worker badge.
54
54
  workerVersion: null,
55
55
  workerFeatures: [],
56
+ bridgeUp: false,
56
57
  // The LLM context strategy, apply rule and budget the next turn runs
57
58
  // under, each with its source (the info bar's llm ctx chip), or null.
58
59
  llmContext: null,
@@ -95,6 +96,7 @@ export function sessionStateFrom(data) {
95
96
  // not at the session's next hub event (followSelectedSession).
96
97
  workerVersion: s.worker_version || null,
97
98
  workerFeatures: Array.isArray(s.worker_features) ? s.worker_features : [],
99
+ bridgeUp: s.bridge_up === true,
98
100
  timing: normalizeTiming(data.timing),
99
101
  };
100
102
  // The saved fill, so a reloaded or stopped session shows it before its
@@ -55,6 +55,7 @@ import {
55
55
  import { commandView, drawCommandBubble, historyHasCommandBubble } from "./commands.js";
56
56
  import { keepEarlyRestore, restoreAction } from "./restore.js";
57
57
  import { PAIR, setBadgeMode } from "./participant_names.js";
58
+ import { isParticipantAuthor } from "./participant_model.js";
58
59
 
59
60
  export function createStreamHandlers(deps) {
60
61
  const {
@@ -163,12 +164,13 @@ export function createStreamHandlers(deps) {
163
164
  // A participant's review turn: its id and author, for Apply at its end
164
165
  // (turn_completed names neither). A pair turn acted itself: no Apply.
165
166
  const author = data.author || data.origin?.author;
167
+ const participant = isParticipantAuthor(author);
166
168
  const pair = data.participant_mode === PAIR;
167
- liveTurn.review = author && author !== "owner" && data.turn_id && !pair ? { turn_id: data.turn_id, author, author_name: data.author_name } : null;
169
+ liveTurn.review = participant && data.turn_id && !pair ? { turn_id: data.turn_id, author, author_name: data.author_name } : null;
168
170
  // Its prompt drawn while queued takes the turn's mode now.
169
171
  if (data.participant_mode) setBadgeMode(promptBubble, data.participant_mode);
170
172
  // The owner's input queues behind it (the composer's delivery line).
171
- liveTurn.participantTurn = !!(author && author !== "owner");
173
+ liveTurn.participantTurn = participant;
172
174
  setTurnRunning(true);
173
175
  setLiveStatus("running");
174
176
  liveTurn.timing.detach();
@@ -1,10 +1,23 @@
1
+ import { ownerClaimHash } from "./claim_link.js";
2
+
1
3
  const TYPES = Object.freeze({
2
4
  confirmed: { icon: "✅", label: "confirmed" },
3
5
  refuted: { icon: "❌", label: "refuted" },
4
6
  unclear: { icon: "❓", label: "unclear" },
5
7
  });
6
8
 
7
- export function placeVerdict(root, record, { who = "Owner", author = null } = {}) {
9
+ export const CHECK_NOT_ON_PAGE = "That check is no longer on this page.";
10
+
11
+ export function ownerCheckHash(sessions, currentSessionId, checkTurnId) {
12
+ if (!Array.isArray(sessions) || typeof checkTurnId !== "string") return null;
13
+ const session = sessions.find((candidate) =>
14
+ candidate?.id !== currentSessionId
15
+ && Array.isArray(candidate.verdicts)
16
+ && candidate.verdicts.some((verdict) => verdict?.check_turn_id === checkTurnId));
17
+ return session ? ownerClaimHash(session.id, { turnId: checkTurnId }) : null;
18
+ }
19
+
20
+ export function placeVerdict(root, record, { who = "Owner", author = null, onMissingCheck = null } = {}) {
8
21
  const anchor = record?.anchor;
9
22
  const type = TYPES[record?.verdict];
10
23
  if (!root || !type || typeof anchor?.turn_id !== "string" || typeof anchor?.quote !== "string"
@@ -47,8 +60,10 @@ export function placeVerdict(root, record, { who = "Owner", author = null } = {}
47
60
  link.textContent = "View check";
48
61
  link.addEventListener("click", (event) => {
49
62
  event.preventDefault();
50
- [...root.querySelectorAll("[data-turn-id]")].find((element) => element.dataset.turnId === record.check_turn_id)
51
- ?.scrollIntoView?.({ behavior: "smooth", block: "center" });
63
+ const check = [...root.querySelectorAll("[data-turn-id]")]
64
+ .find((element) => element.dataset.turnId === record.check_turn_id);
65
+ if (check) check.scrollIntoView?.({ behavior: "smooth", block: "center" });
66
+ else onMissingCheck?.(record);
52
67
  });
53
68
  card.append(heading, quote, reason, link);
54
69
 
@@ -79,6 +79,17 @@ export function renderFresh(card, fresh = {}) {
79
79
  const link = childLink(typeof run === "string" ? null : run.child_id);
80
80
  if (link) line.append(" ", link);
81
81
  part.append(line);
82
+ } else if (card.classList.contains("unclear") && fresh.unavailableReason) {
83
+ const button = doc.createElement("button");
84
+ button.type = "button";
85
+ button.className = "verify-fresh-btn";
86
+ button.textContent = "Verify fresh";
87
+ button.title = fresh.unavailableReason;
88
+ button.disabled = true;
89
+ const note = doc.createElement("p");
90
+ note.className = "verify-fresh-unavailable";
91
+ note.textContent = fresh.unavailableReason;
92
+ part.append(button, note);
82
93
  } else if (card.classList.contains("unclear") && fresh.canStart && fresh.start) {
83
94
  const button = doc.createElement("button");
84
95
  button.type = "button";
@@ -11,6 +11,7 @@
11
11
  .verify-fresh-result{font-weight:600;color:var(--text-bright)}
12
12
  .verify-fresh-running{color:var(--text-muted);font-style:italic}
13
13
  .verify-fresh-error{color:var(--danger-text)}
14
+ .verify-fresh-unavailable{color:var(--text-muted)}
14
15
  .verify-fresh-link{color:var(--accent);font-weight:400}
15
16
  .verify-fresh-btn{font:inherit;font-size:12px;font-weight:600;padding:3px 12px;border-radius:8px;cursor:pointer;background:var(--accent-bg);color:var(--accent);border:1px solid var(--accent-border)}
16
17
  .verify-fresh-btn:hover{border-color:var(--accent)}
@@ -12,6 +12,12 @@ export const VERIFY_PRESET = Object.freeze({
12
12
  + "End with a line `Verdict: confirmed|refuted|unclear — <one-line reason>`.",
13
13
  });
14
14
 
15
+ export const VERIFY_UNAVAILABLE = "restart this session to use Verify";
16
+
17
+ export function verifyWorkerUnavailable(workerFeatures, bridgeUp) {
18
+ return bridgeUp === true && Array.isArray(workerFeatures) && !workerFeatures.includes("verify");
19
+ }
20
+
15
21
  // +presets+ (annotate_presets.js parsePresets) with Verify after them.
16
22
  export function withVerify(presets) {
17
23
  return [...(presets || []), VERIFY_PRESET];
@@ -33,14 +33,18 @@ module Samagotchi
33
33
  def handle_verify_fresh(req, id, of)
34
34
  clicker = principal(req).owner? ? nil : principal(req).grant
35
35
  session = @session_class.load(id, state_dir: default_state_dir)
36
- if clicker && (refused = participant_cap_refusal(clicker) || fresh_worker_refusal(session))
36
+ if clicker && (refused = participant_cap_refusal(clicker) || fresh_worker_refusal(session, participant: true))
37
37
  return verify_fresh_refused(clicker, of, *refused)
38
38
  end
39
39
 
40
40
  by = Steer.sender_author(field_of(VerifyFresh.original(session.verdicts, of), :by))
41
41
  runner = by ? verdict_author_grant(id, by) : clicker
42
42
  return author_link_ended(clicker, id, of, by) if by && runner.nil?
43
- if runner && runner != clicker && (refused = participant_cap_refusal(runner) || fresh_worker_refusal(session))
43
+ if runner && runner != clicker && (refused = participant_cap_refusal(runner) ||
44
+ fresh_worker_refusal(session, participant: true))
45
+ return verify_fresh_refused(clicker, of, *refused, runner: runner)
46
+ end
47
+ if (refused = fresh_worker_refusal(session))
44
48
  return verify_fresh_refused(clicker, of, *refused, runner: runner)
45
49
  end
46
50
 
@@ -76,12 +80,13 @@ module Samagotchi
76
80
 
77
81
  def field_of(record, key) = record && (record[key] || record[key.to_s])
78
82
 
79
- # A live worker of the session that predates participants' turns runs
80
- # no participant's turn (Delivery::REVIEW_UNAVAILABLE): no fresh
81
- # child for one either. nil to go on.
82
- def fresh_worker_refusal(session)
83
+ # A live worker must support VerifyFresh's parent checks. For a
84
+ # participant's child it must also have the review boundary.
85
+ def fresh_worker_refusal(session, participant: false)
83
86
  features = ParticipantMode.live_features(session, default_state_dir)
84
- return nil if features.nil? || features.include?(Delivery::REVIEW_FEATURE)
87
+ return nil if features.nil?
88
+ return [409, VerifyAnchor::UNAVAILABLE["error"], VerifyAnchor::UNAVAILABLE["detail"]] if VerifyAnchor.worker_unavailable?(features)
89
+ return nil unless participant && !features.include?(Delivery::REVIEW_FEATURE)
85
90
 
86
91
  [409, Delivery::REVIEW_UNAVAILABLE["error"], Delivery::REVIEW_UNAVAILABLE["detail"]]
87
92
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: samagotchi
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.127.0
4
+ version: 0.128.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dmitry Dedov
@@ -623,6 +623,7 @@ files:
623
623
  - lib/samagotchi/terminal_ui/local_link.rb
624
624
  - lib/samagotchi/terminal_ui/model_segments_text.rb
625
625
  - lib/samagotchi/terminal_ui/one_shot_sink.rb
626
+ - lib/samagotchi/terminal_ui/own_turns.rb
626
627
  - lib/samagotchi/terminal_ui/participant_commands.rb
627
628
  - lib/samagotchi/terminal_ui/plain_surface.rb
628
629
  - lib/samagotchi/terminal_ui/question_prompt.rb