samagotchi 0.12.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +116 -1
  3. data/README.md +20 -7
  4. data/bin/chi +9 -9
  5. data/docs/architecture.md +4 -2
  6. data/docs/cli.md +34 -10
  7. data/docs/configuration.md +79 -15
  8. data/docs/desktop.md +10 -6
  9. data/docs/guardrails.md +74 -7
  10. data/docs/hooks.md +13 -9
  11. data/docs/memory.md +5 -3
  12. data/docs/plugins.md +31 -16
  13. data/docs/sessions.md +9 -6
  14. data/docs/sub-agent.md +128 -0
  15. data/lib/samagotchi/answer_command.rb +220 -0
  16. data/lib/samagotchi/bridge/card_store.rb +94 -6
  17. data/lib/samagotchi/bridge.rb +14 -2
  18. data/lib/samagotchi/bridge_client.rb +8 -3
  19. data/lib/samagotchi/bundle_command.rb +18 -10
  20. data/lib/samagotchi/bundles/check-in/manifest.yml +2 -2
  21. data/lib/samagotchi/bundles/check-in/plugin.rb +68 -5
  22. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +19 -0
  23. data/lib/samagotchi/bundles/guardrails/manifest.yml +2 -2
  24. data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
  25. data/lib/samagotchi/bundles/mcp/plugin.rb +166 -34
  26. data/lib/samagotchi/bundles/system/config_modification_protocol.md +5 -5
  27. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  28. data/lib/samagotchi/cli/command.rb +3 -2
  29. data/lib/samagotchi/cli/exit.rb +23 -0
  30. data/lib/samagotchi/cli/parent_wait.rb +82 -0
  31. data/lib/samagotchi/config.rb +81 -24
  32. data/lib/samagotchi/context_window.rb +30 -5
  33. data/lib/samagotchi/desktop_command.rb +5 -3
  34. data/lib/samagotchi/engine.rb +54 -17
  35. data/lib/samagotchi/guardrails/parent_approvals.rb +158 -0
  36. data/lib/samagotchi/hooks/bundle_loader.rb +10 -3
  37. data/lib/samagotchi/host_registry.rb +31 -7
  38. data/lib/samagotchi/kernel_loop.rb +1 -1
  39. data/lib/samagotchi/llm/chat_loop.rb +5 -3
  40. data/lib/samagotchi/llm/openai_chat.rb +9 -1
  41. data/lib/samagotchi/llm/turn_settings.rb +7 -5
  42. data/lib/samagotchi/memory_bundle/installer.rb +12 -0
  43. data/lib/samagotchi/memory_bundle/status.rb +9 -1
  44. data/lib/samagotchi/memory_paths.rb +8 -0
  45. data/lib/samagotchi/model_profile.rb +30 -16
  46. data/lib/samagotchi/parent_report.rb +213 -0
  47. data/lib/samagotchi/plugin/api.rb +15 -2
  48. data/lib/samagotchi/prompt_literal_guard.rb +4 -2
  49. data/lib/samagotchi/question_desk.rb +29 -2
  50. data/lib/samagotchi/recap_setup.rb +15 -5
  51. data/lib/samagotchi/reply_wait.rb +5 -1
  52. data/lib/samagotchi/send_command.rb +50 -75
  53. data/lib/samagotchi/session.rb +15 -0
  54. data/lib/samagotchi/session_manager.rb +40 -7
  55. data/lib/samagotchi/sessions_command.rb +13 -3
  56. data/lib/samagotchi/system_prompt.rb +11 -3
  57. data/lib/samagotchi/terminal_ui/attach_launcher.rb +33 -5
  58. data/lib/samagotchi/terminal_ui/attached_loop.rb +53 -14
  59. data/lib/samagotchi/terminal_ui.rb +30 -3
  60. data/lib/samagotchi/thought_stream_splitter.rb +4 -4
  61. data/lib/samagotchi/tool_call_parser.rb +5 -6
  62. data/lib/samagotchi/tools/builtin_calls.rb +14 -3
  63. data/lib/samagotchi/tools/builtins.rb +14 -2
  64. data/lib/samagotchi/tools/delegate_wait.rb +32 -13
  65. data/lib/samagotchi/tools/execute.rb +8 -8
  66. data/lib/samagotchi/tools/memory.rb +3 -3
  67. data/lib/samagotchi/tools/task_create.rb +3 -2
  68. data/lib/samagotchi/tools/task_runtime.rb +7 -2
  69. data/lib/samagotchi/utf8_default.rb +29 -0
  70. data/lib/samagotchi/version.rb +1 -1
  71. data/lib/samagotchi/web/app.rb +22 -10
  72. data/lib/samagotchi/web/message_parts.rb +2 -1
  73. data/lib/samagotchi/web/public/activity.js +6 -1
  74. data/lib/samagotchi/web/public/app.js +39 -6
  75. data/lib/samagotchi/web/public/index.html +8 -1
  76. data/lib/samagotchi/web/public/notify.js +16 -3
  77. data/lib/samagotchi/web/public/question_cards.js +3 -1
  78. data/lib/samagotchi/web/public/sessions_list.js +37 -0
  79. data/lib/samagotchi/web/public/turn_view.js +6 -9
  80. data/lib/samagotchi/web/server.rb +2 -1
  81. data/lib/samagotchi/web/session_summary.rb +3 -11
  82. metadata +8 -1
@@ -2,13 +2,15 @@
2
2
 
3
3
  require_relative "../session"
4
4
  require_relative "../reply_wait"
5
+ require_relative "../parent_report"
5
6
  require_relative "output_guardrails"
6
7
  require_relative "peers"
7
8
 
8
9
  module Samagotchi
9
10
  module Tools
10
11
  # Waiting for a delegated session's next reply, shared by delegate and
11
- # delegate_result: ReplyWait in the tools' words, with a cursor per
12
+ # delegate_result: ReplyWait in the tools' words, with the status words
13
+ # `chi send --wait` and `chi answer` use (ParentReport.status), with a cursor per
12
14
  # child (the newest reply this parent was already given) and the child
13
15
  # as it was before the message went in (ReplyWait's baseline), so a turn
14
16
  # that ends before the wait's first look (a fast failure) still ends it.
@@ -18,6 +20,10 @@ module Samagotchi
18
20
  module DelegateWait
19
21
  TIMEOUT_DEFAULT = 600
20
22
  POLL_INTERVAL = 0.5
23
+ # Seconds with no worker before the child counts as gone: as long as
24
+ # Delegate gives a starting child (its worker takes the owner lock
25
+ # only once it has booted).
26
+ OWNER_GRACE = 15
21
27
  # Cut like an execute result: full up to TRUNCATE_AT bytes, else a
22
28
  # head-and-tail preview of PREVIEW bytes.
23
29
  TRUNCATE_AT_BYTES = OutputGuardrails::DEFAULT_TRUNCATE_AT_BYTES
@@ -39,29 +45,36 @@ module Samagotchi
39
45
  # @param peers [Peers] the parent (session_id, state_dir, cancelled?)
40
46
  # @param timeout [Integer] seconds
41
47
  # @param poll_interval [Float]
48
+ # @param owner_grace [Numeric] seconds with no live worker before
49
+ # the child's worker counts as gone
42
50
  # @return [String] the tool result
43
- def call(child_id, peers:, timeout: TIMEOUT_DEFAULT, poll_interval: POLL_INTERVAL)
51
+ def call(child_id, peers:, timeout: TIMEOUT_DEFAULT, poll_interval: POLL_INTERVAL, owner_grace: OWNER_GRACE)
44
52
  sd = peers.state_dir || Session.default_state_dir
45
53
  key = [peers.session_id, child_id]
46
54
  cancelled = -> { peers.cancelled? }
47
55
  wait = ReplyWait.call(child_id, state_dir: sd, cursor: seen[key], timeout: timeout.to_i,
48
- poll_interval: poll_interval, cancelled: cancelled, baseline: baselines[key])
56
+ poll_interval: poll_interval, cancelled: cancelled, baseline: baselines[key],
57
+ owner_grace: owner_grace)
49
58
  # The turn sent to is handed over: a later wait looks for a later one.
50
59
  baselines.delete(key) if %i[done no_reply].include?(wait.status)
60
+ status = ParentReport.status(wait)
51
61
  case wait.status
52
62
  when :done
53
63
  seen[key] = wait.file
54
- reply_result(child_id, wait.text)
64
+ reply_result(child_id, status, wait.text)
55
65
  when :error
56
- result(child_id, "error", "the child's worker failed: #{wait.text}; its session shows what happened")
66
+ result(child_id, status, "the child's worker failed: #{wait.text}; its session shows what happened")
57
67
  when :stopped
58
- result(child_id, "stopped", "the child was stopped (chi sessions stop); delegate with session: #{child_id} starts it again with a message")
68
+ result(child_id, status, "the child was stopped (chi sessions stop); delegate with session: #{child_id} starts it again with a message")
59
69
  when :waiting_for_answer
60
- result(child_id, "waiting_for_answer", waiting_text(child_id, wait.question))
70
+ result(child_id, status, waiting_text(child_id, wait.question))
61
71
  when :canceled
62
- result(child_id, "canceled", "wait canceled; the child keeps running; delegate_result #{child_id} waits again")
72
+ result(child_id, status, "wait canceled; the child keeps running; delegate_result #{child_id} waits again")
63
73
  when :no_reply
64
- result(child_id, "no_reply", "the child's turn ended without a reply (canceled, failed or empty); its session shows what happened")
74
+ result(child_id, status, "#{no_reply_text(wait)}; its session shows what happened")
75
+ when :worker_gone
76
+ result(child_id, status,
77
+ "the child's worker is gone (it stopped or crashed); delegate with session: #{child_id} starts it again with a message")
65
78
  else
66
79
  timeout_result(child_id, timeout)
67
80
  end
@@ -84,8 +97,13 @@ module Samagotchi
84
97
  baselines[[parent_id, child.id]] = ReplyWait.baseline_of(child, question_id: nil)
85
98
  end
86
99
 
87
- def reply_result(child_id, text)
88
- "session: #{child_id}\nstatus: done\n---\n#{cut(text)}"
100
+ def reply_result(child_id, status, text)
101
+ "session: #{child_id}\nstatus: #{status}\n---\n#{cut(text)}"
102
+ end
103
+
104
+ # ParentReport's line for a turn that left no reply, about the child.
105
+ def no_reply_text(wait)
106
+ ParentReport.no_reply_line(wait).sub(/\Athe turn/, "the child's turn")
89
107
  end
90
108
 
91
109
  def result(child_id, status, text)
@@ -98,9 +116,10 @@ module Samagotchi
98
116
  "chi --attach #{child_id} shows it.")
99
117
  end
100
118
 
119
+ # The whole question, as `chi send --wait` prints it (ParentReport):
120
+ # its text, options and the commands that answer it.
101
121
  def waiting_text(child_id, pending)
102
- what = pending[:kind].to_s == "approval" ? "an approval" : "a question"
103
- "Child #{child_id} is waiting for an answer (#{what}); attach with chi --attach #{child_id} or answer it in the web. " \
122
+ "Child #{child_id} is #{ParentReport.question_text(pending, session_id: child_id)}" \
104
123
  "delegate_result #{child_id} waits again once it is answered."
105
124
  end
106
125
 
@@ -25,7 +25,8 @@ module Samagotchi
25
25
 
26
26
  # @param cancelled [#call] true once the user stopped the turn: the
27
27
  # command is killed (or not started) and its output so far returned
28
- def self.call(command, cwd: nil, cancelled: -> { false })
28
+ # @param env [Hash, nil] added to the command's environment
29
+ def self.call(command, cwd: nil, cancelled: -> { false }, env: nil)
29
30
  return NOT_RUN_ON_STOP if cancelled.call
30
31
 
31
32
  command = command.strip
@@ -33,7 +34,8 @@ module Samagotchi
33
34
  return "Error: cwd not found: #{resolved_cwd}" unless resolved_cwd
34
35
 
35
36
  timeout_sec = timeout_seconds
36
- stdout, stderr, status = run_command(command, timeout_sec: timeout_sec, cwd: resolved_cwd, cancelled: cancelled)
37
+ stdout, stderr, status = run_command(command, timeout_sec: timeout_sec, cwd: resolved_cwd, cancelled: cancelled,
38
+ env: env)
37
39
 
38
40
  stdout_block = output_block("stdout", stdout)
39
41
  stderr_block = output_block("stderr", stderr)
@@ -66,7 +68,7 @@ module Samagotchi
66
68
  end
67
69
  private_class_method :stopped_result
68
70
 
69
- def self.run_command(command, timeout_sec:, cwd:, cancelled: -> { false })
71
+ def self.run_command(command, timeout_sec:, cwd:, cancelled: -> { false }, env: nil)
70
72
  stdout_text = ""
71
73
  stderr_text = ""
72
74
  status = nil
@@ -75,7 +77,7 @@ module Samagotchi
75
77
  started = monotonic_time
76
78
  deadline = started + timeout_sec
77
79
 
78
- Open3.popen3(command, chdir: cwd, pgroup: true) do |stdin, stdout, stderr, wait_thr|
80
+ Open3.popen3(env || {}, command, chdir: cwd, pgroup: true) do |stdin, stdout, stderr, wait_thr|
79
81
  stdin.close
80
82
  stdout_reader = reader_thread_for(stdout)
81
83
  stderr_reader = reader_thread_for(stderr)
@@ -173,11 +175,9 @@ module Samagotchi
173
175
  end
174
176
  private_class_method :monotonic_time
175
177
 
178
+ # execute.timeout_sec (SAMAGOTCHI_EXECUTE_TIMEOUT_SEC); 0 or less = the default.
176
179
  def self.timeout_seconds
177
- value = Integer(ENV["SAMAGOTCHI_EXECUTE_TIMEOUT_SEC"].to_s, exception: false)
178
- return TIMEOUT_SEC if value.nil? || value <= 0
179
-
180
- value
180
+ OutputGuardrails.config_positive_int("execute.timeout_sec", TIMEOUT_SEC)
181
181
  end
182
182
  private_class_method :timeout_seconds
183
183
 
@@ -59,13 +59,13 @@ module Samagotchi
59
59
  resolved_scopes.each do |resolved_scope|
60
60
  path = memory_path(name, resolved_scope)
61
61
  if File.exist?(path)
62
- body = File.read(path)
62
+ body = File.read(path, encoding: "UTF-8")
63
63
  # Append model-specific overlay if key is provided and overlay exists.
64
64
  [model_key, fallback_model_key].compact.each do |key|
65
65
  overlay_path = ModelOverlay.overlay_path_for(name, key, resolved_scope)
66
66
  next unless overlay_path && File.exist?(overlay_path)
67
67
 
68
- body += SEPARATOR + "Model-specific guidance (#{key}):\n" + File.read(overlay_path)
68
+ body += SEPARATOR + "Model-specific guidance (#{key}):\n" + File.read(overlay_path, encoding: "UTF-8")
69
69
  break
70
70
  end
71
71
  results << body
@@ -114,7 +114,7 @@ module Samagotchi
114
114
  def self.scoped_index(scope)
115
115
  dir = memories_dir(scope)
116
116
  index_path = File.join(dir, "#{MEMORY_INDEX}.md")
117
- return File.read(index_path) if File.exist?(index_path)
117
+ return File.read(index_path, encoding: "UTF-8") if File.exist?(index_path)
118
118
 
119
119
  files = Dir.glob(File.join(dir, "*.md")).sort
120
120
  return "No memories stored yet." if files.empty?
@@ -9,8 +9,9 @@ module Samagotchi
9
9
 
10
10
  def self.name = NAME
11
11
 
12
- def self.call(command, cwd: nil, env: nil)
13
- record, error = TaskRuntime.create_task(command, cwd: cwd, env: env)
12
+ # @param marker_env [Hash] set after the model's env (Builtins.parent_env)
13
+ def self.call(command, cwd: nil, env: nil, marker_env: {})
14
+ record, error = TaskRuntime.create_task(command, cwd: cwd, env: env, marker_env: marker_env)
14
15
  return error if error
15
16
 
16
17
  [
@@ -19,10 +19,13 @@ module Samagotchi
19
19
  METADATA_VERSION = 1
20
20
  OUTPUT_TAIL_READ_BYTES = 16 * 1024
21
21
  SANITIZED_ENV_KEYS = %w[RUBYOPT RUBYLIB BUNDLE_GEMFILE BUNDLE_BIN_PATH BUNDLER_VERSION].freeze
22
+ # Set by chi (Builtins.parent_env); the model's env can't change it.
23
+ MARKER_ENV_KEYS = %w[SAMAGOTCHI_PARENT_SESSION].freeze
22
24
 
23
25
  module_function
24
26
 
25
- def create_task(command, cwd: nil, env: nil)
27
+ # @param marker_env [Hash] chi's own variables, set over the model's +env+
28
+ def create_task(command, cwd: nil, env: nil, marker_env: {})
26
29
  normalized_command = command.to_s.strip
27
30
  return [nil, "Error: command is required"] if normalized_command.empty?
28
31
 
@@ -32,6 +35,8 @@ module Samagotchi
32
35
  spawn_env, env_error = spawn_env_for(env)
33
36
  return [nil, env_error] if env_error
34
37
 
38
+ spawn_env = spawn_env.merge(marker_env || {})
39
+
35
40
  task_id = generate_task_id
36
41
  task_dir = task_dir_for(task_id)
37
42
  output_path = File.join(task_dir, OUTPUT_FILENAME)
@@ -258,7 +263,7 @@ module Samagotchi
258
263
  overrides.each do |key, value|
259
264
  return [nil, "Error: env keys and values must be strings"] unless key.is_a?(String) && value.is_a?(String)
260
265
  return [nil, "Error: env keys and values cannot contain NUL bytes"] if key.include?("\0") || value.include?("\0")
261
- return [nil, "Error: env key is reserved: #{key}"] if SANITIZED_ENV_KEYS.include?(key)
266
+ return [nil, "Error: env key is reserved: #{key}"] if SANITIZED_ENV_KEYS.include?(key) || MARKER_ENV_KEYS.include?(key)
262
267
 
263
268
  normalized[key] = value
264
269
  end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ # chi's files (memories, AGENT.md, sessions, config.yml) are UTF-8,
5
+ # whatever the locale. Under LC_ALL=C (or with no locale at all: an app
6
+ # started from Finder or launchd, `env -i`) Ruby reads files as US-ASCII,
7
+ # and a UTF-8 memory index then breaks the system prompt
8
+ # (Encoding::CompatibilityError). bin/chi and every worker call #apply!
9
+ # first.
10
+ module Utf8Default
11
+ LOCALE_KEYS = %w[LC_ALL LC_CTYPE LANG].freeze
12
+
13
+ # Files read as UTF-8 when the locale says US-ASCII. With no locale set
14
+ # at all, LANG too, so the workers and tools chi spawns get one; a
15
+ # locale the user set (LC_ALL=C) is left to them.
16
+ def self.apply!(env = ENV)
17
+ return unless Encoding.default_external == Encoding::US_ASCII
18
+
19
+ env["LANG"] = "en_US.UTF-8" if LOCALE_KEYS.none? { |key| env[key] }
20
+ verbose = $VERBOSE
21
+ begin
22
+ $VERBOSE = nil # Encoding.default_external= warns
23
+ Encoding.default_external = Encoding::UTF_8
24
+ ensure
25
+ $VERBOSE = verbose
26
+ end
27
+ end
28
+ end
29
+ end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.12.0"
4
+ VERSION = "0.14.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
@@ -11,6 +11,7 @@ require "rack/request"
11
11
 
12
12
  require_relative "../bridge_client"
13
13
  require_relative "../bridge/bounded_queue"
14
+ require_relative "../bridge/card_store"
14
15
  require_relative "../session"
15
16
  require_relative "../session_manager"
16
17
  require_relative "../session_commands"
@@ -533,17 +534,18 @@ module Samagotchi
533
534
  # ?cards=1: the page's re-read when a card arrives, for its rendered
534
535
  # body (the stream carries the card's plain text only).
535
536
  if req.params["cards"] == "1"
536
- return json_response(200, { cards: cards_for_display(turn_snapshot) })
537
+ return json_response(200, { cards: cards_for_display(turn_snapshot, id) })
537
538
  end
538
539
  snapshot = live && live["session_state_snapshot"]
539
540
  last_event_seq = snapshot ? snapshot["event_seq"] : bridge_event_seq(id)
540
541
  current_turn = turn_snapshot && turn_snapshot["current_turn"]
541
542
  owner = session_owner(id)
542
- # The file's question only with a live owner: one a dead worker
543
- # saved can't be answered (its turn is gone).
543
+ # The file's question only while a worker owns the session: one a
544
+ # dead worker saved can't be answered (its turn is gone), nor a chi
545
+ # REPL's (it shares nothing with the web).
544
546
  pending = if turn_snapshot
545
547
  current_turn && current_turn["pending_question"]
546
- elsif owner
548
+ elsif owner&.worker?
547
549
  session.pending_question
548
550
  end
549
551
  # A /model in the worker changes it before the file catches up.
@@ -583,7 +585,7 @@ module Samagotchi
583
585
  plugin_warning: turn_snapshot && turn_snapshot["plugin_warning"],
584
586
  # Plugins' slow setup still running (chi.init): the page's init row.
585
587
  init_tasks: turn_snapshot ? Array(turn_snapshot["init_tasks"]) : [],
586
- cards: cards_for_display(turn_snapshot),
588
+ cards: cards_for_display(turn_snapshot, id),
587
589
  # The composer's / autocomplete; the built-ins until a worker
588
590
  # names its plugins' too.
589
591
  commands: turn_snapshot&.fetch("commands", nil) || SessionCommands.builtin_registry.listing,
@@ -600,11 +602,14 @@ module Samagotchi
600
602
  end
601
603
 
602
604
  # The worker's last cards and between-turns notices (Bridge
603
- # snapshot[:cards]), a card's body as body_html: rendered markdown
604
- # when the renderer is on, else the escaped text in a <pre>.
605
- # @return [Array<Hash>] [] without a live worker
606
- def cards_for_display(turn_snapshot)
607
- Array(turn_snapshot && turn_snapshot["cards"]).filter_map do |card|
605
+ # snapshot[:cards]), else (no live worker) the ones the last worker
606
+ # saved in the session's folder (CardStore.saved), a card's body as
607
+ # body_html: rendered markdown when the renderer is on, else the
608
+ # escaped text in a <pre>.
609
+ # @return [Array<Hash>]
610
+ def cards_for_display(turn_snapshot, id)
611
+ cards = turn_snapshot ? turn_snapshot["cards"] : saved_cards(id)
612
+ Array(cards).filter_map do |card|
608
613
  next unless card.is_a?(Hash)
609
614
  next card unless card["type"].to_s == "card"
610
615
 
@@ -612,6 +617,13 @@ module Samagotchi
612
617
  end
613
618
  end
614
619
 
620
+ # As JSON has them (string keys and values), like a snapshot's.
621
+ def saved_cards(id)
622
+ JSON.parse(JSON.generate(Samagotchi::Bridge::CardStore.saved(Session.session_dir(id, state_dir: default_state_dir))))
623
+ rescue StandardError
624
+ []
625
+ end
626
+
615
627
  def card_body_html(body)
616
628
  return "" if body.strip.empty?
617
629
 
@@ -46,7 +46,8 @@ module Samagotchi
46
46
 
47
47
  QWEN_THINK = %r{<think>(.*?)</think>}m
48
48
  QWEN_OPEN_THINK = %r{\A(.*?)</think>}m # the template opened the block
49
- GEMMA_THOUGHT = /<\|channel>thought(.*?)(?:<channel\|>|\z)/m
49
+ GEMMA_THOUGHT = Regexp.new("#{Regexp.escape(ModelProfile::GEMMA_THOUGHT_CHANNEL_OPEN)}(.*?)" \
50
+ "(?:#{Regexp.escape(ModelProfile::GEMMA_THOUGHT_CHANNEL_CLOSE)}|\\z)", Regexp::MULTILINE)
50
51
 
51
52
  CallRef = Struct.new(:name, :arguments)
52
53
 
@@ -12,6 +12,9 @@ export const ROW_STATUS = Object.freeze({
12
12
  ERROR: "error",
13
13
  // A task_wait the user's Stop ended (the task itself runs on).
14
14
  STOPPED: "stopped",
15
+ // A call a guardrail denied (a hook, the guardrails bundle, a parent's
16
+ // or the user's Deny): it never ran, and it isn't a failed call either.
17
+ BLOCKED: "blocked",
15
18
  });
16
19
 
17
20
  // What a row shows for its tool: a plugin tool's label ("chrome:
@@ -101,9 +104,11 @@ export function addCompleted(model, event = {}) {
101
104
  return row;
102
105
  }
103
106
 
104
- // A completed call's row status from the server's: error, stopped, else ok.
107
+ // A completed call's row status from the server's: error, stopped,
108
+ // blocked, else ok.
105
109
  export function completedStatus(status) {
106
110
  if (status === "error") return ROW_STATUS.ERROR;
107
111
  if (status === "stopped") return ROW_STATUS.STOPPED;
112
+ if (status === "blocked") return ROW_STATUS.BLOCKED;
108
113
  return ROW_STATUS.OK;
109
114
  }
@@ -38,7 +38,7 @@ import { ALL_SESSIONS_HASH, sessionHash, sessionIdFromHash } from "./route.js";
38
38
  import { allScopeHref, cardFolder, projectScopeHref, scopeDir } from "./scope.js";
39
39
  import { shouldFollowScroll, keepFollowing } from "./scroll.js";
40
40
  import { stripColumns, stripShowParts } from "./strip.js";
41
- import { applySessionEvent, listedSessions, sortedByUpdated, withChildrenAfterParents } from "./sessions_list.js";
41
+ import { applySessionEvent, heldOrder, listedSessions, sortedByUpdated, waitingBadge, waitingFirst, withChildrenAfterParents } from "./sessions_list.js";
42
42
  import { createNotifications } from "./notifications.js";
43
43
  import {
44
44
  appendAboveLiveTiming, cancelLineText, elapsedSince, formatDuration, normalizeTiming, turnTimingText,
@@ -267,7 +267,9 @@ function filteredSessions(list) {
267
267
  }
268
268
 
269
269
  // A card: the first message as its title, then status, how long ago, owner
270
- // and memories. The id is in the tooltip (and the search) only.
270
+ // and memories. The id is in the tooltip (and the search) only. A session
271
+ // that waits on the user (a question, an approval, a card with actions)
272
+ // wears the warning colour and says what for.
271
273
  // deletable: the all-sessions view's cards get a delete button.
272
274
  function cardHtml(s, { deletable = false } = {}) {
273
275
  const ts = s.updated_at || s.created_at || "";
@@ -281,7 +283,10 @@ function cardHtml(s, { deletable = false } = {}) {
281
283
  const archivedHtml = s.archived ? `<span class="archived-badge" title="Archived: hidden from the lists, kept for good">archived</span>` : "";
282
284
  const ctx = cardCtxText(s.ctx_pct);
283
285
  const ctxHtml = ctx ? `<span class="ctx" title="Context used after the last turn">${escapeHtml(ctx)}</span>` : "";
284
- return `<div class="card${s.archived ? " archived" : ""}" data-id="${s.id}" title="${escapeHtml(tip)}${memTitle ? ` · ${escapeHtml(memTitle)}` : ""}"><div class="preview">${preview}</div>${s.recap ? `<div class="recap" title="${escapeHtml(s.recap)}">${escapeHtml(s.recap)}</div>` : ""}<div class="meta"><span class="status dot ${escapeHtml(s.status)}" title="${escapeHtml(s.status)}" aria-label="${escapeHtml(s.status)}"></span>${timeHtml}${ctxHtml}${folderHtml}${parentChipHtml(s)}${ownerBadgeHtml(s)}${archivedHtml}${s.used_memory_names && s.used_memory_names.length ? `<span title="${escapeHtml(s.used_memory_names.join(", "))}">mem ${s.used_memory_names.length}</span>` : ""}</div>${deletable ? `<button type="button" class="card-del" data-del="${escapeHtml(s.id)}" title="Delete session" aria-label="Delete session ${escapeHtml(s.short_id)}">\u2715</button>` : ""}</div>`;
286
+ const waiting = waitingBadge(s);
287
+ const waitingHtml = waiting ? `<span class="attn ${waiting.kind}" title="${escapeHtml(waiting.title)}">${escapeHtml(waiting.text)}</span>` : "";
288
+ const dotWord = waiting ? waiting.title : s.status;
289
+ return `<div class="card${s.archived ? " archived" : ""}${waiting ? " waiting" : ""}" data-id="${s.id}" title="${escapeHtml(tip)}${memTitle ? ` · ${escapeHtml(memTitle)}` : ""}"><div class="preview">${preview}</div>${s.recap ? `<div class="recap" title="${escapeHtml(s.recap)}">${escapeHtml(s.recap)}</div>` : ""}<div class="meta"><span class="status dot ${escapeHtml(s.status)}${waiting ? " waiting" : ""}" title="${escapeHtml(dotWord)}" aria-label="${escapeHtml(dotWord)}"></span>${waitingHtml}${timeHtml}${ctxHtml}${folderHtml}${parentChipHtml(s)}${ownerBadgeHtml(s)}${archivedHtml}${s.used_memory_names && s.used_memory_names.length ? `<span title="${escapeHtml(s.used_memory_names.join(", "))}">mem ${s.used_memory_names.length}</span>` : ""}</div>${deletable ? `<button type="button" class="card-del" data-del="${escapeHtml(s.id)}" title="Delete session" aria-label="Delete session ${escapeHtml(s.short_id)}">\u2715</button>` : ""}</div>`;
285
290
  }
286
291
 
287
292
  // A delegated session's `↳ <parent>` chip; the parent's preview is the tooltip.
@@ -304,9 +309,30 @@ function fitStripColumns() {
304
309
  topStripEl.style.setProperty("--strip-cols", stripCols);
305
310
  }
306
311
 
312
+ // The order a list draws its cards in: the sessions that wait on the user
313
+ // first (waitingFirst), back to their place once answered. Never under the
314
+ // pointer, though: while it is over the list the order last drawn holds,
315
+ // and its leaving draws the list again.
316
+ const drawnOrder = new Map(); // list element → ids in the order last drawn
317
+ const heldLists = new Set(); // lists drawn in a held order
318
+ const canHover = globalThis.matchMedia?.("(hover: hover)").matches ?? false;
319
+ function listOrder(el, list) {
320
+ const next = waitingFirst(list);
321
+ const held = canHover && el.matches(":hover") ? drawnOrder.get(el) : null;
322
+ const ordered = held ? heldOrder(held, next) : next;
323
+ if (held && ordered.some((s, i) => s !== next[i])) heldLists.add(el);
324
+ else heldLists.delete(el);
325
+ drawnOrder.set(el, ordered.map((s) => s.id));
326
+ return ordered;
327
+ }
328
+
329
+ function stripSessions() {
330
+ return listOrder(topStripEl, shownSessions()).slice(0, stripCols);
331
+ }
332
+
307
333
  function render() {
308
334
  fitStripColumns();
309
- renderTop(shownSessions().slice(0, stripCols));
335
+ renderTop(stripSessions());
310
336
  if (allViewOpen()) renderAll();
311
337
  }
312
338
 
@@ -316,7 +342,7 @@ window.addEventListener("resize", () => {
316
342
  stripResizeTimer = setTimeout(() => {
317
343
  const before = stripCols;
318
344
  fitStripColumns();
319
- if (stripCols !== before && allSessions.length) renderTop(shownSessions().slice(0, stripCols));
345
+ if (stripCols !== before && allSessions.length) renderTop(stripSessions());
320
346
  }, 150);
321
347
  });
322
348
 
@@ -370,7 +396,7 @@ function renderStripShow() {
370
396
  // render, since a hub snapshot hands the list back in the server's order.
371
397
  function renderAll() {
372
398
  const listed = allViewSessions();
373
- const sessions = withChildrenAfterParents(filteredSessions(listed));
399
+ const sessions = listOrder(allListEl, withChildrenAfterParents(filteredSessions(listed)));
374
400
  $("#count").textContent = sessions.length === listed.length
375
401
  ? `${listed.length}`
376
402
  : `${sessions.length} / ${listed.length}`;
@@ -2208,6 +2234,13 @@ setStripHidden(stripHiddenSaved());
2208
2234
  $("#stripShow").addEventListener("click", () => setStripHidden(false));
2209
2235
 
2210
2236
  $("#filter").addEventListener("input", renderAll);
2237
+ // A list whose order was held under the pointer (listOrder) catches up.
2238
+ topStripEl.addEventListener("pointerleave", () => {
2239
+ if (heldLists.has(topStripEl)) renderTop(stripSessions());
2240
+ });
2241
+ allListEl.addEventListener("pointerleave", () => {
2242
+ if (heldLists.has(allListEl) && allViewOpen()) renderAll();
2243
+ });
2211
2244
  // Esc leaves the all-sessions view.
2212
2245
  document.addEventListener("keydown", (e) => {
2213
2246
  if (e.key === "Escape" && allViewOpen()) closeAllView();
@@ -14,6 +14,9 @@ body{font-family: ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,Helvetic
14
14
  #dock,.turn-stage,.dock-card,#topStrip .card,#allList .card,.bubble.question,.bubble.plugin-card{background:var(--glass-bg);-webkit-backdrop-filter:blur(18px) saturate(1.35);backdrop-filter:blur(18px) saturate(1.35);border:1px solid var(--glass-border);box-shadow:inset 0 1px 0 var(--glass-highlight),var(--glass-shadow)}
15
15
  #topStrip .card:hover,#allList .card:hover{background:color-mix(in srgb,var(--bg-hover) 75%,transparent);border-color:rgba(255,255,255,.14)}
16
16
  #topStrip .card.active,#allList .card.active{background:color-mix(in srgb,var(--accent-bg) 75%,transparent);border-color:color-mix(in srgb,var(--accent) 35%,transparent)}
17
+ /* A session that waits on the user (a question, an approval, a card with actions): the warning colour of its question card, also when active or hovered. */
18
+ #topStrip .card.waiting,#allList .card.waiting{border-color:color-mix(in srgb,var(--warn) 75%,transparent)}
19
+ #topStrip .card.waiting:hover,#allList .card.waiting:hover{border-color:var(--warn)}
17
20
  @supports not ((backdrop-filter:blur(1px)) or (-webkit-backdrop-filter:blur(1px))){#dock,.turn-stage,.dock-card,#topStrip .card,#allList .card,#allHead input{background:var(--bg-panel)}.bubble.question{background:color-mix(in srgb,var(--warn) 7%,var(--bg-panel))}.bubble.plugin-card{background:color-mix(in srgb,var(--accent) 6%,var(--bg-panel))}}
18
21
  @media (prefers-reduced-transparency:reduce){#dock,.turn-stage,.dock-card,#topStrip .card,#allList .card,#allHead input{background:var(--bg-panel);-webkit-backdrop-filter:none;backdrop-filter:none}.bubble.question{background:color-mix(in srgb,var(--warn) 7%,var(--bg-panel));-webkit-backdrop-filter:none;backdrop-filter:none}.bubble.plugin-card{background:color-mix(in srgb,var(--accent) 6%,var(--bg-panel));-webkit-backdrop-filter:none;backdrop-filter:none}}
19
22
  body.no-glass #dock,body.no-glass .turn-stage,body.no-glass .dock-card,body.no-glass #topStrip .card,body.no-glass #allList .card,body.no-glass #allHead input{background:var(--bg-panel);-webkit-backdrop-filter:none;backdrop-filter:none}
@@ -111,6 +114,7 @@ main{flex:1;display:flex;flex-direction:column;gap:16px;padding:16px;overflow:au
111
114
  .activity-status.ok{color:var(--ok)}
112
115
  .activity-status.error{color:var(--danger)}
113
116
  .activity-status.stopped{color:var(--warn-text);opacity:.8}
117
+ .activity-status.blocked{color:var(--danger-text)}
114
118
  .activity-tool{color:var(--accent);font-weight:600}
115
119
  .activity-params{color:var(--text-muted);word-break:break-all}
116
120
  .activity-duration{color:var(--text-faint);font-size:11px;font-variant-numeric:tabular-nums;white-space:nowrap}
@@ -687,7 +691,10 @@ body.all-view #center, body.all-view #topStrip{display:none}
687
691
  .card .dot.completed{background:var(--accent)}
688
692
  .card .dot.error{background:var(--danger)}
689
693
  @keyframes dot-pulse{0%,100%{box-shadow:0 0 0 0 color-mix(in srgb,var(--ok) 55%,transparent)}50%{box-shadow:0 0 0 4px color-mix(in srgb,var(--ok) 0%,transparent)}}
690
- @media (prefers-reduced-motion:reduce){.card .dot.running{animation:none}}
694
+ .card .dot.waiting{background:var(--warn);animation:attn-pulse 1.6s ease-in-out infinite}
695
+ @keyframes attn-pulse{0%,100%{box-shadow:0 0 0 0 color-mix(in srgb,var(--warn) 60%,transparent)}50%{box-shadow:0 0 0 4px color-mix(in srgb,var(--warn) 0%,transparent)}}
696
+ @media (prefers-reduced-motion:reduce){.card .dot.running,.card .dot.waiting{animation:none}}
697
+ .card .attn{font-size:11px;font-weight:600;padding:1px 7px;border-radius:999px;white-space:nowrap;flex-shrink:0;background:var(--warn-bg);color:var(--warn-text);border:1px solid var(--warn-border)}
691
698
  .status.running{background:var(--ok-bg);color:var(--ok-text)}
692
699
  .status.idle{background:var(--bg-raised)}
693
700
  .status.completed{background:var(--accent-bg);color:var(--accent)}
@@ -15,6 +15,21 @@ export function notifyState({ hasNotification, secureContext, permission, wanted
15
15
  // A finished turn shorter than this was watched: no "done".
16
16
  export const DONE_MIN_SECONDS = 10;
17
17
 
18
+ const questionReason = (q) => (q.kind === "approval" ? "approval" : "question");
19
+
20
+ // What a session waits on the user for right now, from one summary: an open
21
+ // question or approval, or a running turn's card with actions (check-in's
22
+ // Nudge / Keep going / Stop asks as a question does). The session cards
23
+ // mark and lift these; attentionFor notifies when one opens.
24
+ // @return null | {reason: "question"|"approval"|"card", id}
25
+ export function waitingOn(summary) {
26
+ const q = summary?.pending_question;
27
+ if (q && q.id) return { reason: questionReason(q), id: q.id };
28
+ const card = summary?.pending_card;
29
+ if (card && card.id) return { reason: "card", id: card.id };
30
+ return null;
31
+ }
32
+
18
33
  // What changed between two summaries of one session that needs the user.
19
34
  // @param prev the summary held before (null for a session not seen yet)
20
35
  // @param next the new summary
@@ -24,10 +39,8 @@ export function attentionFor(prev, next) {
24
39
  if (!next || !next.id) return null;
25
40
  const q = next.pending_question;
26
41
  if (q && q.id && q.id !== prev?.pending_question?.id) {
27
- return { reason: q.kind === "approval" ? "approval" : "question", sessionId: next.id, key: `${next.id}:${q.id}` };
42
+ return { reason: questionReason(q), sessionId: next.id, key: `${next.id}:${q.id}` };
28
43
  }
29
- // A running turn's card with actions (check-in's Nudge / Keep going /
30
- // Stop) asks the user as a question does.
31
44
  const card = next.pending_card;
32
45
  if (card && card.id && card.id !== prev?.pending_card?.id) {
33
46
  return { reason: "card", sessionId: next.id, key: cardKey(next.id, card.id) };
@@ -158,9 +158,11 @@ export function createQuestionCards({
158
158
  });
159
159
  card.appendChild(dismiss);
160
160
 
161
+ // Read before the card makes the history taller.
162
+ const follow = isNearBottom();
161
163
  place(card);
162
164
  questionEl = card;
163
- if (isNearBottom()) scrollToEnd();
165
+ if (follow) scrollToEnd();
164
166
  }
165
167
 
166
168
  // The tool, the command (or paths, or args) as code, where and why; no
@@ -1,5 +1,6 @@
1
1
  // The page's session list as a projection of the hub's events
2
2
  // (GET /api/events): a pure reducer over the list the page holds.
3
+ import { waitingOn } from "./notify.js";
3
4
 
4
5
  // @param list the current list (never mutated)
5
6
  // @param type "snapshot" | "session" | "session_gone"
@@ -65,3 +66,39 @@ export function withChildrenAfterParents(list) {
65
66
  const ordered = heads.slice().sort((a, b) => familyMs(b) - familyMs(a));
66
67
  return ordered.flatMap((s) => [s, ...(children.get(s.id) || [])]);
67
68
  }
69
+
70
+ const WAITING_BADGES = {
71
+ question: { text: "question", title: "Waiting for your answer" },
72
+ approval: { text: "approval", title: "Waiting for your approval" },
73
+ card: { text: "needs you", title: "A card in the running turn waits for you" },
74
+ };
75
+
76
+ // A card's "needs you" badge: what the session waits on the user for
77
+ // (notify.js waitingOn), or null.
78
+ // @return null | {kind, text, title}
79
+ export function waitingBadge(summary) {
80
+ const waiting = waitingOn(summary);
81
+ return waiting ? { kind: waiting.reason, ...WAITING_BADGES[waiting.reason] } : null;
82
+ }
83
+
84
+ // The sessions that wait on the user (waitingOn) first, the rest after,
85
+ // each part in the list's order: an answered one goes back to its place.
86
+ // A delegated session moves with its parent when both are listed (a
87
+ // family waits when any of it does), so withChildrenAfterParents' groups
88
+ // stay together.
89
+ export function waitingFirst(list) {
90
+ const ids = new Set(list.map((s) => s.id));
91
+ const family = (s) => (s.parent_id && ids.has(s.parent_id) ? s.parent_id : s.id);
92
+ const waiting = new Set(list.filter((s) => waitingOn(s)).map(family));
93
+ if (!waiting.size) return list;
94
+ return [...list.filter((s) => waiting.has(family(s))), ...list.filter((s) => !waiting.has(family(s)))];
95
+ }
96
+
97
+ // The list in an order shown before (+ids+): what the pointer is over
98
+ // doesn't move. Sessions new since then go after, in the list's order;
99
+ // gone ones drop out.
100
+ export function heldOrder(ids, list) {
101
+ const at = new Map(ids.map((id, i) => [id, i]));
102
+ const known = list.filter((s) => at.has(s.id)).sort((a, b) => at.get(a.id) - at.get(b.id));
103
+ return [...known, ...list.filter((s) => !at.has(s.id))];
104
+ }
@@ -24,19 +24,16 @@ import * as model from "./turn_model.js";
24
24
  import { createHold } from "./hold.js";
25
25
 
26
26
  // A row's status (live, or a saved tool record's on reload): running,
27
- // error, stopped (a task_wait the user's Stop ended), else ok ("done").
27
+ // error, stopped (a task_wait the user's Stop ended), blocked (a guardrail
28
+ // denied it), else ok ("done").
29
+ const NAMED_STATUSES = ["running", "error", "stopped", "blocked"];
30
+
28
31
  function activityStatusClass(status) {
29
- if (status === "running") return "running";
30
- if (status === "error") return "error";
31
- if (status === "stopped") return "stopped";
32
- return "ok";
32
+ return NAMED_STATUSES.includes(status) ? status : "ok";
33
33
  }
34
34
 
35
35
  function activityStatusLabel(status) {
36
- if (status === "running") return "running";
37
- if (status === "error") return "error";
38
- if (status === "stopped") return "stopped";
39
- return "done";
36
+ return NAMED_STATUSES.includes(status) ? status : "done";
40
37
  }
41
38
 
42
39
  // A live tool row filled from its activity row (status, title, output).
@@ -16,6 +16,7 @@ require_relative "../config"
16
16
  require_relative "../project_scope"
17
17
  require_relative "../log"
18
18
  require_relative "../version"
19
+ require_relative "../cli/exit"
19
20
 
20
21
  module Samagotchi
21
22
  module Web
@@ -99,7 +100,7 @@ module Samagotchi
99
100
  # Ctrl-C: the server has stopped (start's ensure); one line, and the
100
101
  # status a shell gives a command it interrupted.
101
102
  puts "#{"\n" if $stdout.tty?}Chi Web stopped."
102
- 130
103
+ CLI::Exit::INTERRUPTED
103
104
  end
104
105
 
105
106
  # @param hub [SessionHub, nil] the session projection the page streams