samagotchi 0.29.0 → 0.30.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 +54 -1
- data/docs/cli.md +4 -4
- data/docs/hooks.md +1 -1
- data/docs/plugins.md +1 -1
- data/docs/sessions.md +2 -2
- data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
- data/lib/samagotchi/bundles/mcp/plugin.rb +4 -3
- data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +17 -4
- data/lib/samagotchi/bundles/source-links/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/command_steps.rb +35 -15
- data/lib/samagotchi/host_registry.rb +4 -0
- data/lib/samagotchi/kernel_loop.rb +19 -6
- data/lib/samagotchi/llm/chat_loop.rb +12 -6
- data/lib/samagotchi/model_list_store.rb +117 -0
- data/lib/samagotchi/model_profile.rb +43 -0
- data/lib/samagotchi/send_command.rb +5 -3
- data/lib/samagotchi/session.rb +5 -0
- data/lib/samagotchi/session_manager.rb +9 -2
- data/lib/samagotchi/session_metrics.rb +6 -3
- data/lib/samagotchi/terminal_ui/formatting.rb +7 -4
- data/lib/samagotchi/terminal_ui.rb +35 -5
- data/lib/samagotchi/tool_activity.rb +6 -2
- data/lib/samagotchi/tools/delegate.rb +14 -10
- data/lib/samagotchi/tools/execute.rb +3 -1
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/public/activity.js +2 -1
- data/lib/samagotchi/web/public/app.js +9 -7
- data/lib/samagotchi/web/public/command_view.js +19 -5
- data/lib/samagotchi/web/public/index.html +14 -6
- data/lib/samagotchi/web/public/stage_model.js +18 -0
- data/lib/samagotchi/web/public/stage_view.js +16 -11
- data/lib/samagotchi/web/public/task_stop.js +11 -2
- data/lib/samagotchi/web/public/timing.js +12 -7
- data/lib/samagotchi/web/public/turn_events.js +3 -1
- data/lib/samagotchi/web/public/turn_model.js +16 -11
- data/lib/samagotchi/web/public/turn_view.js +26 -3
- data/lib/samagotchi/worker.rb +21 -13
- metadata +2 -1
|
@@ -241,9 +241,10 @@ module Samagotchi
|
|
|
241
241
|
|
|
242
242
|
# A worker session like the web start page's: saved as running with the
|
|
243
243
|
# message before its worker spawns, so lists and the web show it at
|
|
244
|
-
# once. The full id, so a script can pass it on.
|
|
245
|
-
#
|
|
246
|
-
#
|
|
244
|
+
# once. The full id, so a script can pass it on. The model's host is
|
|
245
|
+
# checked here (in spawn_session, as in the web): an unknown one is
|
|
246
|
+
# refused before anything is spawned; an id the host's saved model list
|
|
247
|
+
# doesn't have starts with a warning on stderr.
|
|
247
248
|
# @return [Integer] the exit status
|
|
248
249
|
def run_new(prompt, options)
|
|
249
250
|
images = !@images.empty?
|
|
@@ -264,6 +265,7 @@ module Samagotchi
|
|
|
264
265
|
error_line("chi send: could not start a session: #{e.message}")
|
|
265
266
|
return 1
|
|
266
267
|
end
|
|
268
|
+
error_line("chi send: warning: #{session.model_warning}") if session.model_warning
|
|
267
269
|
if through_worker
|
|
268
270
|
return 1 unless deliver_new(session, prompt)
|
|
269
271
|
else
|
data/lib/samagotchi/session.rb
CHANGED
|
@@ -79,6 +79,11 @@ module Samagotchi
|
|
|
79
79
|
# sets it); not saved.
|
|
80
80
|
attr_accessor :seed_images_dropped
|
|
81
81
|
|
|
82
|
+
# The warning for a model id its host's saved list doesn't have
|
|
83
|
+
# (ModelProfile.model_warning; SessionManager.spawn_session sets it);
|
|
84
|
+
# not saved.
|
|
85
|
+
attr_accessor :model_warning
|
|
86
|
+
|
|
82
87
|
def initialize(id:, mode:, model_name:, working_directory:, messages:, created_at:, updated_at:,
|
|
83
88
|
metadata_version: METADATA_VERSION, status: STATUS_IDLE, last_prompt: "",
|
|
84
89
|
first_preview: "", test_run: false, pending_question: nil,
|
|
@@ -130,14 +130,20 @@ module Samagotchi
|
|
|
130
130
|
# @param images_from [String, nil] the session dir the seed's images are in
|
|
131
131
|
# @param title [String, nil] what the lists show before the first turn
|
|
132
132
|
# (the prompt's preview by default, else the seed's first user message)
|
|
133
|
-
# @return [Session] with #seed_images_dropped: refs whose file was gone
|
|
133
|
+
# @return [Session] with #seed_images_dropped: refs whose file was gone,
|
|
134
|
+
# and #model_warning: an id its host's saved list doesn't have
|
|
134
135
|
def self.spawn_session(prompt:, mode: "assist", working_directory: nil, model_name: nil, state_dir: nil,
|
|
135
136
|
memories: [], muted_memories: [], parent_id: nil, messages: [], images_from: nil,
|
|
136
137
|
title: nil)
|
|
137
138
|
sd = state_dir || Session.default_state_dir
|
|
138
139
|
# The resolved ref is stored (a resumed session keeps its model when an
|
|
139
|
-
# alias is retargeted), with the name as typed beside it.
|
|
140
|
+
# alias is retargeted), with the name as typed beside it. A wrong host
|
|
141
|
+
# refuses here, before anything is saved or spawned; a model id the
|
|
142
|
+
# host's saved list doesn't have (ModelListStore) only warns
|
|
143
|
+
# (#model_warning on the session), as some hosts serve ids they don't
|
|
144
|
+
# list.
|
|
140
145
|
typed = Samagotchi::ModelProfile.check_host!(Samagotchi::ModelProfile.required_model_name(model_name))
|
|
146
|
+
model_warning = Samagotchi::ModelProfile.model_warning(typed)
|
|
141
147
|
ref = ConfigFile.model_ref(typed).ref
|
|
142
148
|
session = Session.new_session(
|
|
143
149
|
mode: mode,
|
|
@@ -149,6 +155,7 @@ module Samagotchi
|
|
|
149
155
|
parent_id: parent_id,
|
|
150
156
|
messages: messages
|
|
151
157
|
)
|
|
158
|
+
session.model_warning = model_warning
|
|
152
159
|
# With no prompt there is no first turn to run (an attaching UI sends
|
|
153
160
|
# the prompts), so the session starts idle.
|
|
154
161
|
session.status = prompt.to_s.strip.empty? ? Session::STATUS_IDLE : Session::STATUS_RUNNING
|
|
@@ -481,9 +481,12 @@ module Samagotchi
|
|
|
481
481
|
# A turn that failed mid-stream never saw its generation end:
|
|
482
482
|
# what it got to still counts.
|
|
483
483
|
close_generation if @turn.gen_open
|
|
484
|
-
# A call still running when the turn ends
|
|
485
|
-
#
|
|
486
|
-
|
|
484
|
+
# A call still running when the turn ends never completes: its
|
|
485
|
+
# record says so, as the count already has it. A cancel stopped it
|
|
486
|
+
# ("stopped", as ToolActivity names a call the Stop cut); a
|
|
487
|
+
# failure left it "canceled".
|
|
488
|
+
cut = status == "canceled" ? "stopped" : "canceled"
|
|
489
|
+
@turn.tool_calls_by_id.each_value { |active| finish_tool_record(active, cut) }
|
|
487
490
|
@turn.tool_calls_by_id.clear
|
|
488
491
|
finished_at = now
|
|
489
492
|
record = {
|
|
@@ -226,19 +226,22 @@ module Samagotchi
|
|
|
226
226
|
# A cancel's reason as the UIs name it (anything else as it is);
|
|
227
227
|
# spec/shared/labels_matrix.json pins it for both.
|
|
228
228
|
CANCEL_REASONS = { "ctrl_c" => "Ctrl-C", "user" => "stopped", "hook" => "by a hook" }.freeze
|
|
229
|
+
# A canceled turn's mark: a stop someone chose (Ctrl-C, Stop, a hook),
|
|
230
|
+
# not an error's ✕ (timing.js STOP_MARK; the labels matrix pins it).
|
|
231
|
+
STOP_MARK = "■"
|
|
229
232
|
# Under a canceled prompt turn (a canceled continue is back where it
|
|
230
233
|
# started): its partial progress stays in the conversation.
|
|
231
234
|
ROLLBACK_HINT = "partial progress kept; !rollback restores the pre-turn state"
|
|
232
235
|
|
|
233
|
-
# "
|
|
234
|
-
# (+by+, turn_canceled's cancelled_by): "
|
|
236
|
+
# "■ turn canceled (Ctrl-C) · 3.1s"; a hook's stop that names who
|
|
237
|
+
# (+by+, turn_canceled's cancelled_by): "■ turn stopped by loop-guard"
|
|
235
238
|
def turn_canceled_line(reason, duration_ms, by: nil)
|
|
236
239
|
if reason.to_s == "hook" && !by.to_s.empty?
|
|
237
|
-
return "#{paint(
|
|
240
|
+
return "#{paint(STOP_MARK, 33)} turn stopped by #{by}#{turn_duration_suffix(duration_ms)}"
|
|
238
241
|
end
|
|
239
242
|
|
|
240
243
|
label = reason.to_s.empty? ? nil : CANCEL_REASONS.fetch(reason.to_s, reason.to_s)
|
|
241
|
-
"#{paint(
|
|
244
|
+
"#{paint(STOP_MARK, 33)} turn canceled#{" (#{label})" if label}#{turn_duration_suffix(duration_ms)}"
|
|
242
245
|
end
|
|
243
246
|
|
|
244
247
|
# "✕ turn failed: <summary> · 2.0s"
|
|
@@ -16,6 +16,7 @@ require_relative "host_registry"
|
|
|
16
16
|
require_relative "session"
|
|
17
17
|
require_relative "owner_lock"
|
|
18
18
|
require_relative "bridge/card_store"
|
|
19
|
+
require_relative "bridge/pending_card"
|
|
19
20
|
require_relative "engine"
|
|
20
21
|
require_relative "guardrails/parent_approvals"
|
|
21
22
|
require_relative "tools/memory"
|
|
@@ -298,7 +299,10 @@ module Samagotchi
|
|
|
298
299
|
ensure
|
|
299
300
|
# However it ends (the early returns too; the Engine was built in
|
|
300
301
|
# #initialize): the anytime commands finish, the plugins' services
|
|
301
|
-
# stop (a server process).
|
|
302
|
+
# stop (a server process). Nothing resolves an open card any more, so
|
|
303
|
+
# its pending_card.json goes (a scratch session's folder is deleted
|
|
304
|
+
# below anyway; the others stay).
|
|
305
|
+
clear_pending_card
|
|
302
306
|
@engine&.shutdown
|
|
303
307
|
# A scratch session goes on every way out: /exit, Ctrl-D, an error,
|
|
304
308
|
# SIGTERM or SIGHUP (Ruby raises those here). Only kill -9 leaves it
|
|
@@ -308,6 +312,17 @@ module Samagotchi
|
|
|
308
312
|
|
|
309
313
|
EMPTY_ANSWER_ERROR = "chi: the model gave an empty answer"
|
|
310
314
|
|
|
315
|
+
# The session is over for this process (its exits and the ensure below):
|
|
316
|
+
# nothing will resolve the open card any more, so its pending_card.json
|
|
317
|
+
# goes (the Engine's TURN_END would clear it, but the REPL may end
|
|
318
|
+
# mid-turn).
|
|
319
|
+
def clear_pending_card
|
|
320
|
+
return unless @pending_card_handle
|
|
321
|
+
|
|
322
|
+
@pending_card_handle.observer.clear
|
|
323
|
+
@pending_card_handle = nil
|
|
324
|
+
end
|
|
325
|
+
|
|
311
326
|
# A -p --non-interactive turn that failed (bin/chi exits 1): the error,
|
|
312
327
|
# then how to go on. The Engine saved the failed turn with its prompt,
|
|
313
328
|
# so the session is kept and `chi --resume` takes it up again.
|
|
@@ -415,13 +430,28 @@ module Samagotchi
|
|
|
415
430
|
@surface.commit("Session #{session.id} was not archived (#{e.message}): chi sessions archive #{session.id}")
|
|
416
431
|
end
|
|
417
432
|
|
|
418
|
-
# The session's cards.json, kept as a worker's
|
|
419
|
-
# the session as a worker would): this REPL's
|
|
420
|
-
# the saved count, so a card an earlier worker
|
|
421
|
-
# turns back, and its own cards and notices are
|
|
433
|
+
# The session's cards.json and pending_card.json, kept as a worker's
|
|
434
|
+
# Bridge keeps them (it owns the session as a worker would): this REPL's
|
|
435
|
+
# kept turns count on from the saved count, so a card an earlier worker
|
|
436
|
+
# showed stays that many turns back, and its own cards and notices are
|
|
437
|
+
# saved for the next; an open card with actions is on disk, where
|
|
438
|
+
# `chi web`'s session hub reads it to mark the session as waiting.
|
|
422
439
|
def keep_cards(session)
|
|
423
440
|
path = File.join(Session.session_dir(session.id), Bridge::CardStore::FILE)
|
|
424
441
|
@cards_handle ||= @engine.subscribe(observer: Bridge::CardStore.new(path: path))
|
|
442
|
+
keep_pending_card(session)
|
|
443
|
+
end
|
|
444
|
+
|
|
445
|
+
# A card a worker that died left isn't open: the file goes before the
|
|
446
|
+
# observer takes over, as Bridge#start does. Subscribed once: the REPL
|
|
447
|
+
# keeps one session, and a second observer would fold every card twice
|
|
448
|
+
# and clear the file at the first turn's end.
|
|
449
|
+
def keep_pending_card(session)
|
|
450
|
+
return if @pending_card_handle
|
|
451
|
+
|
|
452
|
+
pending = Bridge::PendingCard.new(Session.session_dir(session.id))
|
|
453
|
+
pending.clear
|
|
454
|
+
@pending_card_handle = @engine.subscribe(observer: pending)
|
|
425
455
|
end
|
|
426
456
|
|
|
427
457
|
# Take the session's OwnerLock for this process's lifetime: the TUI runs
|
|
@@ -176,10 +176,14 @@ module Samagotchi
|
|
|
176
176
|
# "error" for an "Error:" result, and for an execute whose command
|
|
177
177
|
# exited non-zero (its last "exit: N" line; no number: killed by a
|
|
178
178
|
# signal), so the turn tally's "(N failed)" counts it. "stopped" for a
|
|
179
|
-
# task_wait the user's Stop ended (the task itself runs on),
|
|
180
|
-
# whose task was stopped (the stop-task button, or the model's task_stop)
|
|
179
|
+
# task_wait the user's Stop ended (the task itself runs on), for one
|
|
180
|
+
# whose task was stopped (the stop-task button, or the model's task_stop),
|
|
181
|
+
# and for a command the user's Stop killed or kept from starting: a
|
|
182
|
+
# cancel, not a failure (its text still starts "Error:" for the model).
|
|
181
183
|
def tool_activity_status(result, tool_name = nil)
|
|
182
184
|
text = result.to_s
|
|
185
|
+
return "stopped" if text == Tools::Execute::NOT_RUN_ON_STOP
|
|
186
|
+
return "stopped" if tool_name == Tools::Execute::NAME && text.start_with?(Tools::Execute::STOPPED_BY_USER)
|
|
183
187
|
return "error" if text.start_with?("Error:")
|
|
184
188
|
return "error" if tool_name == Tools::Execute::NAME && execute_failed?(text)
|
|
185
189
|
return "stopped" if tool_name == Tools::TaskWait::NAME && text.match?(/^(wait_result: canceled|status: stopped)$/)
|
|
@@ -51,16 +51,18 @@ module Samagotchi
|
|
|
51
51
|
wait = parse_wait(wait)
|
|
52
52
|
timeout = parse_timeout(timeout)
|
|
53
53
|
|
|
54
|
-
child_id = if session.to_s.strip.empty?
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
54
|
+
child_id, warning = if session.to_s.strip.empty?
|
|
55
|
+
start_child(task, parent: parent, model: model, state_dir: sd)
|
|
56
|
+
else
|
|
57
|
+
follow_up(task, session: session.to_s.strip, parent: parent, state_dir: sd)
|
|
58
|
+
end
|
|
59
59
|
return child_id if child_id.start_with?("Error:")
|
|
60
|
-
|
|
60
|
+
|
|
61
|
+
note = warning ? "Warning: #{warning}\n" : ""
|
|
62
|
+
return note + DelegateWait.call(child_id, peers: peers, timeout: timeout) if wait
|
|
61
63
|
|
|
62
64
|
started = session.to_s.strip.empty? ? "Started a delegate session" : "Sent the follow-up to delegate #{child_id[0, 8]}"
|
|
63
|
-
"session: #{child_id}\nstatus: running\n#{started}; delegate_result waits for its reply. " \
|
|
65
|
+
"#{note}session: #{child_id}\nstatus: running\n#{started}; delegate_result waits for its reply. " \
|
|
64
66
|
"It shows in chi sessions list and the web as a child of this session; the user can attach to it."
|
|
65
67
|
rescue ArgumentError => e
|
|
66
68
|
"Error: #{e.message}"
|
|
@@ -84,7 +86,7 @@ module Samagotchi
|
|
|
84
86
|
model_name: child_model(model, parent), memories: CHILD_MEMORIES,
|
|
85
87
|
parent_id: parent.id, state_dir: state_dir)
|
|
86
88
|
DelegateWait.mark_started(parent.id, child)
|
|
87
|
-
child.id
|
|
89
|
+
[child.id, child.model_warning]
|
|
88
90
|
end
|
|
89
91
|
private_class_method :start_child
|
|
90
92
|
|
|
@@ -118,8 +120,10 @@ module Samagotchi
|
|
|
118
120
|
private_class_method :follow_up
|
|
119
121
|
|
|
120
122
|
# The model as typed (spawn_session stores its resolved ref), else the
|
|
121
|
-
# parent's. No existence check:
|
|
122
|
-
#
|
|
123
|
+
# parent's. No existence check here: spawn_session checks the host
|
|
124
|
+
# (check_host!, its UnknownHost comes back as this tool's Error: line)
|
|
125
|
+
# and warns about an id the host's saved model list doesn't have
|
|
126
|
+
# (ModelProfile.model_warning, added to this tool's result).
|
|
123
127
|
def self.child_model(model, parent)
|
|
124
128
|
return parent.model_name if model.to_s.strip.empty?
|
|
125
129
|
|
|
@@ -21,6 +21,8 @@ module Samagotchi
|
|
|
21
21
|
STOP_POLL_INTERVAL_SEC = 0.05
|
|
22
22
|
WAIT_SLICE_SEC = 0.2
|
|
23
23
|
NOT_RUN_ON_STOP = "Error: not run, the user stopped the turn"
|
|
24
|
+
# The first words of a command the user's Stop killed.
|
|
25
|
+
STOPPED_BY_USER = "Error: command stopped by the user"
|
|
24
26
|
TIMEOUT_HINT = "(killed at the limit; for a long command use task_create, then task_wait)"
|
|
25
27
|
# How long the output may stay open once the shell has exited: a
|
|
26
28
|
# `server &` child keeps the pipes, and the read would never end.
|
|
@@ -67,7 +69,7 @@ module Samagotchi
|
|
|
67
69
|
# a long command, and return whatever it printed before it was killed.
|
|
68
70
|
stopped_result("Error: command timed out after #{timeout_sec}s\n#{TIMEOUT_HINT}", e)
|
|
69
71
|
rescue CommandCancelled => e
|
|
70
|
-
stopped_result("
|
|
72
|
+
stopped_result("#{STOPPED_BY_USER} after #{e.elapsed.round}s (killed; rerun it if still needed)", e)
|
|
71
73
|
rescue StandardError => e
|
|
72
74
|
"Error: #{e.message}"
|
|
73
75
|
end
|
data/lib/samagotchi/version.rb
CHANGED
|
@@ -10,7 +10,8 @@ export const ROW_STATUS = Object.freeze({
|
|
|
10
10
|
RUNNING: "running",
|
|
11
11
|
OK: "ok",
|
|
12
12
|
ERROR: "error",
|
|
13
|
-
// A
|
|
13
|
+
// A call the user's Stop cut: a task_wait (the task itself runs on), a
|
|
14
|
+
// killed or never-started command. Not a failure.
|
|
14
15
|
STOPPED: "stopped",
|
|
15
16
|
// A call a guardrail denied (a hook, the guardrails bundle, a parent's
|
|
16
17
|
// or the user's Deny): it never ran, and it isn't a failed call either.
|
|
@@ -43,7 +43,7 @@ import { applySessionEvent, heldOrder, listedSessions, sortedByUpdated, stoppedB
|
|
|
43
43
|
import { createNotifications } from "./notifications.js";
|
|
44
44
|
import { restartConfirmText, versionNotice, workerBadge } from "./update_notice.js";
|
|
45
45
|
import {
|
|
46
|
-
appendAboveLiveTiming, cancelLineText, elapsedSince, formatDuration, mergeTiming, normalizeTiming, turnTimingText,
|
|
46
|
+
appendAboveLiveTiming, cancelLineText, elapsedSince, formatDuration, mergeTiming, normalizeTiming, STOPPED_LINE_CLASS, turnTimingText,
|
|
47
47
|
} from "./timing.js";
|
|
48
48
|
import { DONE_MS, applyInitEvent, dropDone, initRowHtml, newInitTasks, seedInitTasks } from "./init_row.js";
|
|
49
49
|
import { clientLabel, commandView, displayWait, dropEarlyRestores, emptyAnswerLine, emptyRetryLine, hookNoticeLabel, initWaitLine, isOwn, sessionCommandLine, keepEarlyRestore, newClientId, noticeLine, promptOps, reminderText, restoreAction, composerAfterAck, restoreOnAck, replaysDrawnTurn, retryStatusLine, snapshotEvents, steerCutLine, startPageReplyOrHint, turnOutput, unknownCommandHint, webLocalReply, workerGoneText } from "./turn_events.js";
|
|
@@ -960,9 +960,11 @@ function appendChunk(text) {
|
|
|
960
960
|
if (follow) historyEl.scrollTop = historyEl.scrollHeight;
|
|
961
961
|
}
|
|
962
962
|
|
|
963
|
-
|
|
963
|
+
// +stopped+: a stop someone chose (a cancel, the session's stop), drawn in
|
|
964
|
+
// the stopped style (timing.js STOPPED_LINE_CLASS); else a failure's line.
|
|
965
|
+
function addEndBubble(text, { stopped = false } = {}) {
|
|
964
966
|
const div = document.createElement("div");
|
|
965
|
-
div.className = "bubble cancel";
|
|
967
|
+
div.className = stopped ? STOPPED_LINE_CLASS : "bubble cancel";
|
|
966
968
|
div.textContent = text;
|
|
967
969
|
appendToHistory(div);
|
|
968
970
|
if (!turnDom.holds?.()) historyEl.scrollTop = historyEl.scrollHeight;
|
|
@@ -980,7 +982,7 @@ function emptyAnswerEl(emptyAnswer) {
|
|
|
980
982
|
// Everything a turn's end (completed, canceled, failed) closes: then its
|
|
981
983
|
// end line (+endText+, the cancel or failed line), then the step cards that
|
|
982
984
|
// left the collapsed block (card.js leavesBlock), where a reload puts them.
|
|
983
|
-
function endTurnView(kind, endText = null, { emptyAnswer = null } = {}) {
|
|
985
|
+
function endTurnView(kind, endText = null, { emptyAnswer = null, stopped = false } = {}) {
|
|
984
986
|
setTurnRunning(false);
|
|
985
987
|
// Every ended turn has a record (completed, canceled, failed); a worker
|
|
986
988
|
// gone mid-turn is re-rendered (resync), which counts again.
|
|
@@ -991,7 +993,7 @@ function endTurnView(kind, endText = null, { emptyAnswer = null } = {}) {
|
|
|
991
993
|
const kept = turnDom.turnEnded({ kind, endNote, emptyAnswer: !!emptyAnswer }) || [];
|
|
992
994
|
if (endNote && !endNote.isConnected) appendToHistory(endNote);
|
|
993
995
|
finishLiveTurnTiming(null, { canceled: kind === "canceled" });
|
|
994
|
-
if (endText) addEndBubble(endText);
|
|
996
|
+
if (endText) addEndBubble(endText, { stopped });
|
|
995
997
|
if (kept.length) {
|
|
996
998
|
kept.forEach((el) => appendToHistory(el));
|
|
997
999
|
if (!turnDom.holds?.()) historyEl.scrollTop = historyEl.scrollHeight;
|
|
@@ -1653,7 +1655,7 @@ function workerGone(id, { opened = true } = {}) {
|
|
|
1653
1655
|
const stopped = stoppingId === id;
|
|
1654
1656
|
const endText = workerGoneText({ turnRunning, stopped });
|
|
1655
1657
|
if (endText) {
|
|
1656
|
-
endTurnView("gone", endText);
|
|
1658
|
+
endTurnView("gone", endText, { stopped });
|
|
1657
1659
|
if (!stopped) setLiveStatus("idle");
|
|
1658
1660
|
}
|
|
1659
1661
|
if (current.owner === "worker") current.owner = null;
|
|
@@ -1770,7 +1772,7 @@ const streamHandlers = {
|
|
|
1770
1772
|
}
|
|
1771
1773
|
},
|
|
1772
1774
|
turn_canceled: (data) => {
|
|
1773
|
-
endTurnView("canceled", cancelLineText(data.cancellation_reason || "", data.cancelled_by));
|
|
1775
|
+
endTurnView("canceled", cancelLineText(data.cancellation_reason || "", data.cancelled_by), { stopped: true });
|
|
1774
1776
|
setLiveStatus("idle");
|
|
1775
1777
|
refreshTimingFromSession();
|
|
1776
1778
|
},
|
|
@@ -82,6 +82,24 @@ export function stepTextHtml(text) {
|
|
|
82
82
|
return out + escapeHtml(String(text).slice(at));
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
+
function heredocLabel({ tag, lines }) {
|
|
86
|
+
return `${tag} · ${Number(lines)} ${lines === 1 ? "line" : "lines"}`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// A step's heredoc chip: the first heredoc's tag and size, and "+N" for
|
|
90
|
+
// the others when the step has more (CommandSteps' heredocs:, as gh api
|
|
91
|
+
// -f body="$(cat <<EOF …)" -f title="$(cat <<EOF …)"), as a title with
|
|
92
|
+
// more steps reads "+N"; the hover lists them all. "" without one.
|
|
93
|
+
export function heredocChip(step) {
|
|
94
|
+
if (!step?.heredoc) return "";
|
|
95
|
+
const all = Array.isArray(step.heredocs) && step.heredocs.length > 1 ? step.heredocs : [step.heredoc];
|
|
96
|
+
const more = all.length > 1 ? ` +${all.length - 1}` : "";
|
|
97
|
+
const title = all.length > 1
|
|
98
|
+
? `${all.length} heredocs: ${all.map(heredocLabel).join(", ")}; their text in the raw command`
|
|
99
|
+
: "a heredoc: its text in the raw command";
|
|
100
|
+
return `<span class="step-chip heredoc" title="${escapeHtml(title)}">${escapeHtml(heredocLabel(all[0]))}${more}</span>`;
|
|
101
|
+
}
|
|
102
|
+
|
|
85
103
|
// The steps as a list: a label as a small heading above its step, the op
|
|
86
104
|
// that links a step to the one before, its text, its limit and heredoc
|
|
87
105
|
// as chips (beside the text, below it when the row is narrow).
|
|
@@ -89,11 +107,7 @@ export function stepsHtml(steps) {
|
|
|
89
107
|
const items = steps.map((step) => {
|
|
90
108
|
const label = step.label ? `<li class="step-label">${escapeHtml(step.label)}</li>` : "";
|
|
91
109
|
const limit = step.limit ? `<span class="step-chip" title="output limited">${escapeHtml(step.limit)}</span>` : "";
|
|
92
|
-
|
|
93
|
-
? `<span class="step-chip heredoc" title="a heredoc: its text in the raw command">${escapeHtml(step.heredoc.tag)} · ` +
|
|
94
|
-
`${Number(step.heredoc.lines)} ${step.heredoc.lines === 1 ? "line" : "lines"}</span>`
|
|
95
|
-
: "";
|
|
96
|
-
return `${label}<li class="activity-step">${opHtml(step.op)}<span class="step-body"><code>${stepTextHtml(step.text)}</code>${limit}${heredoc}</span></li>`;
|
|
110
|
+
return `${label}<li class="activity-step">${opHtml(step.op)}<span class="step-body"><code>${stepTextHtml(step.text)}</code>${limit}${heredocChip(step)}</span></li>`;
|
|
97
111
|
});
|
|
98
112
|
return `<ol class="activity-steps">${items.join("")}</ol>`;
|
|
99
113
|
}
|
|
@@ -122,6 +122,8 @@ main{flex:1;display:flex;flex-direction:column;gap:16px;padding:16px;overflow:au
|
|
|
122
122
|
.bubble.user blockquote:last-child{margin-bottom:0}
|
|
123
123
|
.bubble.tool{border-left:3px solid var(--ok);background:var(--bg-panel);font-size:13px;color:var(--text-2)}
|
|
124
124
|
.bubble.cancel{border-left:3px solid var(--danger);background:color-mix(in srgb,var(--danger) 8%,var(--bg-panel));color:var(--danger-text);font-size:13px}
|
|
125
|
+
/* A stop someone chose (a cancel, the session's stop): the ■ STOPPED rows' colours, not an error's. */
|
|
126
|
+
.bubble.cancel.stopped{border-left-color:var(--warn);background:color-mix(in srgb,var(--warn) 8%,var(--bg-panel));color:var(--warn-text)}
|
|
125
127
|
.bubble.recap{border-left:3px solid var(--think-text);background:color-mix(in srgb,var(--think) 6%,var(--bg-panel));font-size:13px}
|
|
126
128
|
.bubble.recap summary{cursor:pointer;color:var(--think-text);font-size:12px}
|
|
127
129
|
.bubble.recap .recap-body{white-space:pre-wrap;margin-top:6px}
|
|
@@ -236,6 +238,8 @@ button.task-stop:hover:not(:disabled){background:var(--danger-bg-dim);filter:non
|
|
|
236
238
|
.gen>summary::-webkit-details-marker{display:none}
|
|
237
239
|
.gen>summary::before{content:"▸ ";color:var(--text-faint)}
|
|
238
240
|
.gen[open]>summary::before{content:"▾ "}
|
|
241
|
+
/* Open, a step whose label starts with its body's first words (turn_view.js labelParts) says only its calls. */
|
|
242
|
+
.gen[open]>summary.repeats>:is(.gen-head,.gen-sep){display:none}
|
|
239
243
|
/* A step with a failed call (status error): its chevron in the danger colour. */
|
|
240
244
|
.gen.has-failed>summary::before{color:var(--danger)}
|
|
241
245
|
.gen.live{border-top-color:transparent;background:var(--accent-tint);border-radius:8px;padding:6px 9px 8px calc(9px + var(--step-indent));margin-left:-9px;margin-right:-9px}
|
|
@@ -545,6 +549,7 @@ body.stage-view #dock,body.stage-view #dock:focus-within{background:transparent;
|
|
|
545
549
|
.turn-stage[data-phase="no answer"] .ts-mark,.turn-stage[data-phase="no answer"] .ts-phase{color:var(--text-muted)}
|
|
546
550
|
.turn-stage[data-phase="waiting for you"] .ts-mark{background:var(--warn)}
|
|
547
551
|
.ts-phase{color:var(--accent);font-weight:600}
|
|
552
|
+
.ts-step:empty{display:none}
|
|
548
553
|
.ts-timing{min-width:0;overflow:hidden;text-overflow:ellipsis}
|
|
549
554
|
.ts-timing .turn-timing{margin:0;font-size:12px;color:var(--text-muted);display:inline}
|
|
550
555
|
.ts-mini{min-width:0;overflow:hidden;text-overflow:ellipsis;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;color:var(--text-2)}
|
|
@@ -579,14 +584,13 @@ body.stage-view #dock,body.stage-view #dock:focus-within{background:transparent;
|
|
|
579
584
|
.ts-spin{flex:none;width:11px;height:11px;border:2px solid var(--warn);border-right-color:transparent;border-radius:50%;animation:init-spin .8s linear infinite}
|
|
580
585
|
.ts-idle{flex:none;width:11px;height:11px;border:2px solid var(--border-strong);border-radius:50%}
|
|
581
586
|
.ts-tool{flex:none;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px;font-weight:600;color:var(--accent)}
|
|
582
|
-
.ts-tool.exec{color:var(--think-text)}
|
|
583
|
-
.ts-tool.edit{color:var(--ok-text)}
|
|
584
587
|
.ts-title{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px;color:var(--text-2)}
|
|
585
588
|
.ts-title.quiet{font-family:inherit;font-style:italic;color:var(--text-faint)}
|
|
586
589
|
.ts-trail{height:18px;display:flex;gap:10px;overflow:hidden;font-size:12px;line-height:18px;color:var(--text-faint);white-space:nowrap}
|
|
587
590
|
.ts-trail-item{flex:none;max-width:36ch;overflow:hidden;text-overflow:ellipsis}
|
|
588
|
-
.ts-trail-item.error{color:var(--danger-text)}
|
|
591
|
+
.ts-trail-item:is(.error,.blocked){color:var(--danger-text)}
|
|
589
592
|
.ts-trail-item.warn{color:var(--warn-text)}
|
|
593
|
+
.ts-trail-item.stopped{color:var(--warn-text);opacity:.8}
|
|
590
594
|
.ts-trail-item.info,.ts-trail-item.steer{color:var(--accent)}
|
|
591
595
|
.ts-trail-item.none{font-style:italic}
|
|
592
596
|
/* A card waits: the tool row and trail give it their room. */
|
|
@@ -617,10 +621,14 @@ body.stage-view #dock,body.stage-view #dock:focus-within{background:transparent;
|
|
|
617
621
|
.ts-ticks{display:flex;flex-wrap:nowrap;justify-content:flex-end;overflow:hidden;gap:2px;flex:0 1 auto;min-width:0}
|
|
618
622
|
.ts-tick{flex:none}
|
|
619
623
|
.ts-tick{width:5px;height:12px;border-radius:1.5px;background:var(--text-faint);opacity:.55;cursor:pointer}
|
|
620
|
-
|
|
621
|
-
.ts-tick.edit{background:var(--
|
|
622
|
-
.ts-tick.other{background:var(--
|
|
624
|
+
/* exec and edit are the rows' tool blue (purple is thinking's, green a done ✓'s); other a quieter blue-grey. */
|
|
625
|
+
.ts-tick:is(.exec,.edit){background:var(--accent);opacity:.85}
|
|
626
|
+
.ts-tick.other{background:var(--narr);opacity:.7}
|
|
623
627
|
.ts-tick.error{background:var(--danger);opacity:1}
|
|
628
|
+
/* A call a guardrail blocked: its ✕ BLOCKED row's red. */
|
|
629
|
+
.ts-tick.blocked{background:var(--danger-text);opacity:1}
|
|
630
|
+
/* A call the user's Stop cut: the ■ STOPPED rows' amber, still (running blinks). */
|
|
631
|
+
.ts-tick.stopped{background:var(--warn-text);opacity:.8}
|
|
624
632
|
.ts-tick.running{background:var(--warn);opacity:1;animation:ts-blink 1s ease-in-out infinite}
|
|
625
633
|
@keyframes ts-blink{50%{opacity:.35}}
|
|
626
634
|
.ts-cloud-body{max-height:calc(var(--stage-max,60vh) * .62);overflow:auto;scrollbar-width:thin;border-left:1px solid var(--border);margin-left:6px;padding-left:10px}
|
|
@@ -76,6 +76,20 @@ export function toolKind(name) {
|
|
|
76
76
|
return "other";
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
+
// A finished call's mark in the trail, as its row's (index.html
|
|
80
|
+
// .activity-status): error and blocked ✕, stopped (a Stop cut it) ■, else ✓.
|
|
81
|
+
export function trailMark(status) {
|
|
82
|
+
if (status === "error" || status === "blocked") return "\u2715";
|
|
83
|
+
if (status === "stopped") return "\u25A0";
|
|
84
|
+
return "\u2713";
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// A finished call's tone in the trail, its class as its row's colour:
|
|
88
|
+
// error and blocked (a guardrail's deny) red, stopped amber, else none.
|
|
89
|
+
export function trailTone(status) {
|
|
90
|
+
return status === "error" || status === "blocked" || status === "stopped" ? status : "";
|
|
91
|
+
}
|
|
92
|
+
|
|
79
93
|
function rowTitle(row) {
|
|
80
94
|
return row.title || row.params || "";
|
|
81
95
|
}
|
|
@@ -116,6 +130,9 @@ const NO_ANSWER = "no answer";
|
|
|
116
130
|
// else "…";
|
|
117
131
|
// tool the running call ({name, title, kind}, and command: its full
|
|
118
132
|
// command when it has one; taskId and key: a task_wait's) or null;
|
|
133
|
+
// quiet the now row's word while no call runs: "writing…" while the
|
|
134
|
+
// step's text streams, else "thinking…"; "" once the turn has
|
|
135
|
+
// ended (nothing happens now);
|
|
119
136
|
// trail the last three finished calls, newest last (a file's title
|
|
120
137
|
// without its dir; command as for tool);
|
|
121
138
|
// ticks one per call: {kind, status, step (the gen's index)}.
|
|
@@ -146,6 +163,7 @@ export function liveSlots(turn, { narration = "", thinking = "", pending = false
|
|
|
146
163
|
phase,
|
|
147
164
|
step: ended ? gens.filter((g) => !g.answer || g.thinking || g.tools.length).length : gens.length,
|
|
148
165
|
headline,
|
|
166
|
+
quiet: ended ? "" : current?.text && !current.textDone ? "writing…" : "thinking…",
|
|
149
167
|
tool: running ? withTask(withCommand({ name: toolName(running), title: rowTitle(running), kind: toolKind(running.tool) }, running), running) : null,
|
|
150
168
|
trail,
|
|
151
169
|
ticks: rows.map(({ row, step }) => ({ kind: toolKind(row.tool), status: row.status, step })),
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
// reload. Slots are patched in place, never rebuilt from HTML: a selection
|
|
12
12
|
// and annotate survive. The rules are pure in stage_model.js.
|
|
13
13
|
import { createTurnView } from "./turn_view.js";
|
|
14
|
-
import { flashOf, handOffDue, handOffOrder, headlineOf, inUseFrom, isPlain, liveSlots, placeFor } from "./stage_model.js";
|
|
14
|
+
import { flashOf, handOffDue, handOffOrder, headlineOf, inUseFrom, isPlain, liveSlots, placeFor, trailMark, trailTone } from "./stage_model.js";
|
|
15
15
|
import { formatDuration } from "./timing.js";
|
|
16
16
|
import { taskCommand, taskStopButton } from "./task_stop.js";
|
|
17
17
|
|
|
@@ -189,20 +189,26 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
|
|
|
189
189
|
stage.dataset.phase = slots.phase;
|
|
190
190
|
mark.textContent = MARKS[slots.phase] || "";
|
|
191
191
|
phaseEl.textContent = slots.phase;
|
|
192
|
-
|
|
192
|
+
const steps = `${slots.step} step${slots.step === 1 ? "" : "s"}`;
|
|
193
193
|
|
|
194
194
|
drawHeadline(slots.headline);
|
|
195
|
-
drawNow(slots.tool,
|
|
195
|
+
drawNow(slots.tool, slots.quiet);
|
|
196
196
|
drawTrail(slots.trail);
|
|
197
197
|
drawTicks(slots.ticks);
|
|
198
198
|
const calls = slots.ticks.length;
|
|
199
|
-
|
|
200
|
-
|
|
199
|
+
// An ended turn says each thing once: its steps in the chip (the label
|
|
200
|
+
// of what the cloud holds), its time in the status row's timing line
|
|
201
|
+
// (the one that moves into the history). The chip keeps the time when
|
|
202
|
+
// that line is elsewhere (a turn the stage didn't hold from its start).
|
|
203
|
+
const elapsed = endKind && !timingSlot.firstChild ? formatDuration(endedAt - startedAt) : "";
|
|
204
|
+
chipLabel.textContent = `${steps} · ${calls} tool call${calls === 1 ? "" : "s"}${elapsed ? ` · ${elapsed}` : ""}`;
|
|
201
205
|
miniTool.textContent = collapsed && slots.tool ? `${slots.tool.name} ${slots.tool.title}` : "";
|
|
202
206
|
miniTool.title = (collapsed && slots.tool?.command) || "";
|
|
203
207
|
|
|
204
208
|
live.hidden = plain || !!endKind;
|
|
205
209
|
cloud.hidden = !blockEl || blockEl.parentNode !== cloudBody;
|
|
210
|
+
// Running: the step it is on. Ended: the count, unless the chip says it.
|
|
211
|
+
stepEl.textContent = !endKind ? `step ${slots.step}` : cloud.hidden ? steps : "";
|
|
206
212
|
promptSlot.hidden = !promptSlot.firstChild;
|
|
207
213
|
extras.hidden = !extras.firstChild;
|
|
208
214
|
answer.hidden = !answer.firstChild;
|
|
@@ -225,8 +231,7 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
|
|
|
225
231
|
headline.classList.add("fresh");
|
|
226
232
|
}
|
|
227
233
|
|
|
228
|
-
function drawNow(tool,
|
|
229
|
-
const quiet = gen?.text && !gen.textDone ? "writing…" : "thinking…";
|
|
234
|
+
function drawNow(tool, quiet) {
|
|
230
235
|
// A task_wait's stop button, as the row's (task_stop.js): in the key
|
|
231
236
|
// with its in-flight state, so a redraw keeps a clicked one disabled.
|
|
232
237
|
const stopId = tool?.taskId && taskStop?.target({ tool: "task_wait", status: "running", view: { task_id: tool.taskId } });
|
|
@@ -248,7 +253,7 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
|
|
|
248
253
|
const command = () => taskCommand(view.current().turn?.gens, stopId);
|
|
249
254
|
now.appendChild(taskStopButton(taskStop, stopId, { command, onChange: scheduleRefresh }));
|
|
250
255
|
}
|
|
251
|
-
} else {
|
|
256
|
+
} else if (quiet) {
|
|
252
257
|
const word = node("span", "ts-title quiet", now);
|
|
253
258
|
word.textContent = quiet;
|
|
254
259
|
}
|
|
@@ -262,9 +267,9 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
|
|
|
262
267
|
trail.replaceChildren();
|
|
263
268
|
if (flashing) node("span", `ts-trail-item ${flashing.kind}`, trail).textContent = `${FLASH_MARKS[flashing.kind]} ${flashing.text}`;
|
|
264
269
|
for (const item of items) {
|
|
265
|
-
const
|
|
266
|
-
const el = node("span", `ts-trail-item${
|
|
267
|
-
el.textContent = `${
|
|
270
|
+
const tone = trailTone(item.status);
|
|
271
|
+
const el = node("span", `ts-trail-item${tone ? ` ${tone}` : ""}`, trail);
|
|
272
|
+
el.textContent = `${trailMark(item.status)} ${item.name} ${item.title}`.trim();
|
|
268
273
|
if (item.command) el.title = item.command;
|
|
269
274
|
}
|
|
270
275
|
if (!flashing && !items.length) node("span", "ts-trail-item none", trail).textContent = "no tool calls yet";
|
|
@@ -19,17 +19,26 @@ export function stopTarget(row, features) {
|
|
|
19
19
|
return row.view?.task_id || null;
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
// A task_wait row's title without its wait ("npm test · up to 600s").
|
|
23
|
+
const WAIT_SUFFIX = / · up to \d+s$/;
|
|
24
|
+
|
|
22
25
|
// The command a task_create of this turn started +taskId+ with (its row's
|
|
23
|
-
// view),
|
|
26
|
+
// view), else a task_wait row's title for it (the server titles one by its
|
|
27
|
+
// task's command, also for a task from an earlier turn: ToolActivity.
|
|
28
|
+
// task_title), or null.
|
|
24
29
|
export function taskCommand(gens, taskId) {
|
|
30
|
+
let titled = null;
|
|
25
31
|
for (const gen of gens || []) {
|
|
26
32
|
for (const row of gen.tools || []) {
|
|
33
|
+
if (row.tool === "task_wait" && row.view?.task_id === taskId && row.title) {
|
|
34
|
+
titled ||= row.title.replace(WAIT_SUFFIX, "");
|
|
35
|
+
}
|
|
27
36
|
if (row.tool !== "task_create") continue;
|
|
28
37
|
const id = /(?:^|\n)task_id: (\S+)/.exec(row.output || "")?.[1];
|
|
29
38
|
if (id === taskId) return row.view?.command || null;
|
|
30
39
|
}
|
|
31
40
|
}
|
|
32
|
-
return
|
|
41
|
+
return titled;
|
|
33
42
|
}
|
|
34
43
|
|
|
35
44
|
export function confirmText(taskId, command) {
|
|
@@ -24,20 +24,25 @@ export function turnTimingText(number, durationMs, { running = false, canceled =
|
|
|
24
24
|
// CANCEL_REASONS); anything else as it is. Both sides: spec/shared/labels_matrix.json.
|
|
25
25
|
const CANCEL_REASONS = { ctrl_c: "Ctrl-C", user: "stopped", hook: "by a hook" };
|
|
26
26
|
|
|
27
|
-
// The line a canceled turn ends with: "
|
|
27
|
+
// The line a canceled turn ends with: "■ canceled (stopped)", live from
|
|
28
28
|
// turn_canceled's reason and after a re-render from the turn record's
|
|
29
29
|
// cancellation_reason (records saved before it existed: no reason). A
|
|
30
|
-
// hook's stop that names who (+by+, cancelled_by): "
|
|
30
|
+
// hook's stop that names who (+by+, cancelled_by): "■ stopped by loop-guard".
|
|
31
|
+
// Every cancel is a stop someone chose (the user, Ctrl-C, a hook), not a
|
|
32
|
+
// failure: the ■ mark and the stopped colours of a call the Stop cut
|
|
33
|
+
// (STOPPED_LINE_CLASS), not the ✕ of an error.
|
|
34
|
+
export const STOP_MARK = "\u25A0";
|
|
35
|
+
export const STOPPED_LINE_CLASS = "bubble cancel stopped";
|
|
31
36
|
export function cancelLineText(reason, by) {
|
|
32
|
-
if (reason === "hook" && by) return
|
|
33
|
-
return
|
|
37
|
+
if (reason === "hook" && by) return `${STOP_MARK} stopped by ${by}`;
|
|
38
|
+
return `${STOP_MARK} canceled` + (reason ? ` (${CANCEL_REASONS[reason] || reason})` : "");
|
|
34
39
|
}
|
|
35
40
|
|
|
36
|
-
// A re-rendered turn's cancel line (a .bubble.cancel), or "" when
|
|
37
|
-
// record is not a canceled one.
|
|
41
|
+
// A re-rendered turn's cancel line (a .bubble.cancel.stopped), or "" when
|
|
42
|
+
// the turn record is not a canceled one.
|
|
38
43
|
export function cancelLineHtml(record, escape) {
|
|
39
44
|
if (record?.status !== "canceled") return "";
|
|
40
|
-
return `<div class="
|
|
45
|
+
return `<div class="${STOPPED_LINE_CLASS}">${escape(cancelLineText(record.cancellation_reason, record.cancelled_by))}</div>`;
|
|
41
46
|
}
|
|
42
47
|
|
|
43
48
|
export function elapsedSince(startedAt, now = Date.now()) {
|