samagotchi 0.29.0 → 0.31.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +99 -1
  3. data/docs/cli.md +4 -4
  4. data/docs/configuration.md +4 -0
  5. data/docs/hooks.md +1 -1
  6. data/docs/internals/engine.md +1 -1
  7. data/docs/plugins.md +1 -1
  8. data/docs/sessions.md +11 -4
  9. data/lib/samagotchi/bridge/card_store.rb +1 -1
  10. data/lib/samagotchi/bridge/turn_notice.rb +1 -1
  11. data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
  12. data/lib/samagotchi/bundles/mcp/plugin.rb +4 -3
  13. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +17 -4
  14. data/lib/samagotchi/bundles/source-links/manifest.yml +2 -2
  15. data/lib/samagotchi/bundles/system/delegated.md +1 -0
  16. data/lib/samagotchi/bundles/system/manifest.yml +3 -3
  17. data/lib/samagotchi/bundles/system/self_map.md +2 -1
  18. data/lib/samagotchi/child_reports.rb +214 -0
  19. data/lib/samagotchi/client.rb +35 -4
  20. data/lib/samagotchi/command_steps.rb +35 -15
  21. data/lib/samagotchi/config.rb +5 -0
  22. data/lib/samagotchi/engine.rb +2 -0
  23. data/lib/samagotchi/host_registry.rb +4 -0
  24. data/lib/samagotchi/idle_recap.rb +4 -0
  25. data/lib/samagotchi/kernel_loop.rb +63 -11
  26. data/lib/samagotchi/llm/chat_loop.rb +14 -7
  27. data/lib/samagotchi/llm/errors.rb +9 -0
  28. data/lib/samagotchi/log_subscriber.rb +1 -1
  29. data/lib/samagotchi/model_list_store.rb +117 -0
  30. data/lib/samagotchi/model_profile.rb +43 -0
  31. data/lib/samagotchi/prompt_warmup.rb +30 -5
  32. data/lib/samagotchi/reply_wait.rb +39 -8
  33. data/lib/samagotchi/send_command.rb +5 -3
  34. data/lib/samagotchi/session.rb +31 -5
  35. data/lib/samagotchi/session_inbox.rb +34 -1
  36. data/lib/samagotchi/session_manager.rb +41 -5
  37. data/lib/samagotchi/session_metrics.rb +6 -3
  38. data/lib/samagotchi/steer.rb +39 -7
  39. data/lib/samagotchi/terminal_ui/attached_loop.rb +4 -2
  40. data/lib/samagotchi/terminal_ui/event_renderer.rb +3 -1
  41. data/lib/samagotchi/terminal_ui/formatting.rb +38 -8
  42. data/lib/samagotchi/terminal_ui.rb +44 -5
  43. data/lib/samagotchi/tool_activity.rb +6 -2
  44. data/lib/samagotchi/tool_call_parser.rb +40 -0
  45. data/lib/samagotchi/tool_declarations.rb +7 -7
  46. data/lib/samagotchi/tools/delegate.rb +26 -13
  47. data/lib/samagotchi/tools/delegate_cursor.rb +91 -0
  48. data/lib/samagotchi/tools/delegate_wait.rb +50 -31
  49. data/lib/samagotchi/tools/execute.rb +3 -1
  50. data/lib/samagotchi/tools/task_runtime.rb +21 -2
  51. data/lib/samagotchi/tools/task_wait.rb +2 -2
  52. data/lib/samagotchi/version.rb +1 -1
  53. data/lib/samagotchi/web/app.rb +8 -2
  54. data/lib/samagotchi/web/public/activity.js +2 -1
  55. data/lib/samagotchi/web/public/app.js +21 -11
  56. data/lib/samagotchi/web/public/command_view.js +19 -5
  57. data/lib/samagotchi/web/public/format.js +8 -1
  58. data/lib/samagotchi/web/public/index.html +14 -6
  59. data/lib/samagotchi/web/public/notify.js +4 -2
  60. data/lib/samagotchi/web/public/stage_model.js +18 -0
  61. data/lib/samagotchi/web/public/stage_view.js +16 -11
  62. data/lib/samagotchi/web/public/task_stop.js +11 -2
  63. data/lib/samagotchi/web/public/timing.js +12 -7
  64. data/lib/samagotchi/web/public/turn_events.js +29 -9
  65. data/lib/samagotchi/web/public/turn_model.js +16 -11
  66. data/lib/samagotchi/web/public/turn_view.js +49 -7
  67. data/lib/samagotchi/worker.rb +240 -23
  68. metadata +4 -1
@@ -6,18 +6,18 @@ require_relative "../parent_report"
6
6
  require_relative "output_guardrails"
7
7
  require_relative "peers"
8
8
  require_relative "delegate_relay"
9
+ require_relative "delegate_cursor"
9
10
 
10
11
  module Samagotchi
11
12
  module Tools
12
13
  # Waiting for a delegated session's next reply, shared by delegate and
13
14
  # delegate_result: ReplyWait in the tools' words, with the status words
14
- # `chi send --wait` and `chi answer` use (ParentReport.status), with a cursor per
15
- # child (the newest reply this parent was already given) and the child
16
- # as it was before the message went in (ReplyWait's baseline), so a turn
17
- # that ends before the wait's first look (a fast failure) still ends it.
18
- # Both live in this process (a worker runs one session), keyed by parent
19
- # and child; a worker respawn loses them, which only repeats the newest
20
- # reply once or waits for a later turn.
15
+ # `chi send --wait` and `chi answer` use (ParentReport.status), with a
16
+ # DelegateCursor per child: the newest reply this parent was already
17
+ # given, and the child as it was when the parent last heard from it or
18
+ # sent it a message (ReplyWait's baseline), so a turn that ends before
19
+ # the wait's first look (a fast failure) still ends it. The cursors are
20
+ # on disk (DelegateCursors), so a respawned worker doesn't repeat a reply.
21
21
  module DelegateWait
22
22
  TIMEOUT_DEFAULT = 600
23
23
  POLL_INTERVAL = 0.5
@@ -32,16 +32,6 @@ module Samagotchi
32
32
 
33
33
  module_function
34
34
 
35
- # @return [Hash{Array(String, String) => String}] [parent id, child id] → newest reply filename given
36
- def seen
37
- @seen ||= {}
38
- end
39
-
40
- # @return [Hash{Array(String, String) => Hash}] [parent id, child id] → ReplyWait baseline taken before sending
41
- def baselines
42
- @baselines ||= {}
43
- end
44
-
45
35
  # @return [Hash{Array(String, String) => Array<String>}] [parent id,
46
36
  # child id] → outcome lines of that child's approvals relayed while
47
37
  # the parent waited on another child, for its next result
@@ -69,18 +59,20 @@ module Samagotchi
69
59
  cancelled = -> { peers.cancelled? }
70
60
  relay = peers.respond_to?(:relay) ? peers.relay : nil
71
61
  others = relay && others_for(peers.session_id, child_id, relay, sd)
72
- baseline = baselines[key]
62
+ cursor = DelegateCursors.get(peers.session_id, child_id, state_dir: sd)
63
+ baseline = cursor.baseline
73
64
  left = timeout.to_i
74
65
  outcomes = relayed_outcomes.delete(key) || []
75
66
  loop do
76
67
  started = monotonic
77
- wait = ReplyWait.call(child_id, state_dir: sd, cursor: seen[key], timeout: [left, 0].max,
68
+ wait = ReplyWait.call(child_id, state_dir: sd, cursor: cursor.reply_file, timeout: [left, 0].max,
78
69
  poll_interval: poll_interval, cancelled: cancelled, baseline: baseline,
79
70
  owner_grace: owner_grace, interject: others && -> { others.poll })
80
71
  left -= monotonic - started
81
72
  unless wait.status == :waiting_for_answer && DelegateRelay.relayable?(relay, wait.question)
82
73
  outcomes.concat(relayed_outcomes.delete(key) || [])
83
- return with_outcomes(finish(wait, child_id, key: key, timeout: timeout), outcomes)
74
+ advance(peers.session_id, child_id, wait, state_dir: sd)
75
+ return with_outcomes(finish(wait, child_id, timeout: timeout), outcomes)
84
76
  end
85
77
 
86
78
  outcome = DelegateRelay.call(child_id, wait.question, relay: relay, state_dir: sd, more: others&.count.to_i)
@@ -118,24 +110,43 @@ module Samagotchi
118
110
 
119
111
  def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
120
112
 
113
+ REPORT_NEXT_REPLY = "chi brings the child's next reply here by itself; don't wait for it with delegate_result."
114
+
115
+ # Outcomes that tell the parent something about the child's state.
116
+ REPORTED = %i[done no_reply error stopped waiting_for_answer worker_gone].freeze
117
+
118
+ # Move the parent's cursor past what +wait+ reported: the reply it
119
+ # gave, and the child as that same look loaded it (a fresh load could
120
+ # swallow a turn that ended in between), so a later wait looks for a
121
+ # later turn and a question already reported isn't reported again.
122
+ # Nothing moves on a timeout or a cancel.
123
+ # @param wait [ReplyWait::Result]
124
+ # @return [DelegateCursor, nil] the new cursor
125
+ def advance(parent_id, child_id, wait, state_dir:)
126
+ return nil unless REPORTED.include?(wait.status) && wait.session
127
+
128
+ DelegateCursors.update(parent_id, child_id, state_dir: state_dir) do |cursor|
129
+ cursor = cursor.with(reply_file: wait.file) if wait.status == :done
130
+ cursor.with_baseline(ReplyWait.baseline_of(wait.session))
131
+ end
132
+ end
133
+
121
134
  # The tool result for how the wait ended.
122
135
  # @param wait [ReplyWait::Result]
123
- # @param key [Array(String, String)] [parent id, child id]
136
+ # @param report [Boolean] a delegate report's text (ChildReports), not
137
+ # the tool's: a question's last line says chi brings the next reply
124
138
  # @return [String]
125
- def finish(wait, child_id, key:, timeout:)
126
- # The turn sent to is handed over: a later wait looks for a later one.
127
- baselines.delete(key) if %i[done no_reply].include?(wait.status)
139
+ def finish(wait, child_id, timeout:, report: false)
128
140
  status = ParentReport.status(wait)
129
141
  case wait.status
130
142
  when :done
131
- seen[key] = wait.file
132
143
  reply_result(child_id, status, wait.text)
133
144
  when :error
134
145
  result(child_id, status, "the child's worker failed: #{wait.text}; its session shows what happened")
135
146
  when :stopped
136
147
  result(child_id, status, "the child was stopped (chi sessions stop); delegate with session: #{child_id} starts it again with a message")
137
148
  when :waiting_for_answer
138
- result(child_id, status, waiting_text(child_id, wait.question))
149
+ result(child_id, status, waiting_text(child_id, wait.question, report: report))
139
150
  when :canceled
140
151
  canceled_result(child_id)
141
152
  when :no_reply
@@ -153,14 +164,18 @@ module Samagotchi
153
164
  # child's baseline before the follow-up goes in.
154
165
  # @param child [Session] the child as loaded before sending
155
166
  def mark_seen(parent_id, child, state_dir:)
156
- seen[[parent_id, child.id]] = ReplyWait.newest_reply(child.id, state_dir: state_dir)
157
- baselines[[parent_id, child.id]] = ReplyWait.baseline_of(child)
167
+ newest = ReplyWait.newest_reply(child.id, state_dir: state_dir)
168
+ DelegateCursors.update(parent_id, child.id, state_dir: state_dir) do |cursor|
169
+ cursor.with(reply_file: newest).with_baseline(ReplyWait.baseline_of(child))
170
+ end
158
171
  end
159
172
 
160
173
  # A new child's baseline: as spawned, before its first turn ran.
161
174
  # @param child [Session] the session spawn_session returned
162
- def mark_started(parent_id, child)
163
- baselines[[parent_id, child.id]] = ReplyWait.baseline_of(child, question_id: nil)
175
+ def mark_started(parent_id, child, state_dir:)
176
+ DelegateCursors.update(parent_id, child.id, state_dir: state_dir) do |cursor|
177
+ cursor.with_baseline(ReplyWait.baseline_of(child, question_id: nil))
178
+ end
164
179
  end
165
180
 
166
181
  def reply_result(child_id, status, text)
@@ -188,13 +203,17 @@ module Samagotchi
188
203
  # decide (it isn't relayed: Continue grants no permission): continue
189
204
  # it with chi answer through execute, send a follow-up instead, or
190
205
  # report back.
191
- def waiting_text(child_id, pending)
206
+ # A report's last line differs (+report+): "waits again" made models
207
+ # call delegate_result right after answering Continue.
208
+ def waiting_text(child_id, pending, report: false)
192
209
  text = "Child #{child_id} is #{ParentReport.question_text(pending, session_id: child_id)}"
193
210
  if ParentReport.continue?(pending)
194
211
  text += "The child ran out of steps before it answered. Decide: continue it (run the chi answer command " \
195
212
  "above with execute) if it is getting somewhere; send it a narrower follow-up with delegate " \
196
213
  "session: #{child_id} (that drops the question); or stop it and report back to your user.\n"
197
214
  end
215
+ return "#{text}#{REPORT_NEXT_REPLY}" if report
216
+
198
217
  "#{text}delegate_result #{child_id} waits again once it is answered."
199
218
  end
200
219
 
@@ -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("Error: command stopped by the user after #{e.elapsed.round}s (killed; rerun it if still needed)", e)
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
@@ -154,7 +154,7 @@ module Samagotchi
154
154
 
155
155
  def get_record(task_id)
156
156
  record = load_record(task_id)
157
- return [nil, "Error: task not found: #{task_id}"] unless record
157
+ return [nil, not_found(task_id)] unless record
158
158
 
159
159
  [refresh_record(record), nil]
160
160
  rescue StandardError => e
@@ -180,7 +180,7 @@ module Samagotchi
180
180
  # @param by [String] "model" (task_stop) or "user" (the web's button)
181
181
  def stop_task(task_id, by:)
182
182
  record = load_record(task_id)
183
- return [nil, "Error: task not found: #{task_id}"] unless record
183
+ return [nil, not_found(task_id)] unless record
184
184
 
185
185
  refreshed = refresh_record(record)
186
186
  return [refreshed, nil] unless refreshed["status"] == "running"
@@ -223,7 +223,26 @@ module Samagotchi
223
223
  record
224
224
  end
225
225
 
226
+ # A session id (a uuid) is never a task's: task ids are a timestamp
227
+ # and hex (20261005093000-1a2b3c4d).
228
+ SESSION_ID_SHAPE = /\A\h{8}-\h{4}-\h{4}-\h{4}-\h{12}\z/
229
+
230
+ # A model mixing up ids (Qwen passes session ids to task_get and
231
+ # task_stop) is told which kind it passed and where task ids come from.
232
+ def not_found(task_id)
233
+ error = "Error: task not found: #{task_id}"
234
+ return error unless SESSION_ID_SHAPE.match?(task_id.to_s)
235
+
236
+ "#{error} (that is a session id; task ids come from task_create, and task_list lists them)"
237
+ end
238
+
239
+ # Exactly the shape generate_task_id makes. Anything else (../x, an
240
+ # absolute path) is no task, so a model's id never leaves tmp/tasks.
241
+ TASK_ID_SHAPE = /\A\d{14}-\h{8}\z/
242
+
226
243
  def load_record(task_id)
244
+ return nil unless TASK_ID_SHAPE.match?(task_id.to_s)
245
+
227
246
  path = metadata_path(task_id)
228
247
  return nil unless File.exist?(path)
229
248
 
@@ -34,7 +34,7 @@ module Samagotchi
34
34
 
35
35
  loop do
36
36
  record, error = TaskRuntime.get_record(task_id)
37
- return "Error: #{error}" if error
37
+ return error if error
38
38
 
39
39
  status = record.fetch("status")
40
40
  return finished_response(record, tail_lines) unless status == "running"
@@ -64,7 +64,7 @@ module Samagotchi
64
64
  # normal finished result. Only a task still running says so.
65
65
  def self.cancelled_response(task_id, tail_lines, started)
66
66
  record, error = TaskRuntime.get_record(task_id)
67
- return "Error: #{error}" if error
67
+ return error if error
68
68
  return finished_response(record, tail_lines) unless record.fetch("status") == "running"
69
69
 
70
70
  note = [
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.29.0"
4
+ VERSION = "0.31.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
@@ -1522,8 +1522,14 @@ module Samagotchi
1522
1522
 
1523
1523
  message = { role: norm_role, content: stripped }
1524
1524
  # Merged into the running turn (Steer::INPUT_KIND): part of it,
1525
- # read by step +step+.
1526
- message.merge!(merged: true, step: steer_step(list, index)) if norm_role == "user" && Samagotchi::Steer.input?(m)
1525
+ # read by step +step+; a wake turn's report starts its turn
1526
+ # (Steer.turn_prompt?). Its sender (a delegate report, chi send):
1527
+ # the page's label.
1528
+ if norm_role == "user" && Samagotchi::Steer.input?(m)
1529
+ message.merge!(merged: true, step: steer_step(list, index)) unless Samagotchi::Steer.turn_prompt?(m)
1530
+ source = (m[:source] || m["source"]).to_s
1531
+ message[:source] = source unless source.empty?
1532
+ end
1527
1533
  # The turn it started (Engine): the page pairs it with that turn's
1528
1534
  # record by this, not by its place.
1529
1535
  turn_id = m[:turn_id] || m["turn_id"]
@@ -10,7 +10,8 @@ export const ROW_STATUS = Object.freeze({
10
10
  RUNNING: "running",
11
11
  OK: "ok",
12
12
  ERROR: "error",
13
- // A task_wait the user's Stop ended (the task itself runs on).
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,10 +43,10 @@ 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
- 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";
49
+ import { clientLabel, commandView, isReportClient, 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";
50
50
 
51
51
  const $ = (s) => document.querySelector(s);
52
52
  const topStripEl = $("#topStrip");
@@ -142,8 +142,11 @@ let selectSeq = null;
142
142
  // new turn has started on it yet: see followWokenWorker.
143
143
  let streamFromGuess = false;
144
144
  // The last input_merged had an origin with no bubble: its merged text
145
- // (pending_input_merged) gets one.
145
+ // (pending_input_merged) gets one ("report": a delegate report's).
146
146
  let unmatchedMerge = false;
147
+ // The running turn is one chi ran for delegate reports and its report
148
+ // bubble isn't drawn yet: that bubble starts the turn.
149
+ let wakeStart = false;
147
150
  // The enqueued_ids this page sent (from the /turn acks): only these come back
148
151
  // into the composer when their turn fails.
149
152
  const sentIds = new Set();
@@ -710,6 +713,7 @@ function resetTurnView() {
710
713
  questionCards.removeQuestion();
711
714
  questionCards.forgetContinue();
712
715
  unmatchedMerge = false;
716
+ wakeStart = false;
713
717
  setTurnRunning(false);
714
718
  releaseDisplayWait();
715
719
  }
@@ -960,9 +964,11 @@ function appendChunk(text) {
960
964
  if (follow) historyEl.scrollTop = historyEl.scrollHeight;
961
965
  }
962
966
 
963
- function addEndBubble(text) {
967
+ // +stopped+: a stop someone chose (a cancel, the session's stop), drawn in
968
+ // the stopped style (timing.js STOPPED_LINE_CLASS); else a failure's line.
969
+ function addEndBubble(text, { stopped = false } = {}) {
964
970
  const div = document.createElement("div");
965
- div.className = "bubble cancel";
971
+ div.className = stopped ? STOPPED_LINE_CLASS : "bubble cancel";
966
972
  div.textContent = text;
967
973
  appendToHistory(div);
968
974
  if (!turnDom.holds?.()) historyEl.scrollTop = historyEl.scrollHeight;
@@ -980,7 +986,7 @@ function emptyAnswerEl(emptyAnswer) {
980
986
  // Everything a turn's end (completed, canceled, failed) closes: then its
981
987
  // end line (+endText+, the cancel or failed line), then the step cards that
982
988
  // left the collapsed block (card.js leavesBlock), where a reload puts them.
983
- function endTurnView(kind, endText = null, { emptyAnswer = null } = {}) {
989
+ function endTurnView(kind, endText = null, { emptyAnswer = null, stopped = false } = {}) {
984
990
  setTurnRunning(false);
985
991
  // Every ended turn has a record (completed, canceled, failed); a worker
986
992
  // gone mid-turn is re-rendered (resync), which counts again.
@@ -991,7 +997,7 @@ function endTurnView(kind, endText = null, { emptyAnswer = null } = {}) {
991
997
  const kept = turnDom.turnEnded({ kind, endNote, emptyAnswer: !!emptyAnswer }) || [];
992
998
  if (endNote && !endNote.isConnected) appendToHistory(endNote);
993
999
  finishLiveTurnTiming(null, { canceled: kind === "canceled" });
994
- if (endText) addEndBubble(endText);
1000
+ if (endText) addEndBubble(endText, { stopped });
995
1001
  if (kept.length) {
996
1002
  kept.forEach((el) => appendToHistory(el));
997
1003
  if (!turnDom.holds?.()) historyEl.scrollTop = historyEl.scrollHeight;
@@ -1065,14 +1071,18 @@ function untaggedOwnBubble(prompt) {
1065
1071
  // Returns the bubble of the turn a turn_started starts (the stage takes it
1066
1072
  // into its prompt line), else null.
1067
1073
  function applyPromptOps(event) {
1068
- const ops = promptOps(event, { myId: clientId, known: (id) => !!bubbleById(id), unmatchedMerge });
1074
+ if (event.type === "turn_started") wakeStart = !!event.continue && isReportClient(event.origin?.client_id);
1075
+ const ops = promptOps(event, { myId: clientId, known: (id) => !!bubbleById(id), unmatchedMerge, wakeStart });
1069
1076
  if (event.type === "input_merged") unmatchedMerge = false;
1070
1077
  if (event.type === "pending_input_merged") unmatchedMerge = false;
1071
1078
  let started = null;
1072
1079
  for (const op of ops) {
1073
- if (op.op === "add") {
1080
+ if (op.op === "report") {
1081
+ unmatchedMerge = "report";
1082
+ } else if (op.op === "add") {
1074
1083
  const bubble = makeUserBubble(op.prompt, { state: op.state, label: op.label, enqueuedId: op.enqueuedId, images: op.images });
1075
1084
  if (op.state === "started") started = bubble;
1085
+ if (event.type === "pending_input_merged") wakeStart = false;
1076
1086
  } else if (op.op === "tag") {
1077
1087
  if (bubbleById(op.enqueuedId)) continue;
1078
1088
  const echo = untaggedOwnBubble(op.prompt);
@@ -1653,7 +1663,7 @@ function workerGone(id, { opened = true } = {}) {
1653
1663
  const stopped = stoppingId === id;
1654
1664
  const endText = workerGoneText({ turnRunning, stopped });
1655
1665
  if (endText) {
1656
- endTurnView("gone", endText);
1666
+ endTurnView("gone", endText, { stopped });
1657
1667
  if (!stopped) setLiveStatus("idle");
1658
1668
  }
1659
1669
  if (current.owner === "worker") current.owner = null;
@@ -1770,7 +1780,7 @@ const streamHandlers = {
1770
1780
  }
1771
1781
  },
1772
1782
  turn_canceled: (data) => {
1773
- endTurnView("canceled", cancelLineText(data.cancellation_reason || "", data.cancelled_by));
1783
+ endTurnView("canceled", cancelLineText(data.cancellation_reason || "", data.cancelled_by), { stopped: true });
1774
1784
  setLiveStatus("idle");
1775
1785
  refreshTimingFromSession();
1776
1786
  },
@@ -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
- const heredoc = step.heredoc
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
  }
@@ -26,12 +26,19 @@ export function noteHtml(note) {
26
26
  // Who a steer names as its sender: a plugin's own label, chi's sender ids
27
27
  // in words (the TUI's Formatting#steer_sender agrees;
28
28
  // spec/shared/labels_matrix.json). "": no one named.
29
- const STEER_SENDERS = { parent_agent: "parent agent", chi_send: "chi send", plugin_send: "plugin" };
29
+ const STEER_SENDERS = { parent_agent: "parent agent", chi_send: "chi send", plugin_send: "plugin", delegate_report: "delegate report" };
30
30
  export function steerSender(source) {
31
31
  const id = String(source ?? "");
32
32
  return STEER_SENDERS[id] || id;
33
33
  }
34
34
 
35
+ // The label a user-role line gets on reload for who sent it, by its saved
36
+ // source: a delegate report (the live bubble's clientLabel of child:<id>);
37
+ // null for the user's own (and anything else, as before).
38
+ export function sourceLabel(source) {
39
+ return source === "delegate_report" ? steerSender(source) : null;
40
+ }
41
+
35
42
  // A plugin's steer (a nudge put into the running turn): one line that
36
43
  // names who nudged the model, its text collapsed under it. +bubble+: on its
37
44
  // own between bubbles (no step holds it), not a row of a step.
@@ -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
- .ts-tick.exec{background:var(--think);opacity:.85}
621
- .ts-tick.edit{background:var(--ok);opacity:.85}
622
- .ts-tick.other{background:var(--accent);opacity:.7}
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}
@@ -12,7 +12,9 @@ export function notifyState({ hasNotification, secureContext, permission, wanted
12
12
  return wanted && permission === "granted" ? "on" : "off";
13
13
  }
14
14
 
15
- // A finished turn shorter than this was watched: no "done".
15
+ // A finished turn shorter than this was watched: no "done". Not a turn
16
+ // chi ran for a delegate child's report (last_turn origin delegate_report):
17
+ // nobody started it, so nobody watched it.
16
18
  export const DONE_MIN_SECONDS = 10;
17
19
 
18
20
  // The step-limit question (kind "continue": a turn ran out of iterations
@@ -66,7 +68,7 @@ export function attentionFor(prev, next) {
66
68
  if (next.parent_id) return null;
67
69
  const key = `${next.id}:${turn.ended_at}`;
68
70
  if (turn.outcome === "failed") return { reason: "failed", sessionId: next.id, key };
69
- if (turn.outcome === "completed" && Number(turn.seconds) >= DONE_MIN_SECONDS) {
71
+ if (turn.outcome === "completed" && (Number(turn.seconds) >= DONE_MIN_SECONDS || turn.origin === "delegate_report")) {
70
72
  return { reason: "done", sessionId: next.id, key };
71
73
  }
72
74
  return null;
@@ -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
- stepEl.textContent = endKind ? `${slots.step} step${slots.step === 1 ? "" : "s"}` : `step ${slots.step}`;
192
+ const steps = `${slots.step} step${slots.step === 1 ? "" : "s"}`;
193
193
 
194
194
  drawHeadline(slots.headline);
195
- drawNow(slots.tool, gen);
195
+ drawNow(slots.tool, slots.quiet);
196
196
  drawTrail(slots.trail);
197
197
  drawTicks(slots.ticks);
198
198
  const calls = slots.ticks.length;
199
- const elapsed = endKind ? formatDuration(endedAt - startedAt) : "";
200
- chipLabel.textContent = `${slots.step} step${slots.step === 1 ? "" : "s"} · ${calls} tool call${calls === 1 ? "" : "s"}${elapsed ? ` · ${elapsed}` : ""}`;
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, gen) {
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 failed = item.status === "error";
266
- const el = node("span", `ts-trail-item${failed ? " error" : ""}`, trail);
267
- el.textContent = `${failed ? "✕" : "✓"} ${item.name} ${item.title}`.trim();
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";