samagotchi 0.28.0 → 0.29.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: 51c7c1a00eec47f70f15b04381eb6b60f5bd6c66c8dd11fbe05ef045c41d5219
4
- data.tar.gz: 00cbfcba1b3b21ef54c9d54bd9de349a90a19314cb5613e9daa94ca51c8c04fe
3
+ metadata.gz: 581b7b0fa7184fb49a83588921656f2f91f05ae04538592e69874ba1207b686c
4
+ data.tar.gz: 4c381eb155ab69b933cda29461a02b111910adfb53309bb38ffc5819dfc764d6
5
5
  SHA512:
6
- metadata.gz: 11f14c97b088efe1c1eafafb63604f44c84a7a7a79d738db5f6344191b6fa9948ceab37acd0f8daba2a85840b3f272bfcd767bd2147b0da7abbc23a04936bba4
7
- data.tar.gz: e3136af1decc273b092f66756e2994ad0760a626cb9b007a4a4ad2f68ee85bdf6f37aab3f5a0bde85597f677bde3fde32826763c9b44001e941945abc39dfa5d
6
+ metadata.gz: 84b0ba1d65c4c5d41fcbee9e925934f6fd6ee1eb4b46bb35af98b46cde28f9ad334ab56ff44e1ee503f0f7f9dab107d03429c145c7097fa35f90aa8ab86f1f4f
7
+ data.tar.gz: 7725dc03f4d5c9d42e7a5c4cae0ba5729740a6d95d37fb04c7bdcc611489907b039ad2ec9b0770c7f47ff04fc681d750309988825896c0e90c1e5b7c0dda3605
data/CHANGELOG.md CHANGED
@@ -8,6 +8,35 @@ and commands may change between minor versions. How releases are made:
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [0.29.0] - 2026-10-05
12
+
13
+ ### Added
14
+
15
+ - Web: a session card shows `looped` when loop-guard stopped its last turn (`stopped by <name>` for another hook),
16
+ like `chi sessions list`'s `[looped]`; the all-sessions search finds them by those words.
17
+ - `chi bundle install` warns when a bundle needs a newer chi and its guardrail rules won't load (it already warned
18
+ about its hooks and plugin).
19
+
20
+ ### Changed
21
+
22
+ - Web: a tool row shows its status as a mark (✓, ✕, ■, a spinner while it runs) instead of the word, so its command,
23
+ output and diff start right under the tool name; error, stopped and blocked calls keep their word after the title.
24
+ An open step's body sits indented under its summary.
25
+ - Web: a step with a failed call says so in its collapsed label, `3 tool calls (1 failed)`, with a red chevron; the
26
+ turn's summary counts failed calls below 3 calls too.
27
+ - Web: a `task_wait`, `task_get` or `task_stop` row names its task by the task's command instead of its id, a wait with
28
+ how long it waits (`bundle exec rspec · up to 600s`), also for a task started in an earlier turn; the id line stays
29
+ on hover.
30
+
31
+ ### Fixed
32
+
33
+ - Guardrails: `> /tmp/x cat <<EOF` (a redirection written with a space before the command) is read as a heredoc of
34
+ data like `cat > /tmp/x <<EOF`, and a `)` inside quotes or a heredoc body no longer ends a `$(…)` early.
35
+ - `!cmd` no longer hands a `--model` worker's model to a chi it runs as the default model.
36
+ - A session worker survives a failed save at the end of a turn (it logs it) instead of crashing.
37
+ - A turn that ends with an empty answer removes only its own retry note, not an earlier turn's cancel or failure note.
38
+ - Web: streamed chunks from llama.cpp no longer carry its whole echoed prompt to every web client.
39
+
11
40
  ## [0.28.0] - 2026-10-04
12
41
 
13
42
  ### Added
@@ -1471,7 +1500,8 @@ and long-lived sessions.
1471
1500
  - A macOS desktop helper (`chi desktop install`): a "Send to chi" Service and a
1472
1501
  hotkey panel that send selected text or the clipboard to your sessions.
1473
1502
 
1474
- [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.28.0...HEAD
1503
+ [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.29.0...HEAD
1504
+ [0.29.0]: https://github.com/dm1try/samagotchi/compare/v0.28.0...v0.29.0
1475
1505
  [0.28.0]: https://github.com/dm1try/samagotchi/compare/v0.27.0...v0.28.0
1476
1506
  [0.27.0]: https://github.com/dm1try/samagotchi/compare/v0.26.0...v0.27.0
1477
1507
  [0.26.0]: https://github.com/dm1try/samagotchi/compare/v0.25.0...v0.26.0
data/docs/sessions.md CHANGED
@@ -56,7 +56,7 @@ chi sessions clean --dry-run --days 7 # test sessions older than 7 da
56
56
 
57
57
  `--dry-run` is the safe preview. `--keep N` keeps the N newest sessions that stay: one that goes anyway (a leftover scratch session, one left empty, one older than `--days`) takes no place in the count. Web has no prune endpoint; use the CLI.
58
58
 
59
- `chi sessions list` shows each session's saved recap, its first sentence without the "The user was…" opening (as on the web cards), in place of its last prompt, cut to 60 characters; a session with no recap shows its last prompt. A session whose last turn a hook stopped carries `[looped]` when the hook was loop-guard (the thinking watch's cut, or its stop) and `[stopped by <hook>]` for any other hook, after `[archived]` (`--format json` has it as `stopped_by`; from `Session#last_turn`, so it stays until a later turn ends differently). A delegated session's row ends with `↳ <parent's short id>` (plain and `--live`; `--format json` has `parent_id`); rows keep their `updated_at` order, so a parent may be on another page than its children. After the status, `ctx 12%` says how full the context was after the session's last counted turn (from its `analytics.json`; blank when no turn had a server count or the window is unknown; `ctx_pct` in `--format json`). The web's session cards show it quietly too (`12%`), and a session's ctx meter shows it on load, before its next turn streams.
59
+ `chi sessions list` shows each session's saved recap, its first sentence without the "The user was…" opening (as on the web cards), in place of its last prompt, cut to 60 characters; a session with no recap shows its last prompt. A session whose last turn a hook stopped carries `[looped]` when the hook was loop-guard (the thinking watch's cut, or its stop) and `[stopped by <hook>]` for any other hook, after `[archived]` (`--format json` has it as `stopped_by`; from `Session#last_turn`, so it stays until a later turn ends differently). The web's session cards wear the same marker as a badge: `looped` or `stopped by <hook>` after the status dot (a session that also waits on you shows only its waiting badge: the stop is behind the need), and the all-view's search matches its words too. A delegated session's row ends with `↳ <parent's short id>` (plain and `--live`; `--format json` has `parent_id`); rows keep their `updated_at` order, so a parent may be on another page than its children. After the status, `ctx 12%` says how full the context was after the session's last counted turn (from its `analytics.json`; blank when no turn had a server count or the window is unknown; `ctx_pct` in `--format json`). The web's session cards show it quietly too (`12%`), and a session's ctx meter shows it on load, before its next turn streams.
60
60
 
61
61
  **Projects:** a session belongs to the git project it was started in: the repository, whichever worktree or subfolder of it (the same project root memories use). `chi sessions list` (with `--live` and `--format` too) and `chi web` show the current folder's project; `--scope=all` shows every session, and so does a folder in no repo (`~`). `--cwd PATH` is a folder filter instead of the project. The project is stored with the session (`project_root` in its JSON, `project` in `--format json`), so a session keeps it after its worktree is deleted; sessions older than that are placed by their folder, and one whose folder is gone shows in `--scope=all` only. Retention, `--resume`/`--attach ID`, `chi send`/`chi note ID` and `chi note --all` (every live session) are not scoped. The agent's `list_sessions` lists its own project's sessions; `cwd: "/"` lists every one.
62
62
 
@@ -36,7 +36,8 @@ module Samagotchi
36
36
  build [--scope system|project] [--name NAME] [--version VER] [--description DESC] [--out PATH] [FILES...]
37
37
  Build local memories and installed hooks (and plugin) into a shareable bundle (dir or zip).
38
38
  --scope selects source dir (default: system). --out inferred from extension; default <name>.zip.
39
- FILES... optional allowlist of *.md basenames to include (default: all but installed bundles' ones).
39
+ FILES... optional allowlist of *.md basenames to include (default: all but installed bundles' ones);
40
+ a named memory brings its model overlays (<name>.<model-key>.md).
40
41
 
41
42
  trash [--empty] [--dry-run] [--older-than DAYS]
42
43
  List bundle trash (moved files from uninstalls/upgrades).
@@ -61,7 +62,8 @@ module Samagotchi
61
62
  build [--scope system|project] [--name NAME] [--version VER] [--description DESC] [--out PATH] [FILES...]
62
63
  Build local memories and installed hooks into a shareable bundle (dir or zip).
63
64
  --scope selects source dir (default: system). --out inferred from extension; default <name>.zip.
64
- FILES... optional allowlist of *.md basenames to include (default: all but installed bundles' ones).
65
+ FILES... optional allowlist of *.md basenames to include (default: all but installed bundles' ones);
66
+ a named memory brings its model overlays (<name>.<model-key>.md).
65
67
 
66
68
  Examples:
67
69
  chi bundle build --scope system
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: samagotchi-system
3
- version: 0.28.0
3
+ version: 0.29.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:
@@ -1942,11 +1942,12 @@ module Samagotchi
1942
1942
  # that way: the note, and nothing made up as its answer. The note's
1943
1943
  # marker is for the UIs (the notice, and the empty steps after a
1944
1944
  # reload); it goes on the result's conversation too (the REPL keeps it).
1945
- # A retry's nudge at the tail goes: this note says it all.
1945
+ # A retry's nudge at the tail goes: this note says it all. An earlier
1946
+ # turn's note stays: it says why that turn ended.
1946
1947
  if result.empty_answer?
1947
1948
  note = TurnNote.empty(retries: result.empty_retries, steps: result.empty_steps)
1948
- conversation&.replace(TurnNote.without_trailing(conversation))
1949
- saved = TurnNote.without_trailing(conversation || turn.session.messages)
1949
+ conversation&.replace(TurnNote.without_trailing_nudge(conversation))
1950
+ saved = TurnNote.without_trailing_nudge(conversation || turn.session.messages)
1950
1951
  conversation << note if conversation
1951
1952
  saved + [note]
1952
1953
  elsif result.canceled? && conversation
@@ -169,19 +169,76 @@ module Samagotchi
169
169
  add(SUBST, close + 1 - @i)
170
170
  end
171
171
 
172
+ # From $( to the ) that closes it, as one SUBST. A quote or a
173
+ # heredoc body inside it is skipped over: a ) in one of those
174
+ # doesn't close the substitution.
172
175
  def substitution
173
176
  depth = 0
174
177
  j = @i + 1
175
178
  loop do
176
- depth += 1 if @s[j] == "("
177
- depth -= 1 if @s[j] == ")"
178
- j += 1
179
- break if depth.zero? || j >= @s.size
179
+ break if j >= @s.size
180
+
181
+ c = @s[j]
182
+ if ["'", '"'].include?(c)
183
+ j = quote_end(j)
184
+ elsif c == "("
185
+ depth += 1
186
+ j += 1
187
+ elsif c == ")"
188
+ depth -= 1
189
+ j += 1
190
+ break if depth.zero?
191
+ elsif @s[j, 2] == "<<"
192
+ j = substitution_heredoc(j)
193
+ else
194
+ j += 1
195
+ end
180
196
  end
181
197
  @unterminated = true unless depth.zero?
182
198
  add(SUBST, j - @i)
183
199
  end
184
200
 
201
+ # The index after the quote starting at +j+, a backslash in a double
202
+ # quote escaping the next character; the text's end when it is never
203
+ # closed.
204
+ def quote_end(j)
205
+ quote = @s[j]
206
+ k = j + 1
207
+ while k < @s.size
208
+ if quote == '"' && @s[k] == "\\" then k += 2
209
+ elsif @s[k] == quote then return k + 1
210
+ else k += 1
211
+ end
212
+ end
213
+ k
214
+ end
215
+
216
+ # From a << at +j+ inside a $(…) past its body's terminator line
217
+ # (nothing is lexed: the body is only stepped over), or on to the
218
+ # next character when no tag follows or the terminator never comes
219
+ # (a << that is no heredoc, as arithmetic's 1<<3, stays scanned).
220
+ def substitution_heredoc(j)
221
+ dash = @s[j + 2] == "-"
222
+ k = j + (dash ? 3 : 2)
223
+ k += 1 while [" ", "\t"].include?(@s[k])
224
+ tag, _quoted, stop = heredoc_tag(k)
225
+ return j + 1 if tag.empty?
226
+
227
+ eol = @s.index("\n", stop)
228
+ return j + 1 if eol.nil?
229
+
230
+ k = eol + 1
231
+ while k < @s.size
232
+ eol = @s.index("\n", k) || @s.size
233
+ line = @s[k...eol]
234
+ line = line.sub(/\A\t+/, "") if dash
235
+ return [eol + 1, @s.size].min if line == tag
236
+
237
+ k = eol + 1
238
+ end
239
+ j + 1
240
+ end
241
+
185
242
  def comment
186
243
  @i = @s.index("\n", @i) || @s.size
187
244
  end
@@ -306,10 +363,27 @@ module Samagotchi
306
363
  def data_sink?(at)
307
364
  start = at
308
365
  start -= 1 while start.positive? && @tokens[start - 1][0] == :word
309
- verb = @tokens[start...at].map { |t| t[1] }.find { |w| !w.match?(/\A[A-Za-z_]\w*=/) && !w.match?(REDIRECT) }
366
+ verb = command_word(@tokens[start...at])
310
367
  next_op = @tokens[at..].find { |t| t[0] == :op }
311
368
  DATA_SINKS.include?(File.basename(verb.to_s)) && !["|", "|&"].include?(next_op&.dig(1))
312
369
  end
370
+
371
+ # The command word among the words before a heredoc's: the NAME=
372
+ # words and the redirections dropped, and with a redirection written
373
+ # with a space (> /tmp/x) its target word too.
374
+ def command_word(words)
375
+ i = 0
376
+ while i < words.size
377
+ word = words[i][1]
378
+ if word.match?(REDIRECT)
379
+ i += 1 if word.match?(/\A\d*[<>]{1,2}\z/)
380
+ elsif !word.match?(/\A[A-Za-z_]\w*=/)
381
+ return word
382
+ end
383
+ i += 1
384
+ end
385
+ nil
386
+ end
313
387
  end
314
388
  end
315
389
  end
@@ -372,13 +372,20 @@ module Samagotchi
372
372
  return unless turn.on_stream_event
373
373
 
374
374
  event = { type: :generation_chunk, iteration: turn.iteration, content: chunk[:content], text: split[:text],
375
- thinking: split[:thinking], payload: chunk[:payload] }
375
+ thinking: split[:thinking], payload: event_payload(chunk[:payload]) }
376
376
  # A chunk of a tool call (its bytes are dropped from both lanes): a
377
377
  # steer doesn't cut this generation (Engine#cut_for_steer).
378
378
  event[:tool_call] = true if split[:tool]
379
379
  emit(turn, **event)
380
380
  end
381
381
 
382
+ # The payload a :generation_chunk event carries: llama.cpp's last chunk
383
+ # echoes the whole rendered prompt, which nothing reads and every SSE
384
+ # client would get (the kernel's own reads keep the full payload).
385
+ def event_payload(payload)
386
+ payload.is_a?(Hash) ? payload.except("prompt") : payload
387
+ end
388
+
382
389
  # The payload's prompt-cache counts, kept as the largest seen.
383
390
  def note_cache_counts(generation, payload)
384
391
  counts = TokenUsage.from_payload(payload)
@@ -303,6 +303,7 @@ module Samagotchi
303
303
  # Like the rules, the bundle's plugin replaces an earlier one.
304
304
  plugin_file_for_provenance = install_plugin(manifest, normalized_dir, provenance)
305
305
  warn_hooks_requires_chi(manifest, all_hooks_in_bundle)
306
+ warn_rules_requires_chi(manifest, incoming_rules)
306
307
 
307
308
  # Files the previous version shipped and this one doesn't.
308
309
  if @upgrade && existing_provenance && existing_provenance[:files]
@@ -559,6 +560,19 @@ module Samagotchi
559
560
  end
560
561
  end
561
562
 
563
+ # GuardrailWiring#bundle_rules doesn't read the rules of a bundle whose
564
+ # requires_chi this chi doesn't meet, and a session that never loaded
565
+ # them records a required load failure (the gate refuses guarded calls
566
+ # until chi is updated): say so at install, as the hooks warning does.
567
+ def warn_rules_requires_chi(manifest, rule_files)
568
+ return if manifest.nil? || rule_files.empty?
569
+
570
+ if (failure = Manifest.requires_chi_failure(manifest.requires_chi, Samagotchi::VERSION))
571
+ @warnings << "Bundle #{@name}: its guardrail rules won't load: #{failure}; until chi is updated " \
572
+ "(chi update), guarded tool calls are refused"
573
+ end
574
+ end
575
+
562
576
  # Copy the manifest's plugin file into the bundle's plugin/ dir,
563
577
  # warning when its sha256 differs from the declared one or this chi
564
578
  # doesn't meet requires_chi (the Engine then won't load it).
@@ -5,6 +5,7 @@ require_relative "model_profile"
5
5
  require_relative "served_model"
6
6
  require_relative "turn_flow"
7
7
  require_relative "tools/execute"
8
+ require_relative "tools/builtins"
8
9
  require_relative "commands/registry"
9
10
 
10
11
  module Samagotchi
@@ -207,12 +208,15 @@ module Samagotchi
207
208
  end
208
209
 
209
210
  # The output goes into the conversation for the next turn. Not saved
210
- # here (as in the REPL, the next turn saves it).
211
+ # here (as in the REPL, the next turn saves it). No parent-session
212
+ # marker: the user runs this, not the model. A worker's `chi --model X`
213
+ # default is unset, though: it was that run's, not the default of a chi
214
+ # run here (Tools::Builtins.cli_model_unset).
211
215
  def shell(text)
212
216
  command = text.delete_prefix(SHELL_BANG_PREFIX).strip
213
217
  return reply("!: please provide a shell command after '!'", status: :error) if command.empty?
214
218
 
215
- output = Samagotchi::Tools::Execute.call(command)
219
+ output = Samagotchi::Tools::Execute.call(command, env: Samagotchi::Tools::Builtins.cli_model_unset)
216
220
  @engine.append_messages([{ role: "user", content: "!(#{command})\n#{output}" }])
217
221
  # Rolling back past this would silently drop the command output.
218
222
  @turn_flow.note_conversation_changed
@@ -12,6 +12,7 @@ require_relative "tools/edit"
12
12
  require_relative "tools/task_create"
13
13
  require_relative "tools/task_get"
14
14
  require_relative "tools/task_list"
15
+ require_relative "tools/task_runtime"
15
16
  require_relative "tools/task_stop"
16
17
  require_relative "tools/task_wait"
17
18
  require_relative "tools/web_fetch"
@@ -62,11 +63,13 @@ module Samagotchi
62
63
  # command without its "cd … &&"): a file tool's path relative to +cwd+
63
64
  # when it is under it, a command's description (the model's few words),
64
65
  # else its first step and how many more (CommandSteps; its first line
65
- # when it has no steps), a memory's name;
66
- # nil for the other tools (a plugin's row keeps its preview, the
66
+ # when it has no steps), a memory's name, a task tool's task command
67
+ # (task_title); nil for the other tools (a plugin's row keeps its preview, the
67
68
  # params). +params+ stays the full key=value line (the TUI, the
68
69
  # guardrails).
69
70
  def tool_title(tool_name, call, cwd: nil)
71
+ return task_title(tool_name, call, cwd) if TASK_TOOLS.include?(tool_name)
72
+
70
73
  if [Tools::Execute::NAME, Tools::TaskCreate::NAME].include?(tool_name)
71
74
  title = command_description(tool_name, call) || steps_title(call[:content])
72
75
  return title if title
@@ -90,19 +93,42 @@ module Samagotchi
90
93
  end
91
94
  end
92
95
 
96
+ TASK_TOOLS = [Tools::TaskWait::NAME, Tools::TaskGet::NAME, Tools::TaskStop::NAME].freeze
97
+
98
+ # A task_wait / task_get / task_stop row's title: the task's command
99
+ # (its record under +cwd+, the process's directory when nil) as a
100
+ # command's title, and for a wait how long it waits ("npm test · up to
101
+ # 600s"). nil when there is no record: the row keeps its id.
102
+ def task_title(tool_name, call, cwd)
103
+ command = Tools::TaskRuntime.command_of(call[:content], root: cwd)
104
+ return nil unless command
105
+
106
+ suffix = ""
107
+ if tool_name == Tools::TaskWait::NAME
108
+ timeout = call[:timeout].to_s.strip
109
+ suffix = " · up to #{timeout.empty? ? Tools::TaskWait::TIMEOUT_DEFAULT : timeout.to_i}s"
110
+ end
111
+ room = TOOL_ACTIVITY_PREVIEW_LIMIT - suffix.length
112
+ text = steps_title(command, room) || command_title(command).gsub(/\s+/, " ")
113
+ return nil if text.empty?
114
+
115
+ text = "#{text[0, room - 1]}…" if text.length > room
116
+ "#{text}#{suffix}"
117
+ end
118
+
93
119
  # A leading "cd <dir> &&" / "cd <dir>;" goes: the model's habit, and it
94
120
  # eats the line.
95
121
  LEADING_CD = /\Acd\s+(?:"[^"]*"|'[^']*'|\S+)\s*(?:&&|;)\s*/
96
122
 
97
- # "<first step> +N" (N the steps after it), cut to fit
98
- # TOOL_ACTIVITY_PREVIEW_LIMIT; nil when the command has no steps.
99
- def steps_title(command)
123
+ # "<first step> +N" (N the steps after it), cut to fit +limit+
124
+ # (TOOL_ACTIVITY_PREVIEW_LIMIT); nil when the command has no steps.
125
+ def steps_title(command, limit = TOOL_ACTIVITY_PREVIEW_LIMIT)
100
126
  steps = CommandSteps.parse(command)&.steps
101
127
  return nil unless steps
102
128
 
103
129
  first = steps.first.text.gsub(/\s+/, " ")
104
130
  more = steps.size > 1 ? " +#{steps.size - 1}" : ""
105
- room = TOOL_ACTIVITY_PREVIEW_LIMIT - more.length
131
+ room = limit - more.length
106
132
  first = "#{first[0, room - 1]}…" if first.length > room
107
133
  "#{first}#{more}"
108
134
  end
@@ -117,13 +117,16 @@ module Samagotchi
117
117
  peers = kctx.peers
118
118
  id = peers.respond_to?(:session_id) ? peers.session_id.to_s : ""
119
119
  model = peers.respond_to?(:model_ref) ? peers.model_ref.to_s : ""
120
- vars = { Guardrails::ParentApprovals::PARENT_SESSION_ENV => id.empty? ? "chi" : id,
121
- SESSION_MODEL_ENV => model.empty? ? nil : model }
122
- if env[ModelProfile::MODEL_FROM_CLI_ENV]
123
- vars[ModelProfile::MODEL_ENV] = nil
124
- vars[ModelProfile::MODEL_FROM_CLI_ENV] = nil
125
- end
126
- vars
120
+ { Guardrails::ParentApprovals::PARENT_SESSION_ENV => id.empty? ? "chi" : id,
121
+ SESSION_MODEL_ENV => model.empty? ? nil : model }.merge(cli_model_unset(env))
122
+ end
123
+
124
+ # The unsets that keep a `chi --model X` worker's default from passing
125
+ # for the default of a chi run here (nothing when it wasn't the CLI's).
126
+ def cli_model_unset(env = ENV)
127
+ return {} unless env[ModelProfile::MODEL_FROM_CLI_ENV]
128
+
129
+ { ModelProfile::MODEL_ENV => nil, ModelProfile::MODEL_FROM_CLI_ENV => nil }
127
130
  end
128
131
 
129
132
  SESSION_MODEL_ENV = "SAMAGOTCHI_SESSION_MODEL"
@@ -129,6 +129,27 @@ module Samagotchi
129
129
  end.uniq
130
130
  end
131
131
 
132
+ # A task id as task_create makes them: no path parts.
133
+ TASK_ID = /\A[A-Za-z0-9_-]+\z/
134
+
135
+ # The command of the task +task_id+ under +root+ (a session's working
136
+ # directory) as its record has it, for a UI row's title; nil for no
137
+ # such task or an unreadable record. Reads the record only (no
138
+ # refresh), so it is cheap and never fails.
139
+ # @return [String, nil]
140
+ def command_of(task_id, root: Dir.pwd)
141
+ id = task_id.to_s.strip
142
+ return nil unless id.match?(TASK_ID)
143
+
144
+ path = File.join(root || Dir.pwd, TASKS_DIR, id, METADATA_FILENAME)
145
+ return nil unless File.file?(path)
146
+
147
+ command = JSON.parse(File.read(path))["command"]
148
+ command.is_a?(String) && !command.strip.empty? ? command : nil
149
+ rescue StandardError
150
+ nil
151
+ end
152
+
132
153
  def stopped_by_user?(record) = record["stop_reason"] == "stopped_by_user"
133
154
 
134
155
  def get_record(task_id)
@@ -188,6 +188,16 @@ module Samagotchi
188
188
  list
189
189
  end
190
190
 
191
+ # A copy of +messages+ without the note at its tail when that note is a
192
+ # retry nudge (anything else stays: an earlier turn's note is what says
193
+ # why that turn ended).
194
+ def without_trailing_nudge(messages)
195
+ list = Array(messages).dup
196
+ index = trailing_index(list)
197
+ list.delete_at(index) if index && retry_nudge?(list[index])
198
+ list
199
+ end
200
+
191
201
  # The index of the note at the tail of +list+ (behind context notes at
192
202
  # most), or nil.
193
203
  def trailing_index(list)
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.28.0"
4
+ VERSION = "0.29.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
@@ -39,7 +39,7 @@ import { ALL_SESSIONS_HASH, sessionHash, sessionIdFromHash } from "./route.js";
39
39
  import { allScopeHref, cardFolder, projectScopeHref, scopeDir } from "./scope.js";
40
40
  import { shouldFollowScroll, keepFollowing } from "./scroll.js";
41
41
  import { SHORT_WINDOW_QUERY, stripAutoHidden, stripColumns, stripShowParts } from "./strip.js";
42
- import { applySessionEvent, heldOrder, listedSessions, sortedByUpdated, waitingBadge, waitingFirst, waitingSearchText, withChildrenAfterParents } from "./sessions_list.js";
42
+ import { applySessionEvent, heldOrder, listedSessions, sortedByUpdated, stoppedBadge, waitingBadge, waitingFirst, waitingSearchText, withChildrenAfterParents } from "./sessions_list.js";
43
43
  import { createNotifications } from "./notifications.js";
44
44
  import { restartConfirmText, versionNotice, workerBadge } from "./update_notice.js";
45
45
  import {
@@ -328,8 +328,12 @@ function cardHtml(s, { deletable = false } = {}) {
328
328
  const ctxHtml = ctx ? `<span class="ctx" title="${escapeHtml(tokensTipText(s.tokens))}">${escapeHtml(ctx)}</span>` : "";
329
329
  const waiting = waitingBadge(s);
330
330
  const waitingHtml = waiting ? `<span class="attn attn-${waiting.kind}" title="${escapeHtml(waiting.title)}">${escapeHtml(waiting.text)}</span>` : "";
331
+ // A turn a hook stopped wears its own badge, unless the session waits on
332
+ // the user (the waiting badge wins: the stop is behind the need).
333
+ const stopped = waiting ? null : stoppedBadge(s);
334
+ const stoppedHtml = stopped ? `<span class="stopped-badge" title="${escapeHtml(stopped.title)}">${escapeHtml(stopped.text)}</span>` : "";
331
335
  const dotWord = waiting ? waiting.title : s.status;
332
- return `<div class="card${s.archived ? " archived" : ""}${waiting ? " waiting" : ""}" data-id="${s.id}" title="${escapeHtml(tip)}${memTitle ? ` · ${escapeHtml(memTitle)}` : ""}"><div class="preview">${preview}</div>${s.recap ? `<div class="recap" title="${escapeHtml(s.recap)}">${escapeHtml(s.recap)}</div>` : ""}<div class="meta"><span class="status dot ${escapeHtml(s.status)}${waiting ? " waiting" : ""}" title="${escapeHtml(dotWord)}" aria-label="${escapeHtml(dotWord)}"></span>${waitingHtml}${timeHtml}${ctxHtml}${folderHtml}${parentChipHtml(s)}${ownerBadgeHtml(s)}${archivedHtml}${s.used_memory_names && s.used_memory_names.length ? `<span title="${escapeHtml(s.used_memory_names.join(", "))}">mem ${s.used_memory_names.length}</span>` : ""}</div>${deletable ? `<button type="button" class="card-del" data-del="${escapeHtml(s.id)}" title="Delete session" aria-label="Delete session ${escapeHtml(s.short_id)}">\u2715</button>` : ""}</div>`;
336
+ return `<div class="card${s.archived ? " archived" : ""}${waiting ? " waiting" : ""}" data-id="${s.id}" title="${escapeHtml(tip)}${memTitle ? ` · ${escapeHtml(memTitle)}` : ""}"><div class="preview">${preview}</div>${s.recap ? `<div class="recap" title="${escapeHtml(s.recap)}">${escapeHtml(s.recap)}</div>` : ""}<div class="meta"><span class="status dot ${escapeHtml(s.status)}${waiting ? " waiting" : ""}" title="${escapeHtml(dotWord)}" aria-label="${escapeHtml(dotWord)}"></span>${waitingHtml}${stoppedHtml}${timeHtml}${ctxHtml}${folderHtml}${parentChipHtml(s)}${ownerBadgeHtml(s)}${archivedHtml}${s.used_memory_names && s.used_memory_names.length ? `<span title="${escapeHtml(s.used_memory_names.join(", "))}">mem ${s.used_memory_names.length}</span>` : ""}</div>${deletable ? `<button type="button" class="card-del" data-del="${escapeHtml(s.id)}" title="Delete session" aria-label="Delete session ${escapeHtml(s.short_id)}">\u2715</button>` : ""}</div>`;
333
337
  }
334
338
 
335
339
  // A delegated session's `↳ <parent>` chip; the parent's preview is the tooltip.
@@ -1483,6 +1487,10 @@ function clearTimingInterval() {
1483
1487
  }
1484
1488
 
1485
1489
  let liveTurnNumber = null;
1490
+ // The live line sits in the stage's status row, which already says the turn
1491
+ // runs ("running a tool"): it drops its own "running" there. It never moves
1492
+ // mid-turn (finishLiveTurnTiming rewrites it before any hand-off).
1493
+ let liveTurnTimingInStage = false;
1486
1494
 
1487
1495
  function startLiveTurnTiming(startedAt = new Date().toISOString(), turnId = null) {
1488
1496
  clearTimingInterval();
@@ -1495,7 +1503,9 @@ function startLiveTurnTiming(startedAt = new Date().toISOString(), turnId = null
1495
1503
  liveTurnTimingEl.className = "turn-timing live";
1496
1504
  // The stage's status row while it holds the turn: the line moves into the
1497
1505
  // history with it.
1498
- (turnDom.timingHost?.() || historyEl).appendChild(liveTurnTimingEl);
1506
+ const stageHost = turnDom.timingHost?.() || null;
1507
+ liveTurnTimingInStage = !!stageHost;
1508
+ (stageHost || historyEl).appendChild(liveTurnTimingEl);
1499
1509
  updateLiveTiming();
1500
1510
  timingInterval = setInterval(updateLiveTiming, 1000);
1501
1511
  }
@@ -1509,7 +1519,7 @@ function appendToHistory(el) {
1509
1519
  function updateLiveTiming() {
1510
1520
  const duration = elapsedSince(current.timing.activeTurn?.started_at);
1511
1521
  if (liveTurnTimingEl && duration !== null) {
1512
- liveTurnTimingEl.textContent = turnTimingText(liveTurnNumber, duration, { running: true }) + (liveTurnNote ? ` · ${liveTurnNote}` : "");
1522
+ liveTurnTimingEl.textContent = turnTimingText(liveTurnNumber, duration, { running: !liveTurnTimingInStage }) + (liveTurnNote ? ` · ${liveTurnNote}` : "");
1513
1523
  }
1514
1524
  updateInfoBar();
1515
1525
  }
@@ -127,40 +127,50 @@ main{flex:1;display:flex;flex-direction:column;gap:16px;padding:16px;overflow:au
127
127
  .bubble.recap .recap-body{white-space:pre-wrap;margin-top:6px}
128
128
  .bubble.recap.stale{opacity:.7;border-left-style:dashed}
129
129
  .activity-body{margin-top:6px}
130
- .activity-row{display:flex;gap:8px;align-items:baseline;font-size:13px;color:var(--text-2);padding:5px 2px;border-top:1px solid var(--border)}
130
+ .activity-row{--row-gutter:22px;display:flex;gap:8px;align-items:baseline;font-size:13px;color:var(--text-2);padding:5px 2px;border-top:1px solid var(--border)}
131
131
  .activity-row:first-child{border-top:none}
132
- .activity-status{font-size:11px;min-width:52px;text-transform:uppercase;letter-spacing:.03em;font-weight:600}
133
- .activity-status.running{color:var(--warn)}
134
- .activity-status.ok{color:var(--ok)}
135
- .activity-status.error{color:var(--danger)}
136
- .activity-status.stopped{color:var(--warn-text);opacity:.8}
137
- .activity-status.blocked{color:var(--danger-text)}
132
+ /* A row's status is a mark in a 14 px slot, drawn from its class (no text: a copy or an annotate quote gets
133
+ no glyph; the word is its aria-label and title). The glyphs the stage already uses: done ✓, error and
134
+ blocked ✕, stopped ■, running its border spinner (still once the turn is over: a dead worker's row).
135
+ An exception also shows its word after the title (.activity-state). */
136
+ .activity-status{width:14px;text-align:center;font-size:12px;font-weight:700}
137
+ .activity-status.ok::before{content:"\2713";opacity:.85}
138
+ .activity-status.error::before,.activity-status.blocked::before{content:"\2715"}
139
+ .activity-status.stopped::before{content:"\25A0";font-size:10px}
140
+ .activity-status.running::before{content:"";display:inline-block;box-sizing:border-box;width:10px;height:10px;vertical-align:-1px;border:1.5px solid currentColor;border-right-color:transparent;border-radius:50%}
141
+ .turn-work:not(.done) .activity-status.running::before{animation:init-spin .8s linear infinite}
142
+ .activity-state{flex:none;white-space:nowrap;font-size:11px;text-transform:uppercase;letter-spacing:.03em;font-weight:600}
143
+ :is(.activity-status,.activity-state).running{color:var(--warn)}
144
+ :is(.activity-status,.activity-state).ok{color:var(--ok)}
145
+ :is(.activity-status,.activity-state).error{color:var(--danger)}
146
+ :is(.activity-status,.activity-state).stopped{color:var(--warn-text);opacity:.8}
147
+ :is(.activity-status,.activity-state).blocked{color:var(--danger-text)}
138
148
  .activity-tool{color:var(--accent);font-weight:600}
139
149
  /* The stop-task button on a running task_wait (task_stop.js): a small ghost
140
150
  button in the danger colours, at the row's end and in the stage's now slot. */
141
151
  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
152
  button.task-stop:hover:not(:disabled){background:var(--danger-bg-dim);filter:none}
143
153
  .ts-now button.task-stop{align-self:center}
144
- .activity-params{color:var(--text-muted);word-break:break-all}
154
+ .activity-params{color:var(--text-muted);overflow-wrap:anywhere}
145
155
  .activity-duration{color:var(--text-faint);font-size:11px;font-variant-numeric:tabular-nums;white-space:nowrap}
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}
156
+ .activity-output{margin:4px 0 2px var(--row-gutter);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}
147
157
  /* A row's status and tool name never wrap (a phone broke them letter by
148
158
  letter); the params take what is left. The output goes on its own line
149
159
  under the call instead of a column beside it: on a desktop it lines up
150
- under the tool name (the status is 52 px + the 8 px gap), on a phone it
151
- takes the full width and the params break inside the rest of the first
152
- line rather than drop to a line of their own. */
160
+ under the tool name (--row-gutter: the 14 px mark slot + the 8 px gap), on
161
+ a phone it takes the full width and the params break inside the rest of
162
+ the first line rather than drop to a line of their own. */
153
163
  .activity-row{flex-wrap:wrap}
154
164
  .activity-status,.activity-tool{flex-shrink:0;white-space:nowrap}
155
165
  .activity-params{min-width:0}
156
166
  .activity-output{flex:1 1 100%;min-width:0}
157
167
  /* An edit/write's change: a closed "diff +3 −1" under the output, lined up with it. */
158
- .activity-diff{flex:1 1 100%;min-width:0;margin:2px 0 2px 60px}
168
+ .activity-diff{flex:1 1 100%;min-width:0;margin:2px 0 2px var(--row-gutter)}
159
169
  .activity-diff>summary{cursor:pointer;font-size:12px;color:var(--text-muted);width:max-content;font-variant-numeric:tabular-nums}
160
170
  .activity-diff>summary:hover{color:var(--text-2)}
161
171
  .activity-diff[open]>summary{margin-bottom:4px}
162
172
  /* An execute/task_create's full command, above its output and lined up with it: as written, its own scroll. */
163
- .activity-row>.activity-command{flex:1 1 100%;min-width:0;margin:4px 0 2px 60px}
173
+ .activity-row>.activity-command{flex:1 1 100%;min-width:0;margin:4px 0 2px var(--row-gutter)}
164
174
  .activity-command pre{margin:0;max-height:160px;overflow:auto;white-space:pre-wrap;word-break:break-word;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px;line-height:1.45;color:var(--text-2);background:var(--bg-inset);border:1px solid var(--border);border-radius:6px;padding:6px 34px 6px 8px}
165
175
  .activity-command-cwd,.activity-command-cut{font-size:11px;color:var(--text-faint)}
166
176
  .activity-command-cwd{margin-bottom:2px}
@@ -218,12 +228,17 @@ button.task-stop:hover:not(:disabled){background:var(--danger-bg-dim);filter:non
218
228
  .turn-work:not([open]){padding:3px 10px;background:transparent;border-color:transparent;border-left-color:var(--accent);align-self:flex-start;border-radius:6px;transition:background .12s ease}
219
229
  .turn-work:not([open]):hover{background:var(--bg-panel)}
220
230
  .turn-work:not([open])>summary{font-size:12px;font-weight:500}
221
- .gen{margin-top:8px;padding-top:6px;border-top:1px solid var(--border)}
222
- .gen>summary{cursor:pointer;color:var(--text-muted);font-size:12px;font-weight:500;user-select:none;list-style:none;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
231
+ /* A step's body (thinking, narration, rows, cards) sits --step-indent right of its summary, so the thinking
232
+ chevron and the row marks share one column under the step's own chevron. The summary takes it back with a
233
+ negative margin: a child's own margin shorthand can't undo the padding. */
234
+ .gen{--step-indent:14px;margin-top:8px;padding-top:6px;padding-left:var(--step-indent);border-top:1px solid var(--border)}
235
+ .gen>summary{margin-left:calc(-1 * var(--step-indent));cursor:pointer;color:var(--text-muted);font-size:12px;font-weight:500;user-select:none;list-style:none;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
223
236
  .gen>summary::-webkit-details-marker{display:none}
224
237
  .gen>summary::before{content:"▸ ";color:var(--text-faint)}
225
238
  .gen[open]>summary::before{content:"▾ "}
226
- .gen.live{border-top-color:transparent;background:var(--accent-tint);border-radius:8px;padding:6px 9px 8px;margin-left:-9px;margin-right:-9px}
239
+ /* A step with a failed call (status error): its chevron in the danger colour. */
240
+ .gen.has-failed>summary::before{color:var(--danger)}
241
+ .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}
227
242
  .gen.live>summary{color:var(--accent);white-space:normal}
228
243
  /* The live step's marker: a pulsing accent dot in place of the chevron (the block's own left bar stays the only one). */
229
244
  .gen.live>summary::before{content:"";display:inline-block;width:7px;height:7px;margin:0 8px 1px 2px;border-radius:50%;background:var(--accent);vertical-align:middle;animation:step-pulse 1.6s ease-in-out infinite}
@@ -256,6 +271,7 @@ button.task-stop:hover:not(:disabled){background:var(--danger-bg-dim);filter:non
256
271
  .turn-work:not(:has(.gen~.gen,.thinking-body,.activity-body))>summary,.turn-work:not(:has(.gen~.gen,.thinking-body,.activity-body)) .gen>summary{display:none}
257
272
  .turn-work:not(:has(.gen~.gen,.thinking-body,.activity-body)) .gen{margin:0;padding:0;border:0;background:transparent}
258
273
  .turn-work:not(:has(.gen~.gen,.thinking-body,.activity-body)) .gen-text{margin:0;background:var(--bg-panel);border:1px solid var(--border);border-left:3px solid var(--accent);border-radius:12px;padding:12px 14px;opacity:.95}
274
+ @media(max-width:600px){.gen{--step-indent:8px}}
259
275
  /* A question card asks for input like the composer card, so it wears the same glass (the recipe above); only its warm tint is its own. */
260
276
  .bubble.question{border-left:3px solid var(--warn);background:color-mix(in srgb,var(--warn) 7%,var(--glass-bg));padding:10px 12px}
261
277
  /* Collapsed (resolved) question: one line, like the thinking and activity blocks.
@@ -432,7 +448,8 @@ body.has-session #history{padding-bottom:calc(var(--dock-h,160px) + 32px)}
432
448
  .chip .chip-remove{background:transparent;color:var(--text-3);padding:0 6px;min-height:0;font-size:14px}
433
449
  .thumbs{display:flex;flex-wrap:wrap;gap:6px;margin-top:6px}
434
450
  .thumb{max-height:120px;max-width:200px;border-radius:6px;border:1px solid var(--border-strong);cursor:zoom-in;object-fit:contain;background:var(--bg)}
435
- .activity-row .thumbs{flex-basis:100%}
451
+ .activity-row .thumbs{flex-basis:100%;margin-left:var(--row-gutter)}
452
+ @media(max-width:600px){.activity-row .thumbs{margin-left:0}}
436
453
  #lightbox{position:fixed;inset:0;z-index:50;background:var(--backdrop);display:flex;align-items:center;justify-content:center;cursor:zoom-out}
437
454
  #lightbox[hidden]{display:none}
438
455
  #lightbox img{max-width:96vw;max-height:94vh;border-radius:6px}
@@ -617,7 +634,7 @@ body.stage-view #dock,body.stage-view #dock:focus-within{background:transparent;
617
634
  .ts-defer{display:none}
618
635
  .ts-trail-item{max-width:24ch}
619
636
  }
620
- @media (prefers-reduced-motion:reduce){.turn-stage.enter,.ts-arrive,.ts-headline.fresh,.ts-tick.running,.ts-mark{animation:none}.turn-stage.leaving{transition:none}}
637
+ @media (prefers-reduced-motion:reduce){.turn-stage.enter,.ts-arrive,.ts-headline.fresh,.ts-tick.running,.ts-mark,.turn-work:not(.done) .activity-status.running::before{animation:none}.turn-stage.leaving{transition:none}}
621
638
  #infoBar{display:flex;padding:8px 14px 10px;border-top:1px solid var(--glass-border);background:transparent;border-radius:0 0 16px 16px;color:var(--text-2);gap:10px;align-items:flex-start;flex-wrap:wrap;flex-shrink:0;transition:opacity 280ms ease}
622
639
  #infoBar.visible{display:flex}
623
640
  #infoBar .info{flex:1;min-width:0;font-size:13px;line-height:1.4;word-break:break-word;display:flex;flex-direction:column;gap:2px}
@@ -809,6 +826,7 @@ body.all-view #center, body.all-view #topStrip{display:none}
809
826
  @keyframes attn-pulse{0%,100%{box-shadow:0 0 0 0 color-mix(in srgb,var(--warn) 60%,transparent)}50%{box-shadow:0 0 0 4px color-mix(in srgb,var(--warn) 0%,transparent)}}
810
827
  @media (prefers-reduced-motion:reduce){.card .dot.running,.card .dot.waiting{animation:none}}
811
828
  .card .attn{font-size:11px;font-weight:600;padding:1px 7px;border-radius:999px;white-space:nowrap;flex-shrink:0;background:var(--warn-bg);color:var(--warn-text);border:1px solid var(--warn-border)}
829
+ .card .stopped-badge{font-size:11px;font-weight:600;padding:1px 7px;border-radius:999px;white-space:nowrap;flex-shrink:0;background:var(--warn);color:var(--bg)}
812
830
  .status.running{background:var(--ok-bg);color:var(--ok-text)}
813
831
  .status.idle{background:var(--bg-raised)}
814
832
  .status.completed{background:var(--accent-bg);color:var(--accent)}
@@ -89,12 +89,29 @@ export function waitingBadge(summary) {
89
89
  return { kind: waiting.reason, ...WAITING_BADGES[waiting.reason] };
90
90
  }
91
91
 
92
- // What the all view's search matches for a session that waits on the user:
93
- // "waiting" and its badge's word ("question", "approval", "needs you");
94
- // "" for one that doesn't.
92
+ // A card's badge for a last turn a hook stopped (the same marker as
93
+ // `chi sessions list`'s "[looped]" / "[stopped by X]"): "looped" for
94
+ // loop-guard, "stopped by <name>" for any other hook; null when the last
95
+ // turn ended otherwise.
96
+ // @return null | {text, title}
97
+ export function stoppedBadge(summary) {
98
+ const by = summary?.last_turn?.stopped_by;
99
+ if (!by) return null;
100
+ return by === "loop-guard"
101
+ ? { text: "looped", title: "loop-guard stopped the last turn" }
102
+ : { text: `stopped by ${by}`, title: `${by} stopped the last turn` };
103
+ }
104
+
105
+ // What the all view's search matches for a session: its waiting badge's words
106
+ // ("waiting", "question", "approval", …) and/or its stopped badge's words
107
+ // ("looped", "stopped by <name>"); "" for one with neither.
95
108
  export function waitingSearchText(summary) {
109
+ const parts = [];
96
110
  const badge = waitingBadge(summary);
97
- return badge ? `waiting ${badge.text}` : "";
111
+ if (badge) parts.push(`waiting ${badge.text}`);
112
+ const stopped = stoppedBadge(summary);
113
+ if (stopped) parts.push(stopped.text);
114
+ return parts.join(" ");
98
115
  }
99
116
 
100
117
  // The sessions that wait on the user (waitingOn) first, the rest after,
@@ -11,6 +11,7 @@ const READ_LIKE = new Set(["read", "memory_read", "web_fetch", "task_get", "task
11
11
  const EDITS = new Set(["edit", "write", "memory_write"]);
12
12
  const EXECS = new Set(["execute", "task_create", "task_wait", "task_stop", "delegate", "delegate_result"]);
13
13
  const FILE_TOOLS = new Set(["read", "edit", "write"]);
14
+ const TASK_TOOLS = new Set(["task_wait", "task_get", "task_stop"]);
14
15
  const TRAIL = 3;
15
16
  // The hand-off waits this long after the stage was last in use.
16
17
  export const QUIET_MS = 1500;
@@ -80,9 +81,11 @@ function rowTitle(row) {
80
81
  }
81
82
 
82
83
  // A slot's full command (the row's view), for its hover; none for a tool
83
- // without one.
84
+ // without one. A task tool titled by its task's command (the server's
85
+ // title) hovers its params instead: the id line it replaced.
84
86
  function withCommand(slot, row) {
85
- return row.view?.command ? { ...slot, command: row.view.command } : slot;
87
+ if (row.view?.command) return { ...slot, command: row.view.command };
88
+ return TASK_TOOLS.has(row.tool) && row.title && row.params ? { ...slot, command: row.params } : slot;
86
89
  }
87
90
 
88
91
  // A running task_wait's slot: the task it waits on and the row's key, for
@@ -159,13 +159,22 @@ function cut(line) {
159
159
  return line.length > LABEL_MAX ? `${line.slice(0, LABEL_MAX).trimEnd()}…` : line;
160
160
  }
161
161
 
162
- function toolCallsText(n) {
163
- return `${n} tool call${n === 1 ? "" : "s"}`;
162
+ // "3 tool calls (1 failed)": failed = status error, the tally's predicate
163
+ // (tally.js), so a step's label and the block's summary never disagree.
164
+ function toolCallsText(rows) {
165
+ const n = rows.length;
166
+ const failed = failedCount(rows);
167
+ return `${n} tool call${n === 1 ? "" : "s"}${failed ? ` (${failed} failed)` : ""}`;
168
+ }
169
+
170
+ // A gen's calls that failed (status error; blocked and stopped don't count).
171
+ export function failedCount(rows) {
172
+ return rows.filter((r) => r.status === "error").length;
164
173
  }
165
174
 
166
175
  // The collapsed row of a gen: its narration's first line, else its first
167
176
  // call's title ("edit lib/a.rb"), else the tools it worked with, else
168
- // "thinking"; then its call count.
177
+ // "thinking"; then its call count and how many failed.
169
178
  export function genLabel(gen) {
170
179
  const line = String(gen.text || "").trim().split("\n")[0].trim();
171
180
  const calls = gen.tools.length;
@@ -181,7 +190,7 @@ export function genLabel(gen) {
181
190
  } else {
182
191
  return "thinking";
183
192
  }
184
- return calls ? `${head} · ${toolCallsText(calls)}` : head;
193
+ return calls ? `${head} · ${toolCallsText(gen.tools)}` : head;
185
194
  }
186
195
 
187
196
  // The block's summary line: "working · 3 steps · 8 tool calls · execute ×7 · …"
@@ -192,7 +201,7 @@ export function blockSummary(turn, { running = false } = {}) {
192
201
  const fields = [`${steps} step${steps === 1 ? "" : "s"}`];
193
202
  const tally = tallyText(rows, { last: false });
194
203
  if (tally) fields.push(tally);
195
- else if (rows.length) fields.push(toolCallsText(rows.length));
204
+ else if (rows.length) fields.push(toolCallsText(rows));
196
205
  if (running) fields.unshift("working");
197
206
  return fields.join(" · ");
198
207
  }
@@ -30,19 +30,44 @@ import { taskCommand, taskStopButton } from "./task_stop.js";
30
30
  // denied it), else ok ("done").
31
31
  const NAMED_STATUSES = ["running", "error", "stopped", "blocked"];
32
32
 
33
- function activityStatusClass(status) {
34
- return NAMED_STATUSES.includes(status) ? status : "ok";
33
+ // A row's status as its parts: the mark's class (CSS draws the glyph), its
34
+ // label (aria-label and title) and the word an exception shows after the
35
+ // title (empty for done and running). Live and reload share it.
36
+ export function statusParts(status) {
37
+ const named = NAMED_STATUSES.includes(status);
38
+ const cls = named ? status : "ok";
39
+ const label = named ? status : "done";
40
+ return { cls, label, word: cls === "ok" || cls === "running" ? "" : label };
35
41
  }
36
42
 
37
- function activityStatusLabel(status) {
38
- return NAMED_STATUSES.includes(status) ? status : "done";
43
+ function statusMarkHtml({ cls, label }) {
44
+ return `<span class="activity-status ${cls}" role="img" aria-label="${label}" title="${label}"></span>`;
45
+ }
46
+
47
+ function statusWordHtml({ cls, word }) {
48
+ return word ? `<span class="activity-state ${cls}">${word}</span>` : "";
39
49
  }
40
50
 
41
51
  // A live tool row filled from its activity row (status, title, output).
42
52
  function fillActivityRow(el, row, sessionId) {
53
+ const parts = statusParts(row.status);
43
54
  const statusEl = el.querySelector(".activity-status");
44
- statusEl.className = `activity-status ${activityStatusClass(row.status)}`;
45
- statusEl.textContent = activityStatusLabel(row.status);
55
+ statusEl.className = `activity-status ${parts.cls}`;
56
+ statusEl.setAttribute("role", "img");
57
+ statusEl.setAttribute("aria-label", parts.label);
58
+ statusEl.title = parts.label;
59
+ // The exception's word after the title (reloadRowHtml's place); never
60
+ // beside a running task_wait's stop button, which needs "running".
61
+ let wordEl = el.querySelector(":scope > .activity-state");
62
+ if (!parts.word) wordEl?.remove();
63
+ else {
64
+ if (!wordEl) {
65
+ wordEl = document.createElement("span");
66
+ (el.querySelector(":scope > .activity-params") || el.querySelector(":scope > .activity-tool")).after(wordEl);
67
+ }
68
+ wordEl.className = `activity-state ${parts.cls}`;
69
+ wordEl.textContent = parts.word;
70
+ }
46
71
  const toolEl = el.querySelector(".activity-tool");
47
72
  if (toolEl) toolEl.textContent = toolName(row);
48
73
  const paramsEl = el.querySelector(".activity-params");
@@ -363,13 +388,21 @@ export function createTurnView({
363
388
  // answer, which reuses the wrap).
364
389
  if (!gen.text && !gen.tools.length && turn.gens.length > 1) unwrapThinking(entry);
365
390
  }
366
- entry.summary.textContent = model.genLabel(gen);
391
+ writeClosedLabel(entry, gen);
367
392
  if (!entry.toggled) entry.el.open = false;
368
393
  // Every generation's text goes into seenContent (the turn output dedupe).
369
394
  const k = normalize(gen.text);
370
395
  if (k) sawText(k);
371
396
  }
372
397
 
398
+ // A closed step's label and its failed mark (a red chevron that survives
399
+ // an ellipsed label): at close, and again when a late tool result lands
400
+ // in a step that already closed.
401
+ function writeClosedLabel(entry, gen) {
402
+ entry.summary.textContent = model.genLabel(gen);
403
+ entry.el.classList.toggle("has-failed", model.failedCount(gen.tools) > 0);
404
+ }
405
+
373
406
  // A step that closes mid-turn (the next one starts) collapses: a warn card
374
407
  // in it would go out of sight until the turn ends. It moves out of the
375
408
  // step, right after it in the block (open while the turn runs), where
@@ -417,6 +450,7 @@ export function createTurnView({
417
450
  fillActivityRow(el, r.row, sessionId());
418
451
  syncTaskStop(el, r.row);
419
452
  }
453
+ if (!entry.el.classList.contains("live")) writeClosedLabel(entry, r.gen);
420
454
  updateBlockSummary();
421
455
  onChange();
422
456
  }
@@ -712,8 +746,9 @@ function reloadRowHtml(r, thumbs) {
712
746
  const output = full
713
747
  ? `<div class="activity-output" title="${escapeHtml(full)}">${escapeHtml(full.length > 300 ? `${full.slice(0, 300)}…` : full)}</div>`
714
748
  : "";
715
- return `<div class="activity-row" data-key="${escapeHtml(r.key)}"><span class="activity-status ${activityStatusClass(r.status)}">${activityStatusLabel(r.status)}</span>` +
716
- `<span class="activity-tool">${escapeHtml(toolName(r))}</span>${params}${duration ? `<span class="activity-duration">${escapeHtml(duration)}</span>` : ""}` +
749
+ const status = statusParts(r.status);
750
+ return `<div class="activity-row" data-key="${escapeHtml(r.key)}">${statusMarkHtml(status)}` +
751
+ `<span class="activity-tool">${escapeHtml(toolName(r))}</span>${params}${statusWordHtml(status)}${duration ? `<span class="activity-duration">${escapeHtml(duration)}</span>` : ""}` +
717
752
  `${commandBlockHtml(r.view, r.title)}${output}` +
718
753
  `${r.images?.length ? thumbs(r.images) : ""}${r.diff ? activityDiffHtml(r.diff) : ""}</div>`;
719
754
  }
@@ -775,7 +810,8 @@ export function turnHistoryHtml(items, timing, { thumbs }) {
775
810
  // The steers the step answered first, where the live rows went.
776
811
  const steers = (group.steps[k].steers || []).map((i) => steerHtml(i, false)).join("");
777
812
  const tools = gen.tools.length ? `<div class="activity-body">${gen.tools.map((r) => reloadRowHtml(r, thumbs)).join("")}</div>` : "";
778
- return `<details class="gen"><summary>${escapeHtml(model.genLabel(gen))}</summary>${steers}${thinking}${text}${tools}</details>`;
813
+ const failed = model.failedCount(gen.tools) ? " has-failed" : "";
814
+ return `<details class="gen${failed}"><summary>${escapeHtml(model.genLabel(gen))}</summary>${steers}${thinking}${text}${tools}</details>`;
779
815
  });
780
816
  parts.push(`<details class="turn-work done"><summary>${escapeHtml(model.blockSummary({ gens, ended: true }))}</summary>${rows.join("")}</details>`);
781
817
  }
@@ -515,7 +515,16 @@ module Samagotchi
515
515
  unless response.nil? || response.strip.empty? || @continue_offer.awaiting?
516
516
  SessionInbox.write_output(@session_dir, response)
517
517
  end
518
+ save_after_turn
519
+ end
520
+
521
+ # The turn's own save: the turn ended fine and its messages are in
522
+ # memory, so a save that fails here (disk full, permissions; the next
523
+ # save writes them) must not take the worker down with it.
524
+ def save_after_turn
518
525
  save_session
526
+ rescue SystemCallError, IOError => e
527
+ Log.exception(:worker, "save_failed", e)
519
528
  end
520
529
 
521
530
  # Back to the conversation before the failed turn, as the REPL does (so
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.28.0
4
+ version: 0.29.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dmitry Dedov