samagotchi 0.13.0 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +55 -1
- data/bin/chi +4 -7
- data/docs/configuration.md +50 -7
- data/docs/guardrails.md +2 -1
- data/docs/plugins.md +27 -12
- data/docs/sessions.md +1 -1
- data/docs/sub-agent.md +1 -1
- data/lib/samagotchi/bridge/card_store.rb +94 -6
- data/lib/samagotchi/bridge.rb +3 -1
- data/lib/samagotchi/bundles/check-in/manifest.yml +2 -2
- data/lib/samagotchi/bundles/check-in/plugin.rb +68 -5
- data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
- data/lib/samagotchi/bundles/mcp/plugin.rb +166 -34
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/config.rb +31 -6
- data/lib/samagotchi/context_window.rb +30 -5
- data/lib/samagotchi/engine.rb +14 -3
- data/lib/samagotchi/guardrails/parent_approvals.rb +5 -2
- data/lib/samagotchi/host_registry.rb +31 -6
- data/lib/samagotchi/kernel_loop.rb +1 -1
- data/lib/samagotchi/llm/chat_loop.rb +5 -3
- data/lib/samagotchi/llm/turn_settings.rb +7 -5
- data/lib/samagotchi/session_manager.rb +6 -0
- data/lib/samagotchi/system_prompt.rb +1 -1
- data/lib/samagotchi/terminal_ui/attached_loop.rb +37 -10
- data/lib/samagotchi/terminal_ui.rb +16 -3
- data/lib/samagotchi/tools/delegate_wait.rb +17 -10
- data/lib/samagotchi/tools/memory.rb +3 -3
- data/lib/samagotchi/utf8_default.rb +29 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +18 -7
- data/lib/samagotchi/web/public/activity.js +6 -1
- data/lib/samagotchi/web/public/app.js +39 -6
- data/lib/samagotchi/web/public/index.html +8 -1
- data/lib/samagotchi/web/public/notify.js +16 -3
- data/lib/samagotchi/web/public/question_cards.js +3 -1
- data/lib/samagotchi/web/public/sessions_list.js +37 -0
- data/lib/samagotchi/web/public/turn_view.js +6 -9
- metadata +2 -1
|
@@ -8,12 +8,14 @@ module Samagotchi
|
|
|
8
8
|
# ChatLoop) and ToolRunner: the turn's VisionContext (nil sends no
|
|
9
9
|
# images, placeholders instead), its request parameters
|
|
10
10
|
# (SamplingSettings.for; empty sends none), its thinking level
|
|
11
|
-
# (Thinking.resolve)
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
|
|
11
|
+
# (Thinking.resolve), the bare model id the turn runs on (every debug
|
|
12
|
+
# dump is tagged with it) and the model's or host's configured context
|
|
13
|
+
# window (ContextWindow.setting, nil for none). The Engine sets them
|
|
14
|
+
# once per turn, in Engine#generate, after the model's host is synced.
|
|
15
|
+
TurnSettings = Data.define(:vision, :sampling, :thinking, :model_name, :window_setting) do
|
|
15
16
|
# No vision, no sampling, the default thinking, no model name.
|
|
16
|
-
def self.none = new(vision: nil, sampling: {}.freeze, thinking: Thinking::DEFAULT, model_name: nil
|
|
17
|
+
def self.none = new(vision: nil, sampling: {}.freeze, thinking: Thinking::DEFAULT, model_name: nil,
|
|
18
|
+
window_setting: nil)
|
|
17
19
|
end
|
|
18
20
|
end
|
|
19
21
|
end
|
|
@@ -12,6 +12,7 @@ require_relative "config"
|
|
|
12
12
|
require_relative "session"
|
|
13
13
|
require_relative "session_inbox"
|
|
14
14
|
require_relative "session_metrics"
|
|
15
|
+
require_relative "utf8_default"
|
|
15
16
|
require_relative "turn_note"
|
|
16
17
|
require_relative "owner_lock"
|
|
17
18
|
require_relative "bridge_client"
|
|
@@ -552,6 +553,9 @@ module Samagotchi
|
|
|
552
553
|
File.directory?(path) && (!EMPTY_DIRS.include?(name) || Dir.children(path).empty?)
|
|
553
554
|
elsif name == "analytics.json"
|
|
554
555
|
Array(JSON.parse(File.read(path))["turn_records"]).empty?
|
|
556
|
+
# Cards and notices from before any turn (Bridge::CardStore).
|
|
557
|
+
elsif name == "cards.json"
|
|
558
|
+
JSON.parse(File.read(path))["turns"].to_i.zero?
|
|
555
559
|
else
|
|
556
560
|
EMPTY_SKELETON_FILES.include?(name)
|
|
557
561
|
end
|
|
@@ -631,6 +635,8 @@ module Samagotchi
|
|
|
631
635
|
# @return [Symbol] :idle_exit or :exit_requested
|
|
632
636
|
def self.run_session_loop(session_id, state_dir: nil, owner_wait: OwnerLock::DEFAULT_WAIT,
|
|
633
637
|
idle_exit_minutes: nil, poll_interval: nil)
|
|
638
|
+
# A worker inherits chi's locale (LC_ALL=C too): read files as UTF-8.
|
|
639
|
+
Utf8Default.apply!
|
|
634
640
|
sd = state_dir || Session.default_state_dir
|
|
635
641
|
# The spawner passed the file and level through ENV; a worker's stderr
|
|
636
642
|
# is /dev/null, so warnings only reach the file.
|
|
@@ -335,7 +335,7 @@ module Samagotchi
|
|
|
335
335
|
path = agent_description_path
|
|
336
336
|
return nil unless path
|
|
337
337
|
|
|
338
|
-
content = File.read(path).strip
|
|
338
|
+
content = File.read(path, encoding: "UTF-8").strip
|
|
339
339
|
return nil if content.empty?
|
|
340
340
|
|
|
341
341
|
"Project specific description:\n#{content}"
|
|
@@ -105,6 +105,7 @@ module Samagotchi
|
|
|
105
105
|
@running = false
|
|
106
106
|
@joined_mid_turn = false
|
|
107
107
|
@attached = false
|
|
108
|
+
@early_lines = []
|
|
108
109
|
@question = nil
|
|
109
110
|
@answered_ids = Set.new
|
|
110
111
|
@reader = nil
|
|
@@ -162,16 +163,17 @@ module Samagotchi
|
|
|
162
163
|
when :event
|
|
163
164
|
ended = safely_handle(payload)
|
|
164
165
|
return ended if %i[closed failed unanswered].include?(ended)
|
|
166
|
+
|
|
167
|
+
ended = take_early_lines
|
|
168
|
+
return ended if ended
|
|
165
169
|
return end_of_input if @input_ended && !own_turns_pending?
|
|
166
170
|
when :line
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
end
|
|
174
|
-
return end_of_input if submit(payload) == :detach
|
|
171
|
+
# A pipe is read at once: its lines wait for the joining
|
|
172
|
+
# snapshot, which may bring the question they answer.
|
|
173
|
+
next @early_lines << payload if @wait_at_eof && !@attached
|
|
174
|
+
|
|
175
|
+
ended = take_line(payload)
|
|
176
|
+
return ended if ended
|
|
175
177
|
when :interrupt then return :detached if interrupt(payload) == :detach
|
|
176
178
|
end
|
|
177
179
|
end
|
|
@@ -187,6 +189,30 @@ module Samagotchi
|
|
|
187
189
|
stream&.close
|
|
188
190
|
end
|
|
189
191
|
|
|
192
|
+
# One input line (nil: the input ended).
|
|
193
|
+
# @return [Symbol, nil] how the run ends, or nil to go on
|
|
194
|
+
def take_line(line)
|
|
195
|
+
if line.nil? && @wait_at_eof && own_turns_pending?
|
|
196
|
+
# The pipe's end: this run's turns first. An open question
|
|
197
|
+
# has nothing left to answer it.
|
|
198
|
+
return unanswered_question(@question_pending) if @question
|
|
199
|
+
|
|
200
|
+
@input_ended = true
|
|
201
|
+
return nil
|
|
202
|
+
end
|
|
203
|
+
end_of_input if submit(line) == :detach
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# The lines read before the snapshot came, once it has.
|
|
207
|
+
# @return [Symbol, nil] how the run ends, or nil to go on
|
|
208
|
+
def take_early_lines
|
|
209
|
+
until !@attached || @early_lines.empty?
|
|
210
|
+
ended = take_line(@early_lines.shift)
|
|
211
|
+
return ended if ended
|
|
212
|
+
end
|
|
213
|
+
nil
|
|
214
|
+
end
|
|
215
|
+
|
|
190
216
|
# Render one Bridge event (string keys).
|
|
191
217
|
# @return [Symbol, nil] :closed when the stream ended, :failed when the
|
|
192
218
|
# launch's first command didn't go through
|
|
@@ -948,7 +974,8 @@ module Samagotchi
|
|
|
948
974
|
# The snapshot's cards and between-turns notices (Bridge CardStore):
|
|
949
975
|
# on join the ones since the last turn (and the running turn's), as
|
|
950
976
|
# the join header shows the last exchange; on a resync only cards not
|
|
951
|
-
# shown here yet
|
|
977
|
+
# shown here yet, and not the ones an earlier worker saved (a resync
|
|
978
|
+
# onto a new worker: earlier turns, not news). A turn's own rows (notices, retries) are left out:
|
|
952
979
|
# the running turn's come with its parts, and the join shows no older
|
|
953
980
|
# turn's steps.
|
|
954
981
|
def render_snapshot_cards(entries, joining:)
|
|
@@ -959,7 +986,7 @@ module Samagotchi
|
|
|
959
986
|
|
|
960
987
|
@screen.commit(EventRenderer.hook_notice_line(entry)) if joining && entry[:turns_since].to_i.zero?
|
|
961
988
|
when "card"
|
|
962
|
-
next unless joining ? entry[:turns_since].to_i.zero? : !@renderer.card_shown?(entry[:id])
|
|
989
|
+
next unless joining ? entry[:turns_since].to_i.zero? : !entry[:earlier] && !@renderer.card_shown?(entry[:id])
|
|
963
990
|
|
|
964
991
|
@renderer.render_card(entry, updated: entry[:updated] == true)
|
|
965
992
|
end
|
|
@@ -1112,6 +1112,7 @@ module Samagotchi
|
|
|
1112
1112
|
# was cancelled).
|
|
1113
1113
|
def answer_question_widget(prompt)
|
|
1114
1114
|
loop do
|
|
1115
|
+
@choice_echoed = false
|
|
1115
1116
|
raw = begin
|
|
1116
1117
|
yield
|
|
1117
1118
|
rescue Interrupt
|
|
@@ -1133,7 +1134,7 @@ module Samagotchi
|
|
|
1133
1134
|
@surface.commit(answer.note) if answer.note
|
|
1134
1135
|
unless answer.ok?
|
|
1135
1136
|
# The read left no echo: the line shows above its error.
|
|
1136
|
-
|
|
1137
|
+
echo_choice(raw)
|
|
1137
1138
|
@surface.commit(answer.error)
|
|
1138
1139
|
next
|
|
1139
1140
|
end
|
|
@@ -1146,7 +1147,7 @@ module Samagotchi
|
|
|
1146
1147
|
rescue QuestionDesk::Refused => e
|
|
1147
1148
|
# A parent's allow guardrails.parent_approvals doesn't let through:
|
|
1148
1149
|
# the question stays open for a deny (or the end of the input).
|
|
1149
|
-
|
|
1150
|
+
echo_choice(raw)
|
|
1150
1151
|
@surface.commit(Guardrails::ParentApprovals.message(e.reason, typed: true))
|
|
1151
1152
|
next
|
|
1152
1153
|
rescue ArgumentError => e
|
|
@@ -1173,7 +1174,19 @@ module Samagotchi
|
|
|
1173
1174
|
# A ? read of its own, off a terminal (specs, pipes): $stdin.gets.
|
|
1174
1175
|
def read_choice_line(question_prompt)
|
|
1175
1176
|
@surface.set_slot(:editor, [question_prompt])
|
|
1176
|
-
$stdin.gets
|
|
1177
|
+
line = $stdin.gets
|
|
1178
|
+
# A pipe doesn't echo: the line completes the ? prompt here, once,
|
|
1179
|
+
# and the widget doesn't echo it again (#echo_choice).
|
|
1180
|
+
unless $stdin.tty?
|
|
1181
|
+
@surface.commit(line.to_s.chomp)
|
|
1182
|
+
@choice_echoed = true
|
|
1183
|
+
end
|
|
1184
|
+
line
|
|
1185
|
+
end
|
|
1186
|
+
|
|
1187
|
+
# The answer line above its error, unless its read echoed it already.
|
|
1188
|
+
def echo_choice(raw)
|
|
1189
|
+
@surface.commit("#{paint(QUESTION_PROMPT, 33)}#{raw}") unless @choice_echoed
|
|
1177
1190
|
end
|
|
1178
1191
|
|
|
1179
1192
|
# The next line submitted at the open prompt (?), nil for Ctrl-D
|
|
@@ -9,7 +9,8 @@ require_relative "peers"
|
|
|
9
9
|
module Samagotchi
|
|
10
10
|
module Tools
|
|
11
11
|
# Waiting for a delegated session's next reply, shared by delegate and
|
|
12
|
-
# delegate_result: ReplyWait in the tools' words, with
|
|
12
|
+
# delegate_result: ReplyWait in the tools' words, with the status words
|
|
13
|
+
# `chi send --wait` and `chi answer` use (ParentReport.status), with a cursor per
|
|
13
14
|
# child (the newest reply this parent was already given) and the child
|
|
14
15
|
# as it was before the message went in (ReplyWait's baseline), so a turn
|
|
15
16
|
# that ends before the wait's first look (a fast failure) still ends it.
|
|
@@ -56,22 +57,23 @@ module Samagotchi
|
|
|
56
57
|
owner_grace: owner_grace)
|
|
57
58
|
# The turn sent to is handed over: a later wait looks for a later one.
|
|
58
59
|
baselines.delete(key) if %i[done no_reply].include?(wait.status)
|
|
60
|
+
status = ParentReport.status(wait)
|
|
59
61
|
case wait.status
|
|
60
62
|
when :done
|
|
61
63
|
seen[key] = wait.file
|
|
62
|
-
reply_result(child_id, wait.text)
|
|
64
|
+
reply_result(child_id, status, wait.text)
|
|
63
65
|
when :error
|
|
64
|
-
result(child_id,
|
|
66
|
+
result(child_id, status, "the child's worker failed: #{wait.text}; its session shows what happened")
|
|
65
67
|
when :stopped
|
|
66
|
-
result(child_id,
|
|
68
|
+
result(child_id, status, "the child was stopped (chi sessions stop); delegate with session: #{child_id} starts it again with a message")
|
|
67
69
|
when :waiting_for_answer
|
|
68
|
-
result(child_id,
|
|
70
|
+
result(child_id, status, waiting_text(child_id, wait.question))
|
|
69
71
|
when :canceled
|
|
70
|
-
result(child_id,
|
|
72
|
+
result(child_id, status, "wait canceled; the child keeps running; delegate_result #{child_id} waits again")
|
|
71
73
|
when :no_reply
|
|
72
|
-
result(child_id,
|
|
74
|
+
result(child_id, status, "#{no_reply_text(wait)}; its session shows what happened")
|
|
73
75
|
when :worker_gone
|
|
74
|
-
result(child_id,
|
|
76
|
+
result(child_id, status,
|
|
75
77
|
"the child's worker is gone (it stopped or crashed); delegate with session: #{child_id} starts it again with a message")
|
|
76
78
|
else
|
|
77
79
|
timeout_result(child_id, timeout)
|
|
@@ -95,8 +97,13 @@ module Samagotchi
|
|
|
95
97
|
baselines[[parent_id, child.id]] = ReplyWait.baseline_of(child, question_id: nil)
|
|
96
98
|
end
|
|
97
99
|
|
|
98
|
-
def reply_result(child_id, text)
|
|
99
|
-
"session: #{child_id}\nstatus:
|
|
100
|
+
def reply_result(child_id, status, text)
|
|
101
|
+
"session: #{child_id}\nstatus: #{status}\n---\n#{cut(text)}"
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# ParentReport's line for a turn that left no reply, about the child.
|
|
105
|
+
def no_reply_text(wait)
|
|
106
|
+
ParentReport.no_reply_line(wait).sub(/\Athe turn/, "the child's turn")
|
|
100
107
|
end
|
|
101
108
|
|
|
102
109
|
def result(child_id, status, text)
|
|
@@ -59,13 +59,13 @@ module Samagotchi
|
|
|
59
59
|
resolved_scopes.each do |resolved_scope|
|
|
60
60
|
path = memory_path(name, resolved_scope)
|
|
61
61
|
if File.exist?(path)
|
|
62
|
-
body = File.read(path)
|
|
62
|
+
body = File.read(path, encoding: "UTF-8")
|
|
63
63
|
# Append model-specific overlay if key is provided and overlay exists.
|
|
64
64
|
[model_key, fallback_model_key].compact.each do |key|
|
|
65
65
|
overlay_path = ModelOverlay.overlay_path_for(name, key, resolved_scope)
|
|
66
66
|
next unless overlay_path && File.exist?(overlay_path)
|
|
67
67
|
|
|
68
|
-
body += SEPARATOR + "Model-specific guidance (#{key}):\n" + File.read(overlay_path)
|
|
68
|
+
body += SEPARATOR + "Model-specific guidance (#{key}):\n" + File.read(overlay_path, encoding: "UTF-8")
|
|
69
69
|
break
|
|
70
70
|
end
|
|
71
71
|
results << body
|
|
@@ -114,7 +114,7 @@ module Samagotchi
|
|
|
114
114
|
def self.scoped_index(scope)
|
|
115
115
|
dir = memories_dir(scope)
|
|
116
116
|
index_path = File.join(dir, "#{MEMORY_INDEX}.md")
|
|
117
|
-
return File.read(index_path) if File.exist?(index_path)
|
|
117
|
+
return File.read(index_path, encoding: "UTF-8") if File.exist?(index_path)
|
|
118
118
|
|
|
119
119
|
files = Dir.glob(File.join(dir, "*.md")).sort
|
|
120
120
|
return "No memories stored yet." if files.empty?
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Samagotchi
|
|
4
|
+
# chi's files (memories, AGENT.md, sessions, config.yml) are UTF-8,
|
|
5
|
+
# whatever the locale. Under LC_ALL=C (or with no locale at all: an app
|
|
6
|
+
# started from Finder or launchd, `env -i`) Ruby reads files as US-ASCII,
|
|
7
|
+
# and a UTF-8 memory index then breaks the system prompt
|
|
8
|
+
# (Encoding::CompatibilityError). bin/chi and every worker call #apply!
|
|
9
|
+
# first.
|
|
10
|
+
module Utf8Default
|
|
11
|
+
LOCALE_KEYS = %w[LC_ALL LC_CTYPE LANG].freeze
|
|
12
|
+
|
|
13
|
+
# Files read as UTF-8 when the locale says US-ASCII. With no locale set
|
|
14
|
+
# at all, LANG too, so the workers and tools chi spawns get one; a
|
|
15
|
+
# locale the user set (LC_ALL=C) is left to them.
|
|
16
|
+
def self.apply!(env = ENV)
|
|
17
|
+
return unless Encoding.default_external == Encoding::US_ASCII
|
|
18
|
+
|
|
19
|
+
env["LANG"] = "en_US.UTF-8" if LOCALE_KEYS.none? { |key| env[key] }
|
|
20
|
+
verbose = $VERBOSE
|
|
21
|
+
begin
|
|
22
|
+
$VERBOSE = nil # Encoding.default_external= warns
|
|
23
|
+
Encoding.default_external = Encoding::UTF_8
|
|
24
|
+
ensure
|
|
25
|
+
$VERBOSE = verbose
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
data/lib/samagotchi/version.rb
CHANGED
data/lib/samagotchi/web/app.rb
CHANGED
|
@@ -11,6 +11,7 @@ require "rack/request"
|
|
|
11
11
|
|
|
12
12
|
require_relative "../bridge_client"
|
|
13
13
|
require_relative "../bridge/bounded_queue"
|
|
14
|
+
require_relative "../bridge/card_store"
|
|
14
15
|
require_relative "../session"
|
|
15
16
|
require_relative "../session_manager"
|
|
16
17
|
require_relative "../session_commands"
|
|
@@ -533,7 +534,7 @@ module Samagotchi
|
|
|
533
534
|
# ?cards=1: the page's re-read when a card arrives, for its rendered
|
|
534
535
|
# body (the stream carries the card's plain text only).
|
|
535
536
|
if req.params["cards"] == "1"
|
|
536
|
-
return json_response(200, { cards: cards_for_display(turn_snapshot) })
|
|
537
|
+
return json_response(200, { cards: cards_for_display(turn_snapshot, id) })
|
|
537
538
|
end
|
|
538
539
|
snapshot = live && live["session_state_snapshot"]
|
|
539
540
|
last_event_seq = snapshot ? snapshot["event_seq"] : bridge_event_seq(id)
|
|
@@ -584,7 +585,7 @@ module Samagotchi
|
|
|
584
585
|
plugin_warning: turn_snapshot && turn_snapshot["plugin_warning"],
|
|
585
586
|
# Plugins' slow setup still running (chi.init): the page's init row.
|
|
586
587
|
init_tasks: turn_snapshot ? Array(turn_snapshot["init_tasks"]) : [],
|
|
587
|
-
cards: cards_for_display(turn_snapshot),
|
|
588
|
+
cards: cards_for_display(turn_snapshot, id),
|
|
588
589
|
# The composer's / autocomplete; the built-ins until a worker
|
|
589
590
|
# names its plugins' too.
|
|
590
591
|
commands: turn_snapshot&.fetch("commands", nil) || SessionCommands.builtin_registry.listing,
|
|
@@ -601,11 +602,14 @@ module Samagotchi
|
|
|
601
602
|
end
|
|
602
603
|
|
|
603
604
|
# The worker's last cards and between-turns notices (Bridge
|
|
604
|
-
# snapshot[:cards]),
|
|
605
|
-
#
|
|
606
|
-
#
|
|
607
|
-
|
|
608
|
-
|
|
605
|
+
# snapshot[:cards]), else (no live worker) the ones the last worker
|
|
606
|
+
# saved in the session's folder (CardStore.saved), a card's body as
|
|
607
|
+
# body_html: rendered markdown when the renderer is on, else the
|
|
608
|
+
# escaped text in a <pre>.
|
|
609
|
+
# @return [Array<Hash>]
|
|
610
|
+
def cards_for_display(turn_snapshot, id)
|
|
611
|
+
cards = turn_snapshot ? turn_snapshot["cards"] : saved_cards(id)
|
|
612
|
+
Array(cards).filter_map do |card|
|
|
609
613
|
next unless card.is_a?(Hash)
|
|
610
614
|
next card unless card["type"].to_s == "card"
|
|
611
615
|
|
|
@@ -613,6 +617,13 @@ module Samagotchi
|
|
|
613
617
|
end
|
|
614
618
|
end
|
|
615
619
|
|
|
620
|
+
# As JSON has them (string keys and values), like a snapshot's.
|
|
621
|
+
def saved_cards(id)
|
|
622
|
+
JSON.parse(JSON.generate(Samagotchi::Bridge::CardStore.saved(Session.session_dir(id, state_dir: default_state_dir))))
|
|
623
|
+
rescue StandardError
|
|
624
|
+
[]
|
|
625
|
+
end
|
|
626
|
+
|
|
616
627
|
def card_body_html(body)
|
|
617
628
|
return "" if body.strip.empty?
|
|
618
629
|
|
|
@@ -12,6 +12,9 @@ export const ROW_STATUS = Object.freeze({
|
|
|
12
12
|
ERROR: "error",
|
|
13
13
|
// A task_wait the user's Stop ended (the task itself runs on).
|
|
14
14
|
STOPPED: "stopped",
|
|
15
|
+
// A call a guardrail denied (a hook, the guardrails bundle, a parent's
|
|
16
|
+
// or the user's Deny): it never ran, and it isn't a failed call either.
|
|
17
|
+
BLOCKED: "blocked",
|
|
15
18
|
});
|
|
16
19
|
|
|
17
20
|
// What a row shows for its tool: a plugin tool's label ("chrome:
|
|
@@ -101,9 +104,11 @@ export function addCompleted(model, event = {}) {
|
|
|
101
104
|
return row;
|
|
102
105
|
}
|
|
103
106
|
|
|
104
|
-
// A completed call's row status from the server's: error, stopped,
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
|
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 (
|
|
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
|
+
}
|