samagotchi 0.26.0 → 0.27.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d9914119c74e2e2233fcd5931386afca4fbc95b84aa7603e2f4f14289651a12c
4
- data.tar.gz: 5ba92c2703f202dffe2d4125eac11fd9d93c970a032cc56af1ce41c0827ab5ba
3
+ metadata.gz: b91f830918a3f58dac1d54820450ec90b4caa2d8f970b3fab03c8a0199b3ac2e
4
+ data.tar.gz: 1d1f0f1dc839247801be1462a895e8a57e189a68882fbaeb5a7707fdb4add0ab
5
5
  SHA512:
6
- metadata.gz: 881df5c769cb4d5ca994253bf7cccfbbd29d033f51e9f73533cf74926d8d6fe155c8e0e92a7a6226b770dceb4e4a816b1d78dd0ff0b1502938a6e8761a26c8d3
7
- data.tar.gz: 0a6c687f219568001c688d385221b239a2f285c66f17895c2202bf77098c40236a42cd3ca6b824b493260acb698bf6345e715ecaaa8e53ce6368e938c0bb155f
6
+ metadata.gz: 398faa2152be479cf78093fb9c752158932a2c186bf1fa5fd1ee9a622895bf742f82f1a25c843febfb877c826cee869623ee9871c50154015fdb54d02a490054
7
+ data.tar.gz: 35a4b45af554dd9cfd79c87d67ed7d819d14759421f8f669ac769ddbb1b37a64f5b0c79101f15a002ebdb4a2e2e9bee1def9eead820d50f61312c9f4a8cc41c8
data/CHANGELOG.md CHANGED
@@ -8,6 +8,25 @@ and commands may change between minor versions. How releases are made:
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [0.27.0] - 2026-10-04
12
+
13
+ ### Added
14
+
15
+ - Web: a "stop task" button on a running `task_wait` stops the background task; the turn goes on and the model is
16
+ told the user stopped it (Cancel still cancels the turn and leaves the task running).
17
+
18
+ ### Changed
19
+
20
+ - `/mcp` lists each server's tools alphabetically, not in the order the server sent them (mcp bundle 0.4.3).
21
+
22
+ ### Fixed
23
+
24
+ - A stopped background task ends as `stopped`, not `failed`, and records who stopped it (the user or the model).
25
+ - Code blocks in the web follow the light theme too: their highlighting comes from the page's palette instead of one
26
+ fixed dark theme.
27
+
28
+ Update with `chi update` (mcp 0.4.3).
29
+
11
30
  ## [0.26.0] - 2026-10-04
12
31
 
13
32
  ### Added
@@ -1419,7 +1438,8 @@ and long-lived sessions.
1419
1438
  - A macOS desktop helper (`chi desktop install`): a "Send to chi" Service and a
1420
1439
  hotkey panel that send selected text or the clipboard to your sessions.
1421
1440
 
1422
- [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.26.0...HEAD
1441
+ [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.27.0...HEAD
1442
+ [0.27.0]: https://github.com/dm1try/samagotchi/compare/v0.26.0...v0.27.0
1423
1443
  [0.26.0]: https://github.com/dm1try/samagotchi/compare/v0.25.0...v0.26.0
1424
1444
  [0.25.0]: https://github.com/dm1try/samagotchi/compare/v0.24.0...v0.25.0
1425
1445
  [0.24.0]: https://github.com/dm1try/samagotchi/compare/v0.23.0...v0.24.0
data/docs/architecture.md CHANGED
@@ -246,6 +246,12 @@ process that already owns the `Engine`); every worker starts it, and it exposes:
246
246
  `guardrails.parent_approvals` is `403 parent_approval_refused`, the question still open.
247
247
  - `POST /session/:id/question/dismiss` — leave the question unanswered (an approval: denied);
248
248
  `200`, or `409` when it is no longer pending.
249
+ - `POST /session/:id/tasks/stop` — `{task_id}`: stop a background task as the user
250
+ (`TaskRuntime.stop_task by: "user"`, the web's stop-task button on a running `task_wait`;
251
+ web route `POST /api/sessions/:id/tasks/:task_id/stop`). Only a task this conversation's
252
+ `task_create` started (the saved messages, or the running turn's accumulator); answers once the
253
+ task is gone: `200 {status, stop_reason, task_id}`, `404 task_not_found`, `409 not_running`.
254
+ The worker names it in its sidecar features (`task_stop`).
249
255
  - `POST /session/:id/relay` — the approval relay, on a delegated child's worker
250
256
  (`{action, relay_id, question_id}`): `opened` / `closed` (with a `reason`) set or clear the
251
257
  pending question's `relayed_to` mark (`QuestionDesk#annotate`, `:question_relay`); `answered`
data/docs/hooks.md CHANGED
@@ -56,7 +56,7 @@ The plugin class must respond to `#call(event)` — duck-typed, no base class re
56
56
  | `:after_generation` | After LLM returns (both loops); not after a generation a hook cut (`stop_generation`) | `{ type: :after_generation, iteration: N, response: "...", messages: [...] (the conversation as sent) }` |
57
57
  | `:generation_progress` | While the response streams, in batches (see [Watching the stream](#watching-the-stream)) | `{ type: :generation_progress, iteration: N, thinking: "..." (new since the last fire), text: "..." (new visible text), thinking_chars: N, text_chars: N (this generation so far), elapsed_ms: N }` |
58
58
  | `:before_tool_call` | Before tool dispatch (and before `tool_call_started`) | `{ type: :before_tool_call, iteration: N, call: {...}, params: "...", guardrail: Verdict, context: {...}, targets: {...}, blocked: false, block_reason: nil }` |
59
- | `:after_tool_call` | After tool execution | `{ type: :after_tool_call, iteration: N, tool: "read", output: "..." (capped at max_tool_output_chars), status: "ok" \| "error" \| "blocked" \| "stopped" }` |
59
+ | `:after_tool_call` | After tool execution | `{ type: :after_tool_call, iteration: N, tool: "read", output: "..." (capped at max_tool_output_chars), status: "ok" \| "error" \| "blocked" \| "stopped" }`; `stopped` is a `task_wait` the turn's Stop ended (the task runs on) or whose task was stopped |
60
60
  | `:session_end` | After each turn `:after_turn` fires for, after it (turn-level lifecycle) | `{ type: :session_end, session_id: "..." }` |
61
61
 
62
62
  Every event also carries the hook runtime (next section): `hook:` (the label
@@ -27,6 +27,8 @@ require_relative "version"
27
27
  require_relative "worker_sidecar"
28
28
  require_relative "relay_verifier"
29
29
  require_relative "relay_watcher"
30
+ require_relative "tools/task_create"
31
+ require_relative "tools/task_runtime"
30
32
 
31
33
  module Samagotchi
32
34
  # Bridge is an optional HTTP transport that lets an external web / desktop
@@ -461,7 +463,8 @@ module Samagotchi
461
463
  # sidecar (WorkerSidecar#features): a client checks a name here rather
462
464
  # than a version.
463
465
  # restart: POST /exit takes "restart": true (WorkerIdleExit#hold_for_restart).
464
- FEATURES = ["restart"].freeze
466
+ # task_stop: POST tasks/stop (#handle_task_stop), the web's stop-task button.
467
+ FEATURES = %w[restart task_stop].freeze
465
468
 
466
469
  # The routes, by method and what follows /session/:id/: a handler takes
467
470
  # the session id and the request body and returns [headers, status, body].
@@ -477,6 +480,7 @@ module Samagotchi
477
480
  %w[POST recap] => :handle_recap,
478
481
  %w[POST relay] => :handle_relay,
479
482
  %w[POST relay/status] => :handle_relay_status,
483
+ %w[POST tasks/stop] => :handle_task_stop,
480
484
  %w[GET state] => :handle_state,
481
485
  %w[GET stats] => :handle_stats,
482
486
  %w[GET snapshot] => :handle_snapshot,
@@ -539,6 +543,38 @@ module Samagotchi
539
543
  end
540
544
  end
541
545
 
546
+ # POST /session/:id/tasks/stop {task_id}: stop a background task as the
547
+ # user (TaskRuntime.stop_task by: "user"); a task_wait on it returns, and
548
+ # the turn goes on. Only a task this conversation created: records in
549
+ # tmp/tasks are shared by every session in the cwd. Those are in the
550
+ # saved conversation, or, created in the running turn, only in the
551
+ # accumulator's task_create parts. 200 {status, stop_reason, task_id},
552
+ # 404 task_not_found, 409 not_running (it already ended), 400 no id.
553
+ # Answers once the task is gone: up to STOP_GRACE_SEC.
554
+ def handle_task_stop(_session_id, body)
555
+ parsed = parse_json(body)
556
+ task_id = (fetched(parsed, "task_id") if parsed.is_a?(Hash)).to_s.strip
557
+ return [{ "Allow" => "POST" }, 400, { error: "missing_fields", detail: "task_id required" }] if task_id.empty?
558
+ return [{}, 404, { error: "task_not_found", task_id: task_id }] unless own_task_ids.include?(task_id)
559
+
560
+ record, error = Tools::TaskRuntime.get_record(task_id)
561
+ return [{}, 404, { error: "task_not_found", task_id: task_id }] if error
562
+ return [{}, 409, { error: "not_running", status: record["status"], task_id: task_id }] unless record["status"] == "running"
563
+
564
+ Log.info(:bridge, "task_stop", task_id: task_id)
565
+ record, error = Tools::TaskRuntime.stop_task(task_id, by: "user")
566
+ return [{}, 500, { error: "bridge_error", detail: error }] if error
567
+
568
+ [{}, 200, { status: record["status"], stop_reason: record["stop_reason"], task_id: task_id }]
569
+ end
570
+
571
+ def own_task_ids
572
+ running = Array(@accumulator.current_turn&.dig(:parts)).filter_map do |part|
573
+ part[:output].to_s[/\A(?:\[task_create\]\n)?task_id: (\S+)/, 1] if part[:kind] == "tool" && part[:tool] == Tools::TaskCreate::NAME
574
+ end
575
+ Tools::TaskRuntime.created_ids_in(@engine.messages_checkpoint) | running
576
+ end
577
+
542
578
  # Answer the pending question. A past +deadline+ (see #handle_post_turn):
543
579
  # 408 deadline_passed, and the question stays open. An answer takes no
544
580
  # event hold, so it is checked right before it is recorded.
@@ -187,6 +187,15 @@ module Samagotchi
187
187
  post("cancel", { reason: reason }, read_body: true)
188
188
  end
189
189
 
190
+ # POST /session/:id/tasks/stop: stop a background task this session's
191
+ # conversation created, as the user (Bridge#handle_task_stop). 200 with
192
+ # its final status, 404 task_not_found, 409 not_running. Synchronous:
193
+ # the worker answers once the task is gone (up to its 3 s grace).
194
+ # @return [Response]
195
+ def stop_task(task_id)
196
+ post("tasks/stop", { task_id: task_id }, read_body: true)
197
+ end
198
+
190
199
  # How long the approval relay's calls wait for a reply: a parent's Stop
191
200
  # isn't held for READ_TIMEOUT by a child that doesn't answer.
192
201
  RELAY_TIMEOUT = 5
@@ -1,11 +1,11 @@
1
1
  ---
2
2
  name: mcp
3
- version: 0.4.2
3
+ version: 0.4.3
4
4
  scope: system
5
5
  description: "Tools from MCP servers (stdio): each server in config.yml bundles: mcp: servers: runs for the session, its tools are mcp_<server>_<tool>; /mcp lists them"
6
6
  trust_level: reviewed
7
7
  files: {}
8
8
  plugin:
9
9
  file: plugin.rb
10
- sha256: sha256:d0491b038e7f111f9f8c629729a2b0525d573fd0b17d48ea8ec59c4a2b8b821b
10
+ sha256: sha256:079a42892f10822115235e1ced7ccd481f80c9ef54abb094f9a1c599bb03e8e2
11
11
  requires_chi: ">= 0.8.0"
@@ -752,7 +752,8 @@ class Plugin
752
752
 
753
753
  @servers.map do |server|
754
754
  head = "**#{server.name}**: #{state_text(server)}"
755
- tools = server.tools.map { |tool| tool["chi_name"] }.compact
755
+ # Sorted: a server lists its tools in its own (often grouped) order.
756
+ tools = server.tools.map { |tool| tool["chi_name"] }.compact.sort
756
757
  tools.empty? ? head : "#{head}\n#{tools.map { |t| "- `#{t}`" }.join("\n")}"
757
758
  end.join("\n\n")
758
759
  end
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: samagotchi-system
3
- version: 0.26.0
3
+ version: 0.27.0
4
4
  scope: system
5
5
  description: Default system memories — identity, self map, config modification protocol, memory guide and the delegated-session rules
6
6
  files:
@@ -23,6 +23,8 @@ module Samagotchi
23
23
  # (once, until Config.reload!). While that runs (Config can warn itself)
24
24
  # records only echo. Nothing here raises into the caller: a record that
25
25
  # can't be formatted is dropped, an unwritable file pauses (DebugLog).
26
+ # The one exception is strict mode (SAMAGOTCHI_LOG_STRICT=1, the spec
27
+ # suite): an unknown tag or a malformed event raises, whatever the level.
26
28
  module Log
27
29
  LEVELS = { debug: 0, info: 1, warn: 2, error: 3 }.freeze
28
30
  DEFAULT_LEVEL = :info
@@ -39,6 +41,7 @@ module Samagotchi
39
41
  @state = nil
40
42
  @resolving = false
41
43
  @session_id = nil
44
+ @strict = ENV["SAMAGOTCHI_LOG_STRICT"] == "1"
42
45
 
43
46
  class << self
44
47
  # @param path [String, nil, :auto] the file; :auto → LogPath.resolve
@@ -72,6 +75,9 @@ module Samagotchi
72
75
  # carry its first SID_LENGTH chars unless they pass their own sid:.
73
76
  attr_accessor :session_id
74
77
 
78
+ # Raise on an unknown tag or a bad event instead of dropping the record.
79
+ attr_accessor :strict
80
+
75
81
  def debug(tag, event, **kw) = log(:debug, tag, event, **kw)
76
82
  def info(tag, event, **kw) = log(:info, tag, event, **kw)
77
83
  def warn(tag, event, **kw) = log(:warn, tag, event, **kw)
@@ -96,7 +102,19 @@ module Samagotchi
96
102
  resolved_state&.dig(:writer)&.path
97
103
  end
98
104
 
99
- def log(level, tag, event, payload: nil, sid: nil, echo: nil, **fields)
105
+ def log(level, tag, event, **kw)
106
+ check_names!(tag, event) if strict
107
+ write_record(level, tag, event, **kw)
108
+ end
109
+
110
+ private
111
+
112
+ def check_names!(tag, event)
113
+ raise ArgumentError, "unknown log tag #{tag}" unless LogLine::TAGS.include?(tag.to_s)
114
+ raise ArgumentError, "bad log event #{event}" unless event.to_s.match?(LogLine::SLUG)
115
+ end
116
+
117
+ def write_record(level, tag, event, payload: nil, sid: nil, echo: nil, **fields)
100
118
  options = @options
101
119
  echo_to_stderr(echo) if echo && options.fetch(:stderr, true)
102
120
  state = resolved_state
@@ -113,8 +131,6 @@ module Samagotchi
113
131
  nil
114
132
  end
115
133
 
116
- private
117
-
118
134
  def build(level, tag, event, payload:, sid:, fields:)
119
135
  tag = tag.to_s
120
136
  event = event.to_s
@@ -23,7 +23,7 @@ module Samagotchi
23
23
  class ThinkingTails
24
24
  FILE = "thinking_tails.jsonl"
25
25
  # The last chars of a generation's thinking kept: a loop's cycle and
26
- # some of its lead-in.
26
+ # some of its lead-in. TurnNote cuts an empty step's thinking to it too.
27
27
  TAIL_CHARS = 20_000
28
28
  MAX_RECORDS = 10
29
29
 
@@ -150,12 +150,13 @@ module Samagotchi
150
150
  # "error" for an "Error:" result, and for an execute whose command
151
151
  # exited non-zero (its last "exit: N" line; no number: killed by a
152
152
  # signal), so the turn tally's "(N failed)" counts it. "stopped" for a
153
- # task_wait the user's Stop ended: the task itself runs on.
153
+ # task_wait the user's Stop ended (the task itself runs on), and for one
154
+ # whose task was stopped (the stop-task button, or the model's task_stop).
154
155
  def tool_activity_status(result, tool_name = nil)
155
156
  text = result.to_s
156
157
  return "error" if text.start_with?("Error:")
157
158
  return "error" if tool_name == Tools::Execute::NAME && execute_failed?(text)
158
- return "stopped" if tool_name == Tools::TaskWait::NAME && text.match?(/^wait_result: canceled$/)
159
+ return "stopped" if tool_name == Tools::TaskWait::NAME && text.match?(/^(wait_result: canceled|status: stopped)$/)
159
160
 
160
161
  "ok"
161
162
  end
@@ -3,6 +3,7 @@
3
3
  require_relative "command_steps"
4
4
  require_relative "tools/execute"
5
5
  require_relative "tools/task_create"
6
+ require_relative "tools/task_wait"
6
7
 
7
8
  module Samagotchi
8
9
  # What a richer UI shows of a tool call beyond its one-line params: the
@@ -18,8 +19,9 @@ module Samagotchi
18
19
  # can't (the fallback: the UI shows the command itself) or the command
19
20
  # is cut. +description+: what the model said the command does (execute's
20
21
  # optional description), whitespace collapsed, a trailing period gone;
21
- # nil when it gave none.
22
- ToolView = Data.define(:command, :cwd, :truncated, :chars, :cd, :steps, :description) do
22
+ # nil when it gave none. +task_id+: the task a task_wait waits on (its
23
+ # only field), for the web's stop-task button.
24
+ ToolView = Data.define(:command, :cwd, :truncated, :chars, :cd, :steps, :description, :task_id) do
23
25
  def to_h = super.merge(steps: steps&.map(&:to_h)).compact.reject { |_key, value| value == false }
24
26
  end
25
27
 
@@ -37,6 +39,7 @@ module Samagotchi
37
39
  # @return [ToolView, nil] nil for a tool without a view and for an
38
40
  # empty command
39
41
  def self.for(tool_name, call)
42
+ return task_wait(call) if tool_name == Tools::TaskWait::NAME && call.is_a?(Hash)
40
43
  return nil unless TOOLS.include?(tool_name) && call.is_a?(Hash)
41
44
 
42
45
  command = call[:content].to_s
@@ -47,9 +50,17 @@ module Samagotchi
47
50
  parsed = truncated ? nil : CommandSteps.parse(command)
48
51
  new(command: truncated ? command[0, COMMAND_LIMIT] : command, cwd: cwd.empty? ? nil : cwd,
49
52
  truncated: truncated, chars: truncated ? command.length : nil, cd: parsed&.cd, steps: parsed&.steps,
50
- description: description(call))
53
+ description: description(call), task_id: nil)
51
54
  end
52
55
 
56
+ def self.task_wait(call)
57
+ task_id = call[:content].to_s.strip
58
+ return nil if task_id.empty?
59
+
60
+ new(command: nil, cwd: nil, truncated: false, chars: nil, cd: nil, steps: nil, description: nil, task_id: task_id)
61
+ end
62
+ private_class_method :task_wait
63
+
53
64
  # The call's description as a view shows it, nil for none.
54
65
  def self.description(call)
55
66
  text = call[:description]
@@ -30,8 +30,9 @@ module Samagotchi
30
30
  "finished_at: #{record["finished_at"]}",
31
31
  "exit_code: #{record["exit_code"]}",
32
32
  "stop_reason: #{record["stop_reason"]}",
33
- "output_path: #{record.fetch("output_path")}"
34
- ].join("\n")
33
+ "output_path: #{record.fetch("output_path")}",
34
+ (TaskRuntime::USER_STOP_NOTE if TaskRuntime.stopped_by_user?(record))
35
+ ].compact.join("\n")
35
36
  end
36
37
  private_class_method :format_record
37
38
  end
@@ -22,6 +22,9 @@ module Samagotchi
22
22
  SANITIZED_ENV_KEYS = %w[RUBYOPT RUBYLIB BUNDLE_GEMFILE BUNDLE_BIN_PATH BUNDLER_VERSION].freeze
23
23
  # Set by chi (Builtins.parent_env); the model's env can't change it.
24
24
  MARKER_ENV_KEYS = %w[SAMAGOTCHI_PARENT_SESSION SAMAGOTCHI_SESSION_MODEL].freeze
25
+ # task_wait and task_get add it to a task the user stopped (the web's
26
+ # stop-task button), so the model doesn't rerun the job.
27
+ USER_STOP_NOTE = "note: the user stopped this task; don't restart it unless they ask."
25
28
 
26
29
  module_function
27
30
 
@@ -107,19 +110,27 @@ module Samagotchi
107
110
  # A native tool_response joins its calls' results with "---".
108
111
  # @return [Array<Hash>] {id:, command:}, oldest first
109
112
  def running_created_in(messages)
110
- ids = Array(messages).flat_map do |message|
113
+ created_ids_in(messages).filter_map do |id|
114
+ record, _error = get_record(id)
115
+ { id: id, command: record["command"].to_s } if record&.fetch("status") == "running"
116
+ end
117
+ end
118
+
119
+ # The ids of every task this conversation's task_create calls started,
120
+ # whatever their status now, oldest first.
121
+ # @return [Array<String>]
122
+ def created_ids_in(messages)
123
+ Array(messages).flat_map do |message|
111
124
  next [] unless (message[:role] || message["role"]).to_s == "tool_response"
112
125
 
113
126
  (message[:content] || message["content"]).to_s.split("\n\n---\n\n").filter_map do |block|
114
127
  block[/\A\[task_create\]\ntask_id: (\S+)/, 1]
115
128
  end
116
- end
117
- ids.uniq.filter_map do |id|
118
- record, _error = get_record(id)
119
- { id: id, command: record["command"].to_s } if record&.fetch("status") == "running"
120
- end
129
+ end.uniq
121
130
  end
122
131
 
132
+ def stopped_by_user?(record) = record["stop_reason"] == "stopped_by_user"
133
+
123
134
  def get_record(task_id)
124
135
  record = load_record(task_id)
125
136
  return [nil, "Error: task not found: #{task_id}"] unless record
@@ -142,13 +153,19 @@ module Samagotchi
142
153
  content.lines.last(line_count).join
143
154
  end
144
155
 
145
- def stop_task(task_id)
156
+ # A requested stop ends as "stopped" whatever the exit code: the flag
157
+ # is on disk before the TERM, so a reader refreshing the record while
158
+ # the group dies records the stop, not a failure.
159
+ # @param by [String] "model" (task_stop) or "user" (the web's button)
160
+ def stop_task(task_id, by:)
146
161
  record = load_record(task_id)
147
162
  return [nil, "Error: task not found: #{task_id}"] unless record
148
163
 
149
164
  refreshed = refresh_record(record)
150
165
  return [refreshed, nil] unless refreshed["status"] == "running"
151
166
 
167
+ refreshed["stop_requested_by"] = by.to_s
168
+ write_record(refreshed)
152
169
  pid = refreshed["pid"].to_i
153
170
 
154
171
  begin
@@ -168,10 +185,7 @@ module Samagotchi
168
185
  end
169
186
  end
170
187
 
171
- updated = refresh_record(refreshed)
172
- updated["status"] = "stopped" if updated["status"] == "running"
173
- updated["finished_at"] ||= timestamp
174
- updated["stop_reason"] = "stopped_by_user"
188
+ updated = mark_stopped(refresh_record(refreshed))
175
189
  write_record(updated)
176
190
 
177
191
  [updated, nil]
@@ -294,9 +308,15 @@ module Samagotchi
294
308
  end
295
309
 
296
310
  def mark_finished_without_exit_code(record)
311
+ record = current_record(record)
312
+ return record unless record["status"] == "running"
313
+
297
314
  exit_code = read_exit_code(record["exit_code_path"])
298
315
 
299
- if exit_code.nil?
316
+ if record["stop_requested_by"]
317
+ record["exit_code"] = exit_code
318
+ mark_stopped(record)
319
+ elsif exit_code.nil?
300
320
  record["status"] = "failed"
301
321
  record["exit_code"] = nil
302
322
  record["stop_reason"] ||= "process_ended_without_exit_code"
@@ -313,6 +333,23 @@ module Samagotchi
313
333
  record
314
334
  end
315
335
 
336
+ # The disk copy when it is newer than +record+: already finished, or
337
+ # flagged by a stop that started after +record+ was read.
338
+ def current_record(record)
339
+ disk = load_record(record.fetch("id"))
340
+ return record unless disk
341
+ return disk if disk["status"] != "running" || disk["stop_requested_by"]
342
+
343
+ record
344
+ end
345
+
346
+ def mark_stopped(record)
347
+ record["status"] = "stopped"
348
+ record["stop_reason"] = "stopped_by_#{record["stop_requested_by"]}"
349
+ record["finished_at"] ||= timestamp
350
+ record
351
+ end
352
+
316
353
  def read_exit_code(path)
317
354
  return nil if path.to_s.empty? || !File.exist?(path)
318
355
 
@@ -10,7 +10,7 @@ module Samagotchi
10
10
  def self.name = NAME
11
11
 
12
12
  def self.call(task_id)
13
- record, error = TaskRuntime.stop_task(task_id.to_s.strip)
13
+ record, error = TaskRuntime.stop_task(task_id.to_s.strip, by: "model")
14
14
  return error if error
15
15
 
16
16
  [
@@ -37,10 +37,10 @@ module Samagotchi
37
37
  return "Error: #{error}" if error
38
38
 
39
39
  status = record.fetch("status")
40
- return format_response(record) unless status == "running"
40
+ return finished_response(record, tail_lines) unless status == "running"
41
41
 
42
42
  if completion_pattern
43
- output_tail = TaskRuntime.output_tail_lines(record.fetch("output_path"), tail_lines)
43
+ output_tail = output_tail(record, tail_lines)
44
44
  return format_response(record, wait_result: "pattern_matched", output_tail: output_tail) if completion_pattern.match?(output_tail)
45
45
  end
46
46
 
@@ -53,10 +53,9 @@ module Samagotchi
53
53
 
54
54
  # Timeout reached — do one final refresh to capture the latest status
55
55
  refreshed, = TaskRuntime.get_record(task_id)
56
- return format_response(refreshed) unless refreshed.fetch("status") == "running"
56
+ return finished_response(refreshed, tail_lines) unless refreshed.fetch("status") == "running"
57
57
 
58
- output_tail = TaskRuntime.output_tail_lines(refreshed.fetch("output_path"), tail_lines)
59
- format_response(refreshed, wait_result: "timeout", output_tail: output_tail)
58
+ format_response(refreshed, wait_result: "timeout", output_tail: output_tail(refreshed, tail_lines))
60
59
  rescue StandardError => e
61
60
  "Error: #{e.message}"
62
61
  end
@@ -66,18 +65,31 @@ module Samagotchi
66
65
  def self.cancelled_response(task_id, tail_lines, started)
67
66
  record, error = TaskRuntime.get_record(task_id)
68
67
  return "Error: #{error}" if error
69
- return format_response(record) unless record.fetch("status") == "running"
68
+ return finished_response(record, tail_lines) unless record.fetch("status") == "running"
70
69
 
71
- output_tail = TaskRuntime.output_tail_lines(record.fetch("output_path"), tail_lines)
72
70
  note = [
73
71
  "note: the user stopped the turn while waiting; the task is STILL RUNNING.",
74
72
  " To keep waiting: task_wait #{task_id}. If it is stuck or no longer needed: task_stop #{task_id}.",
75
73
  "waited: #{(monotonic_time - started).round}s"
76
74
  ]
77
- format_response(record, wait_result: "canceled", output_tail: output_tail, extra: note)
75
+ format_response(record, wait_result: "canceled", output_tail: output_tail(record, tail_lines), extra: note)
78
76
  end
79
77
  private_class_method :cancelled_response
80
78
 
79
+ # A task the user stopped also says so, with the tail of its output:
80
+ # the model sees where the job was cut off.
81
+ def self.finished_response(record, tail_lines)
82
+ return format_response(record) unless TaskRuntime.stopped_by_user?(record)
83
+
84
+ format_response(record, output_tail: output_tail(record, tail_lines), extra: [TaskRuntime::USER_STOP_NOTE])
85
+ end
86
+ private_class_method :finished_response
87
+
88
+ def self.output_tail(record, tail_lines)
89
+ TaskRuntime.output_tail_lines(record.fetch("output_path"), tail_lines)
90
+ end
91
+ private_class_method :output_tail
92
+
81
93
  def self.format_response(record, wait_result: nil, output_tail: nil, extra: [])
82
94
  lines = [
83
95
  "task_id: #{record.fetch("id")}",
@@ -86,9 +98,9 @@ module Samagotchi
86
98
  "stop_reason: #{record["stop_reason"]}",
87
99
  "output_path: #{record.fetch("output_path")}"
88
100
  ]
89
- return lines.join("\n") unless wait_result
101
+ return lines.join("\n") unless output_tail
90
102
 
91
- lines << "wait_result: #{wait_result}"
103
+ lines << "wait_result: #{wait_result}" if wait_result
92
104
  lines.concat(extra)
93
105
  lines << "output_tail:"
94
106
  lines << output_tail.rstrip unless output_tail.empty?
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "model_profile"
4
+ require_relative "thinking_tails"
4
5
 
5
6
  module Samagotchi
6
7
  # The one-line system note a turn leaves in the conversation when it ends
@@ -23,7 +24,8 @@ module Samagotchi
23
24
  EMPTY_ANSWER = :empty_answer
24
25
  # An empty step's thinking kept on the marker: its tail, so a model
25
26
  # that thought for minutes doesn't put megabytes in the session file.
26
- STEP_CHARS = 20_000
27
+ # The same cap as ThinkingTails' saved tails.
28
+ STEP_CHARS = ThinkingTails::TAIL_CHARS
27
29
  # A step's native thinking starts with one of these; the cut keeps it,
28
30
  # so the UIs still find the thinking (MessageParts).
29
31
  THINK_OPENS = ["<think>", ModelProfile::GEMMA_THOUGHT_CHANNEL_OPEN].freeze
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.26.0"
4
+ VERSION = "0.27.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
@@ -139,6 +139,7 @@ module Samagotchi
139
139
  ["GET", %r{\A/api/sessions/([^/]+)/stream\z}, :handle_stream],
140
140
  ["GET", %r{\A/api/sessions/([^/]+)/output\z}, :handle_output],
141
141
  ["POST", %r{\A/api/sessions/([^/]+)/cancel\z}, :handle_cancel],
142
+ ["POST", %r{\A/api/sessions/([^/]+)/tasks/([^/]+)/stop\z}, :handle_task_stop],
142
143
  ["POST", %r{\A/api/sessions/([^/]+)/stop\z}, :handle_stop],
143
144
  ["POST", %r{\A/api/sessions/([^/]+)/restart\z}, :handle_restart],
144
145
  ["POST", %r{\A/api/sessions/([^/]+)/archive\z}, :handle_archive],
@@ -1019,6 +1020,30 @@ module Samagotchi
1019
1020
  error_response(404, "not_found", e.message)
1020
1021
  end
1021
1022
 
1023
+ # A task id as TaskRuntime.generate_task_id makes it.
1024
+ TASK_ID = /\A\d{14}-\h{8}\z/
1025
+
1026
+ # The stop-task button: the worker stops the task as the user
1027
+ # (Bridge#handle_task_stop) and its task_wait returns. Its own 404
1028
+ # (not this conversation's task) and 409 (already ended) pass through;
1029
+ # a 404 from a worker without the route is the stale worker's 501.
1030
+ def handle_task_stop(_req, id, task_id)
1031
+ return error_response(400, "invalid_task_id", "not a task id: #{task_id.to_s[0, 80]}") unless TASK_ID.match?(task_id)
1032
+
1033
+ @session_class.load(id, state_dir: default_state_dir)
1034
+ request = ->(client) { client.stop_task(task_id) }
1035
+ relay(id, bridge_client(id), request, what: "the task was not stopped", cant: "stop tasks") do |reply|
1036
+ body = reply.json || {}
1037
+ case reply.status
1038
+ when 200 then json_response(200, { status: body["status"], stop_reason: body["stop_reason"], task_id: task_id, session_id: id })
1039
+ when 404 then (error_response(404, "task_not_found", "no task #{task_id} in this conversation") if body["error"] == "task_not_found")
1040
+ when 409 then json_response(409, { error: "not_running", status: body["status"], task_id: task_id, session_id: id })
1041
+ end
1042
+ end
1043
+ rescue ArgumentError => e
1044
+ error_response(404, "not_found", e.message)
1045
+ end
1046
+
1022
1047
  def handle_stop(_req, id)
1023
1048
  # Bounded, so a Rack thread isn't held long; a worker still exiting
1024
1049
  # after it just means an immediate resume finds it (rare).
@@ -8,7 +8,7 @@ module Samagotchi
8
8
  class MarkdownRenderer
9
9
  WARNING = "Markdown rendering is enabled, but the optional commonmarker gem is not installed. Install it with: gem install commonmarker"
10
10
  # NOTE: `span` is allowed because the syntax highlighter (commonmarker/syntect)
11
- # emits inline-styled <span>s; keeping it lets highlighting render.
11
+ # emits classed <span>s; keeping it lets highlighting render.
12
12
  ALLOWED_ELEMENTS = %w[a blockquote br code del em h1 h2 h3 h4 h5 h6 hr li ol p pre s strong span table tbody td th thead tr ul].freeze
13
13
  DROP_ELEMENTS = %w[iframe object script style svg].freeze
14
14
  ALLOWED_PROTOCOLS = %w[http https mailto].freeze
@@ -35,6 +35,11 @@ module Samagotchi
35
35
  html = Commonmarker.to_html(markdown.to_s, options: {
36
36
  render: { escape: true }, # escape raw HTML from source; highlighter output is still emitted
37
37
  extension: { header_ids: nil }
38
+ }, plugins: {
39
+ # An empty theme makes syntect emit scope classes (`keyword`, `string`, ...)
40
+ # instead of inline colours from one fixed theme, so the page's light and
41
+ # dark palettes colour code blocks (the --code-* tokens in index.html).
42
+ syntax_highlighter: { theme: "" }
38
43
  })
39
44
  return nil unless html
40
45
 
@@ -74,6 +79,7 @@ module Samagotchi
74
79
 
75
80
  node.remove_attribute(attribute.name)
76
81
  end
82
+ prefix_highlight_classes(node) if node.name == "span"
77
83
  return unless node.name == "a"
78
84
 
79
85
  href = node["href"].to_s.strip
@@ -85,16 +91,25 @@ module Samagotchi
85
91
  node["rel"] = "noopener noreferrer"
86
92
  end
87
93
 
88
- # The syntax highlighter emits inline `style=` on <span>/<pre>/<code>. Those
89
- # values come from the (trusted) theme, not user input, and raw HTML from the
90
- # markdown source is already escaped by commonmarker (escape: true), so style
91
- # attributes are safe to keep on these elements only.
94
+ # The syntax highlighter emits `class=` on <pre> ("syntax-highlighting") and
95
+ # <span> (syntect scope names). They come from the highlighter, not user input:
96
+ # raw HTML from the markdown source is already escaped by commonmarker
97
+ # (escape: true), so class attributes are safe to keep on these elements only.
92
98
  def allowed_attribute?(node, name)
93
- return true if name == "style" && %w[span pre code].include?(node.name)
99
+ return true if name == "class" && %w[span pre].include?(node.name)
94
100
 
95
101
  node.name == "a" && %w[href title].include?(name)
96
102
  end
97
103
 
104
+ # Scope names are plain words ("diff", "meta", "name", ...) that page CSS and
105
+ # querySelectors use too: prefix them so they only ever match the hl-* rules.
106
+ def prefix_highlight_classes(node)
107
+ words = node["class"].to_s.split
108
+ return node.remove_attribute("class") if words.empty?
109
+
110
+ node["class"] = words.map { |word| "hl-#{word}" }.join(" ")
111
+ end
112
+
98
113
  def safe_href?(href)
99
114
  return true if href.start_with?("#", "/")
100
115
 
@@ -23,6 +23,7 @@ import { chipName, chipsHtml, imageFiles, restoredChips, thumbsHtml, turnRefs, t
23
23
  import { escapeHtml, memChipText, memChipTitle, noteHtml, normalize, steerRowHtml, userBodyHtml, ownerBadge, previewForCard, relativeTime, failedTurnText, modelLabel, deleteConfirmText, recapLabel, canStopSession, goneSessionNotice, withLiveStatus, withoutWorker, recapPlace, delegatedBy } from "./format.js";
24
24
  import { EMPTY_ANSWER_CLASS, createTurnView, turnHistoryHtml } from "./turn_view.js";
25
25
  import { createStageView } from "./stage_view.js";
26
+ import { createTaskStopper } from "./task_stop.js";
26
27
  import { appendQuote } from "./annotations.js";
27
28
  import { parsePresets } from "./annotate_presets.js";
28
29
  import { createAnnotateBar } from "./annotate_bar.js";
@@ -86,6 +87,9 @@ const viewArgs = {
86
87
  isNearBottom: () => isNearBottom(),
87
88
  sawText: (k) => seenContent.add(k),
88
89
  sessionId: () => selected,
90
+ // The stop-task button on a running task_wait, for a worker that has
91
+ // the route (its sidecar features).
92
+ taskStop: createTaskStopper({ sessionId: () => selected, features: () => current.workerFeatures }),
89
93
  };
90
94
  const turnDom = view === "stage"
91
95
  ? createStageView({ ...viewArgs, isNearBottom: () => atHistoryEnd(), dockEl: $("#dock"), centerEl: $("#center"), composerEl })
@@ -123,6 +123,17 @@ export function stopSession(id, opts = {}) {
123
123
  return api(`/api/sessions/${id}/stop`, { method: "POST", body: JSON.stringify({}), ...opts });
124
124
  }
125
125
 
126
+ // Kills a background task the session's conversation started (the
127
+ // stop-task button): 200 {status, stop_reason}; 409 it already ended, 503
128
+ // no live worker.
129
+ export function stopTask(id, taskId, opts = {}) {
130
+ return api(`/api/sessions/${encodeURIComponent(id)}/tasks/${encodeURIComponent(taskId)}/stop`, {
131
+ method: "POST",
132
+ body: JSON.stringify({}),
133
+ ...opts,
134
+ });
135
+ }
136
+
126
137
  // Hands the session to a new worker on the newest chi installed: 200
127
138
  // {status, from_version, version}, or 409 with the reason in words (detail).
128
139
  export function restartSession(id, opts = {}) {
@@ -8,11 +8,13 @@
8
8
  :root{color-scheme:dark;--bg:#0b0d11;--bg-panel:#151821;--bg-inset:#0f1115;--bg-raised:#22262f;--bg-hover:#1b1f27;--border:#1f232b;--border-strong:#2d333f;--text:#e6e8eb;--text-bright:#f0f3f6;--text-2:#c9d1d9;--text-3:#9aa4b2;--text-muted:#8b949e;--text-faint:#6e7681;--accent:#7aa5ff;--accent-strong:#1f6feb;--accent-bg:#1b2336;--accent-ring:rgba(122,165,255,.18);--primary:#2a68d1;--ok:#3fb950;--ok-text:#7ee787;--ok-bg:#1a2e1a;--warn:#d29922;--warn-text:#e3b341;--warn-bright:#f2cc60;--warn-bg:#2b2108;--warn-border:#6b5416;--danger:#f85149;--danger-bg:#da3633;--danger-text:#ff7b72;--danger-bg-dim:#3d1f1f;--think:#8957e5;--think-text:#d2a8ff;--accent-border:#223150;--accent-tint:color-mix(in srgb,var(--accent) 7%,transparent);--narr:color-mix(in srgb,var(--accent) 38%,var(--text-muted));
9
9
  --glass-bg:color-mix(in srgb,var(--bg-panel) 70%,transparent);--glass-border:rgba(255,255,255,.08);--glass-highlight:rgba(255,255,255,.05);--glass-shadow:0 8px 30px rgba(0,0,0,.35);
10
10
  --diff-add-bg:rgba(63,185,80,.14);--diff-add-text:#aff5b4;--diff-del-bg:rgba(248,81,73,.14);--diff-del-text:#ffc1bc;
11
+ --code-text:#c9d1d9;--code-keyword:#ff7b72;--code-string:#a5d6ff;--code-comment:#8b949e;--code-constant:#79c0ff;--code-func:#d2a8ff;--code-type:#ffa657;
11
12
  --card-hover-border:rgba(255,255,255,.14);--on-solid:#fff;--on-warn:#fff;--text-hover:#fff;--badge-bg:rgba(255,255,255,.08);--badge-ring:rgba(255,255,255,.18);--shadow-strong:rgba(0,0,0,.5);--backdrop:rgba(0,0,0,.85);--toast-shadow:0 6px 24px rgba(0,0,0,.25)}
12
13
  /* Light palette: follows the system setting. :not([data-theme="dark"]) leaves room for a later in-page toggle (data-theme on <html>); there is none yet. Each palette sets color-scheme, so native radios, inputs and scrollbars follow it. */
13
14
  @media (prefers-color-scheme:light){:root:not([data-theme="dark"]){color-scheme:light;--bg:#f5f6f8;--bg-panel:#ffffff;--bg-inset:#f1f3f6;--bg-raised:#e8ebf0;--bg-hover:#eceff4;--border:#dde1e7;--border-strong:#c3c9d2;--text:#1f2328;--text-bright:#0d1117;--text-2:#2f363d;--text-3:#475160;--text-muted:#57606a;--text-faint:#636c76;--accent:#0b5cd5;--accent-strong:#0550ae;--accent-bg:#e2ecfd;--accent-ring:rgba(11,92,213,.2);--primary:#1f6feb;--ok:#1a7f37;--ok-text:#116329;--ok-bg:#dafbe1;--warn:#9a6700;--warn-text:#7d4e00;--warn-bright:#875c00;--warn-bg:#fff6cc;--warn-border:#d4a72c;--danger:#cf222e;--danger-bg:#cf222e;--danger-text:#a40e26;--danger-bg-dim:#ffebe9;--think:#8250df;--think-text:#6639ba;--accent-border:#b8cef5;
14
15
  --glass-border:rgba(31,35,40,.10);--glass-highlight:rgba(255,255,255,.7);--glass-shadow:0 8px 30px rgba(31,35,40,.10);
15
16
  --diff-add-bg:rgba(26,127,55,.12);--diff-add-text:#116329;--diff-del-bg:rgba(207,34,46,.10);--diff-del-text:#82071e;
17
+ --code-text:#1f2328;--code-keyword:#cf222e;--code-string:#0a3069;--code-comment:#57606a;--code-constant:#0550ae;--code-func:#8250df;--code-type:#953800;
16
18
  --card-hover-border:rgba(31,35,40,.18);--on-solid:#fff;--on-warn:#1f2328;--text-hover:#0d1117;--badge-bg:rgba(31,35,40,.06);--badge-ring:rgba(31,35,40,.15);--shadow-strong:rgba(31,35,40,.18);--backdrop:rgba(13,17,23,.85);--toast-shadow:0 6px 24px rgba(31,35,40,.14)}}
17
19
  *{box-sizing:border-box;margin:0;padding:0}
18
20
  body{font-family: ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;background:radial-gradient(1100px 700px at 8% 0%,color-mix(in srgb,var(--accent) 13%,transparent),transparent 70%),radial-gradient(900px 700px at 100% 100%,color-mix(in srgb,var(--think) 9%,transparent),transparent 70%),var(--bg);background-attachment:fixed;color:var(--text);height:100vh;display:flex;flex-direction:column}
@@ -62,6 +64,18 @@ main{flex:1;display:flex;flex-direction:column;gap:16px;padding:16px;overflow:au
62
64
  .bubble.output.markdown code{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:.88em}
63
65
  .bubble.output.markdown :not(pre)>code{background:var(--bg-inset);border:1px solid var(--border);border-radius:4px;padding:1px 5px;color:var(--text)}
64
66
  .bubble.output.markdown pre{font-size:13px;line-height:1.45;border:1px solid var(--border)}
67
+ /* Code highlighting (markdown_renderer.rb): syntect scope classes, hl-prefixed, coloured from the palette's --code-* tokens. More classes win over fewer (a function name is also "entity"). */
68
+ .syntax-highlighting{color:var(--code-text)}
69
+ .syntax-highlighting .hl-keyword,.syntax-highlighting .hl-storage{color:var(--code-keyword)}
70
+ .syntax-highlighting .hl-string{color:var(--code-string)}
71
+ .syntax-highlighting .hl-string .hl-embedded{color:var(--code-text)}
72
+ .syntax-highlighting .hl-constant{color:var(--code-constant)}
73
+ .syntax-highlighting .hl-entity.hl-name.hl-function,.syntax-highlighting .hl-support.hl-function{color:var(--code-func)}
74
+ .syntax-highlighting .hl-entity.hl-name.hl-class,.syntax-highlighting .hl-entity.hl-name.hl-type,.syntax-highlighting .hl-entity.hl-other.hl-inherited-class,.syntax-highlighting .hl-support.hl-class,.syntax-highlighting .hl-support.hl-type,.syntax-highlighting .hl-meta.hl-diff.hl-range{color:var(--code-type)}
75
+ .syntax-highlighting .hl-comment{color:var(--code-comment);font-style:italic}
76
+ .syntax-highlighting .hl-markup.hl-inserted{color:var(--diff-add-text)}
77
+ .syntax-highlighting .hl-markup.hl-deleted{color:var(--diff-del-text)}
78
+ .syntax-highlighting .hl-invalid{color:var(--danger-text)}
65
79
  .bubble.output.markdown h1,.bubble.output.markdown h2,.bubble.output.markdown h3,.bubble.output.markdown h4,.bubble.output.markdown h5,.bubble.output.markdown h6{font-weight:600;line-height:1.3;margin:18px 0 8px;color:var(--text-bright)}
66
80
  .bubble.output.markdown h1{font-size:19px}
67
81
  .bubble.output.markdown h2{font-size:17px}
@@ -122,6 +136,11 @@ main{flex:1;display:flex;flex-direction:column;gap:16px;padding:16px;overflow:au
122
136
  .activity-status.stopped{color:var(--warn-text);opacity:.8}
123
137
  .activity-status.blocked{color:var(--danger-text)}
124
138
  .activity-tool{color:var(--accent);font-weight:600}
139
+ /* The stop-task button on a running task_wait (task_stop.js): a small ghost
140
+ button in the danger colours, at the row's end and in the stage's now slot. */
141
+ button.task-stop{flex:none;margin-left:auto;padding:1px 8px;border:1px solid color-mix(in srgb,var(--danger) 45%,transparent);border-radius:6px;background:transparent;color:var(--danger-text);font-size:11.5px;line-height:18px;white-space:nowrap}
142
+ button.task-stop:hover:not(:disabled){background:var(--danger-bg-dim);filter:none}
143
+ .ts-now button.task-stop{align-self:center}
125
144
  .activity-params{color:var(--text-muted);word-break:break-all}
126
145
  .activity-duration{color:var(--text-faint);font-size:11px;font-variant-numeric:tabular-nums;white-space:nowrap}
127
146
  .activity-output{margin:4px 0 2px 60px;font-size:12px;color:var(--text-muted);white-space:pre-wrap;word-break:break-word;background:var(--bg);border-radius:6px;padding:6px 8px;max-height:160px;overflow:auto}
@@ -85,6 +85,12 @@ function withCommand(slot, row) {
85
85
  return row.view?.command ? { ...slot, command: row.view.command } : slot;
86
86
  }
87
87
 
88
+ // A running task_wait's slot: the task it waits on and the row's key, for
89
+ // the stop-task button (task_stop.js).
90
+ function withTask(slot, row) {
91
+ return row.tool === "task_wait" && row.view?.task_id ? { ...slot, taskId: row.view.task_id, key: row.key } : slot;
92
+ }
93
+
88
94
  // While the first step has no thinking and no tool rows the turn is plain:
89
95
  // its text streams in the stage's answer slot, as the turn view's
90
96
  // chrome-less block streams like a bubble.
@@ -106,7 +112,7 @@ const NO_ANSWER = "no answer";
106
112
  // feed), else the newest thinking one (+thinking+, its ticker),
107
113
  // else "…";
108
114
  // tool the running call ({name, title, kind}, and command: its full
109
- // command when it has one) or null;
115
+ // command when it has one; taskId and key: a task_wait's) or null;
110
116
  // trail the last three finished calls, newest last (a file's title
111
117
  // without its dir; command as for tool);
112
118
  // ticks one per call: {kind, status, step (the gen's index)}.
@@ -137,7 +143,7 @@ export function liveSlots(turn, { narration = "", thinking = "", pending = false
137
143
  phase,
138
144
  step: ended ? gens.filter((g) => !g.answer || g.thinking || g.tools.length).length : gens.length,
139
145
  headline,
140
- tool: running ? withCommand({ name: toolName(running), title: rowTitle(running), kind: toolKind(running.tool) }, running) : null,
146
+ tool: running ? withTask(withCommand({ name: toolName(running), title: rowTitle(running), kind: toolKind(running.tool) }, running), running) : null,
141
147
  trail,
142
148
  ticks: rows.map(({ row, step }) => ({ kind: toolKind(row.tool), status: row.status, step })),
143
149
  };
@@ -13,6 +13,7 @@
13
13
  import { createTurnView } from "./turn_view.js";
14
14
  import { flashOf, handOffDue, handOffOrder, headlineOf, inUseFrom, isPlain, liveSlots, placeFor } from "./stage_model.js";
15
15
  import { formatDuration } from "./timing.js";
16
+ import { taskCommand, taskStopButton } from "./task_stop.js";
16
17
 
17
18
  const COLLAPSE_KEY = "chi_stage_collapsed";
18
19
  const WATCH_MS = 200;
@@ -45,7 +46,9 @@ function readCollapsed() {
45
46
  // +historyNearBottom+: the history's own follow check (the hand-off scrolls
46
47
  // the history only for a reader at its end). +dockEl+, +centerEl+ and
47
48
  // +composerEl+ size the stage's cap.
48
- export function createStageView({ historyEl, appendToHistory, isNearBottom: historyNearBottom, sawText, sessionId, dockEl, centerEl, composerEl }) {
49
+ // +taskStop+: the page's stopper (task_stop.js), for the stop-task button
50
+ // on a running task_wait.
51
+ export function createStageView({ historyEl, appendToHistory, isNearBottom: historyNearBottom, sawText, sessionId, dockEl, centerEl, composerEl, taskStop = null }) {
49
52
  // The dock becomes a clear column: the stage's card, then the composer's
50
53
  // (its rows moved into .dock-card, which wears the glass and the focus ring).
51
54
  document.body.classList.add("stage-view");
@@ -131,6 +134,7 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
131
134
  },
132
135
  useHold: false,
133
136
  newestFirst: true,
137
+ taskStop,
134
138
  onChange: () => scheduleRefresh(),
135
139
  onThinkingLine: (text) => {
136
140
  const { turn } = view.current();
@@ -223,7 +227,11 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
223
227
 
224
228
  function drawNow(tool, gen) {
225
229
  const quiet = gen?.text && !gen.textDone ? "writing…" : "thinking…";
226
- const key = tool ? `run|${tool.name}|${tool.title}|${tool.command || ""}` : `quiet|${quiet}`;
230
+ // A task_wait's stop button, as the row's (task_stop.js): in the key
231
+ // with its in-flight state, so a redraw keeps a clicked one disabled.
232
+ const stopId = tool?.taskId && taskStop?.target({ tool: "task_wait", status: "running", view: { task_id: tool.taskId } });
233
+ const stopKey = stopId ? `|stop:${tool.key}:${taskStop.busy(stopId)}` : "";
234
+ const key = tool ? `run|${tool.name}|${tool.title}|${tool.command || ""}${stopKey}` : `quiet|${quiet}`;
227
235
  if (shown.now === key) return;
228
236
  shown.now = key;
229
237
  now.replaceChildren();
@@ -236,6 +244,10 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
236
244
  title.textContent = tool.title;
237
245
  // The full command when the call has one (the title is its first step +N).
238
246
  title.title = tool.command || tool.title;
247
+ if (stopId) {
248
+ const command = () => taskCommand(view.current().turn?.gens, stopId);
249
+ now.appendChild(taskStopButton(taskStop, stopId, { command, onChange: scheduleRefresh }));
250
+ }
239
251
  } else {
240
252
  const word = node("span", "ts-title quiet", now);
241
253
  word.textContent = quiet;
@@ -0,0 +1,101 @@
1
+ // The stop-task button on a running task_wait (POST …/tasks/:task_id/stop,
2
+ // Bridge#handle_task_stop): the worker kills the task as the user, the wait
3
+ // returns, and the turn goes on. Which row gets one, what the confirm says,
4
+ // and the stops in flight. The in-flight set lives here, not in the DOM:
5
+ // the stage rebuilds its "now" slot, and a redraw must keep a clicked
6
+ // button disabled. Pure but for the injected calls, so `node --test`
7
+ // covers it; turn_view.js and stage_view.js draw the button.
8
+ import { stopTask as postStopTask } from "./data.js";
9
+
10
+ // What a worker that has the route names in its sidecar features.
11
+ export const TASK_STOP_FEATURE = "task_stop";
12
+ const COMMAND_SHOWN = 120;
13
+
14
+ // The task a row's button would stop: a running task_wait's (its server
15
+ // view's task_id), on a worker that has the route; else null.
16
+ export function stopTarget(row, features) {
17
+ if (!row || row.tool !== "task_wait" || row.status !== "running") return null;
18
+ if (!Array.isArray(features) || !features.includes(TASK_STOP_FEATURE)) return null;
19
+ return row.view?.task_id || null;
20
+ }
21
+
22
+ // The command a task_create of this turn started +taskId+ with (its row's
23
+ // view), or null: a task from an earlier turn has no row here.
24
+ export function taskCommand(gens, taskId) {
25
+ for (const gen of gens || []) {
26
+ for (const row of gen.tools || []) {
27
+ if (row.tool !== "task_create") continue;
28
+ const id = /(?:^|\n)task_id: (\S+)/.exec(row.output || "")?.[1];
29
+ if (id === taskId) return row.view?.command || null;
30
+ }
31
+ }
32
+ return null;
33
+ }
34
+
35
+ export function confirmText(taskId, command) {
36
+ const line = String(command || "").replace(/\s+/g, " ").trim();
37
+ const what = !line ? taskId : line.length > COMMAND_SHOWN ? `${line.slice(0, COMMAND_SHOWN - 1)}…` : line;
38
+ return `Stop task ${what}?`;
39
+ }
40
+
41
+ // A failed stop said nothing about: 409 (the task ended meanwhile), 503
42
+ // (no live worker: it can't be stopped now).
43
+ export function quietFailure(error) {
44
+ return error?.status === 409 || error?.status === 503;
45
+ }
46
+
47
+ // +sessionId+ and +features+ are read at each call (the selected session,
48
+ // its worker's sidecar features).
49
+ export function createTaskStopper({
50
+ sessionId,
51
+ features,
52
+ stopTask = postStopTask,
53
+ confirm = (text) => globalThis.confirm(text),
54
+ alert = (text) => globalThis.alert(text),
55
+ }) {
56
+ const inFlight = new Set();
57
+ const keyOf = (taskId) => `${sessionId()}|${taskId}`;
58
+ return {
59
+ target: (row) => stopTarget(row, features()),
60
+ busy: (taskId) => inFlight.has(keyOf(taskId)),
61
+ // true once the worker stopped it. A stop that went through stays in
62
+ // flight: the button goes with its row's completion a moment later.
63
+ async stop(taskId, command = null) {
64
+ const id = sessionId();
65
+ const key = keyOf(taskId);
66
+ if (!id || inFlight.has(key) || !confirm(confirmText(taskId, command))) return false;
67
+ inFlight.add(key);
68
+ try {
69
+ await stopTask(id, taskId);
70
+ return true;
71
+ } catch (e) {
72
+ inFlight.delete(key);
73
+ if (!quietFailure(e)) alert(e.message);
74
+ return false;
75
+ }
76
+ },
77
+ };
78
+ }
79
+
80
+ // The button itself (the one DOM part): a click asks, posts, and keeps it
81
+ // disabled while the stop is in flight. +command+ is read at the click;
82
+ // +onChange+ lets a view redraw (the stage's slot).
83
+ export function taskStopButton(stopper, taskId, { command = () => null, onChange = () => {} } = {}) {
84
+ const btn = document.createElement("button");
85
+ btn.type = "button";
86
+ btn.className = "ghost task-stop";
87
+ btn.textContent = "stop task";
88
+ btn.title = "Kill this background task; the model is told you stopped it";
89
+ btn.disabled = stopper.busy(taskId);
90
+ btn.addEventListener("click", (e) => {
91
+ e.stopPropagation();
92
+ const pending = stopper.stop(taskId, command());
93
+ btn.disabled = stopper.busy(taskId);
94
+ onChange();
95
+ pending.then(() => {
96
+ btn.disabled = stopper.busy(taskId);
97
+ onChange();
98
+ });
99
+ });
100
+ return btn;
101
+ }
@@ -23,6 +23,7 @@ import { createSentenceFeed } from "./sentences.js";
23
23
  import { createTicker } from "./thinking_ticker.js";
24
24
  import * as model from "./turn_model.js";
25
25
  import { createHold } from "./hold.js";
26
+ import { taskCommand, taskStopButton } from "./task_stop.js";
26
27
 
27
28
  // A row's status (live, or a saved tool record's on reload): running,
28
29
  // error, stopped (a task_wait the user's Stop ended), blocked (a guardrail
@@ -90,7 +91,9 @@ function fillActivityRow(el, row, sessionId) {
90
91
  // (chronological() restores the order);
91
92
  // onChange() after anything in the turn changed (the stage redraws
92
93
  // its slots from current());
93
- // onThinkingLine(sentence) the live step's ticker showed a new line.
94
+ // onThinkingLine(sentence) the live step's ticker showed a new line;
95
+ // taskStop the page's stopper (task_stop.js): a running task_wait
96
+ // row gets its stop-task button (none: no button).
94
97
  export function createTurnView({
95
98
  historyEl, appendToHistory, isNearBottom, sawText, sessionId,
96
99
  blockHost = appendToHistory,
@@ -100,6 +103,7 @@ export function createTurnView({
100
103
  newestFirst = false,
101
104
  onChange = () => {},
102
105
  onThinkingLine = () => {},
106
+ taskStop = null,
103
107
  }) {
104
108
  let turn = null;
105
109
  let blockEl = null;
@@ -407,14 +411,33 @@ export function createTurnView({
407
411
  const stick = isNearBottom();
408
412
  body.appendChild(el);
409
413
  fillActivityRow(el, r.row, sessionId());
414
+ syncTaskStop(el, r.row);
410
415
  if (stick) follow();
411
416
  } else {
412
417
  fillActivityRow(el, r.row, sessionId());
418
+ syncTaskStop(el, r.row);
413
419
  }
414
420
  updateBlockSummary();
415
421
  onChange();
416
422
  }
417
423
 
424
+ // The stop-task button after a running task_wait's params; gone once the
425
+ // row completes.
426
+ function syncTaskStop(el, row) {
427
+ const taskId = taskStop?.target(row);
428
+ const btn = el.querySelector(":scope > .task-stop");
429
+ if (!taskId) {
430
+ btn?.remove();
431
+ return;
432
+ }
433
+ if (btn) {
434
+ btn.disabled = taskStop.busy(taskId);
435
+ return;
436
+ }
437
+ const command = () => taskCommand(turn?.gens, taskId);
438
+ el.querySelector(".activity-params").after(taskStopButton(taskStop, taskId, { command }));
439
+ }
440
+
418
441
  // A hook's line (hook_notice) as a row of the current step, above the row
419
442
  // of the call it judged; false with no running turn (app.js shows the
420
443
  // bubble then). data.line is a whole row of chi's own (an empty-answer
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: samagotchi
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.26.0
4
+ version: 0.27.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dmitry Dedov
@@ -471,6 +471,7 @@ files:
471
471
  - lib/samagotchi/web/public/stage_view.js
472
472
  - lib/samagotchi/web/public/strip.js
473
473
  - lib/samagotchi/web/public/tally.js
474
+ - lib/samagotchi/web/public/task_stop.js
474
475
  - lib/samagotchi/web/public/thinking_ticker.js
475
476
  - lib/samagotchi/web/public/timing.js
476
477
  - lib/samagotchi/web/public/turn_events.js