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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +116 -1
- data/README.md +20 -7
- data/bin/chi +9 -9
- data/docs/architecture.md +4 -2
- data/docs/cli.md +34 -10
- data/docs/configuration.md +79 -15
- data/docs/desktop.md +10 -6
- data/docs/guardrails.md +74 -7
- data/docs/hooks.md +13 -9
- data/docs/memory.md +5 -3
- data/docs/plugins.md +31 -16
- data/docs/sessions.md +9 -6
- data/docs/sub-agent.md +128 -0
- data/lib/samagotchi/answer_command.rb +220 -0
- data/lib/samagotchi/bridge/card_store.rb +94 -6
- data/lib/samagotchi/bridge.rb +14 -2
- data/lib/samagotchi/bridge_client.rb +8 -3
- data/lib/samagotchi/bundle_command.rb +18 -10
- data/lib/samagotchi/bundles/check-in/manifest.yml +2 -2
- data/lib/samagotchi/bundles/check-in/plugin.rb +68 -5
- data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +19 -0
- data/lib/samagotchi/bundles/guardrails/manifest.yml +2 -2
- data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
- data/lib/samagotchi/bundles/mcp/plugin.rb +166 -34
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +5 -5
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/cli/command.rb +3 -2
- data/lib/samagotchi/cli/exit.rb +23 -0
- data/lib/samagotchi/cli/parent_wait.rb +82 -0
- data/lib/samagotchi/config.rb +81 -24
- data/lib/samagotchi/context_window.rb +30 -5
- data/lib/samagotchi/desktop_command.rb +5 -3
- data/lib/samagotchi/engine.rb +54 -17
- data/lib/samagotchi/guardrails/parent_approvals.rb +158 -0
- data/lib/samagotchi/hooks/bundle_loader.rb +10 -3
- data/lib/samagotchi/host_registry.rb +31 -7
- data/lib/samagotchi/kernel_loop.rb +1 -1
- data/lib/samagotchi/llm/chat_loop.rb +5 -3
- data/lib/samagotchi/llm/openai_chat.rb +9 -1
- data/lib/samagotchi/llm/turn_settings.rb +7 -5
- data/lib/samagotchi/memory_bundle/installer.rb +12 -0
- data/lib/samagotchi/memory_bundle/status.rb +9 -1
- data/lib/samagotchi/memory_paths.rb +8 -0
- data/lib/samagotchi/model_profile.rb +30 -16
- data/lib/samagotchi/parent_report.rb +213 -0
- data/lib/samagotchi/plugin/api.rb +15 -2
- data/lib/samagotchi/prompt_literal_guard.rb +4 -2
- data/lib/samagotchi/question_desk.rb +29 -2
- data/lib/samagotchi/recap_setup.rb +15 -5
- data/lib/samagotchi/reply_wait.rb +5 -1
- data/lib/samagotchi/send_command.rb +50 -75
- data/lib/samagotchi/session.rb +15 -0
- data/lib/samagotchi/session_manager.rb +40 -7
- data/lib/samagotchi/sessions_command.rb +13 -3
- data/lib/samagotchi/system_prompt.rb +11 -3
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +33 -5
- data/lib/samagotchi/terminal_ui/attached_loop.rb +53 -14
- data/lib/samagotchi/terminal_ui.rb +30 -3
- data/lib/samagotchi/thought_stream_splitter.rb +4 -4
- data/lib/samagotchi/tool_call_parser.rb +5 -6
- data/lib/samagotchi/tools/builtin_calls.rb +14 -3
- data/lib/samagotchi/tools/builtins.rb +14 -2
- data/lib/samagotchi/tools/delegate_wait.rb +32 -13
- data/lib/samagotchi/tools/execute.rb +8 -8
- data/lib/samagotchi/tools/memory.rb +3 -3
- data/lib/samagotchi/tools/task_create.rb +3 -2
- data/lib/samagotchi/tools/task_runtime.rb +7 -2
- data/lib/samagotchi/utf8_default.rb +29 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +22 -10
- data/lib/samagotchi/web/message_parts.rb +2 -1
- 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
- data/lib/samagotchi/web/server.rb +2 -1
- data/lib/samagotchi/web/session_summary.rb +3 -11
- 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
|
|
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,
|
|
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,
|
|
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,
|
|
70
|
+
result(child_id, status, waiting_text(child_id, wait.question))
|
|
61
71
|
when :canceled
|
|
62
|
-
result(child_id,
|
|
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,
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
13
|
-
|
|
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
|
-
|
|
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
|
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,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
|
|
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]),
|
|
604
|
-
#
|
|
605
|
-
#
|
|
606
|
-
|
|
607
|
-
|
|
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 =
|
|
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,
|
|
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
|
+
}
|
|
@@ -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),
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
103
|
+
CLI::Exit::INTERRUPTED
|
|
103
104
|
end
|
|
104
105
|
|
|
105
106
|
# @param hub [SessionHub, nil] the session projection the page streams
|