samagotchi 0.70.0 → 0.72.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +83 -1
  3. data/docs/architecture.md +16 -10
  4. data/docs/cli.md +7 -4
  5. data/docs/configuration.md +24 -14
  6. data/docs/hooks.md +1 -1
  7. data/docs/internals/anthropic.md +1 -1
  8. data/docs/internals/background-tasks.md +2 -2
  9. data/docs/internals/engine.md +38 -13
  10. data/docs/internals/llm-context-live.md +1 -1
  11. data/docs/internals/prompt-caching.md +6 -0
  12. data/docs/plugins.md +18 -7
  13. data/docs/sessions.md +4 -2
  14. data/docs/testing.md +12 -3
  15. data/lib/samagotchi/bootstrap/config_writer.rb +2 -3
  16. data/lib/samagotchi/bridge/turn_accumulator.rb +4 -1
  17. data/lib/samagotchi/bridge.rb +5 -2
  18. data/lib/samagotchi/broadcast/scope_card.rb +2 -2
  19. data/lib/samagotchi/bundles/claude-peer/manifest.yml +2 -7
  20. data/lib/samagotchi/bundles/claude-peer/plugin.rb +2 -2
  21. data/lib/samagotchi/bundles/codex-peer/manifest.yml +2 -6
  22. data/lib/samagotchi/bundles/codex-peer/plugin.rb +2 -2
  23. data/lib/samagotchi/bundles/coordinator/manifest.yml +8 -6
  24. data/lib/samagotchi/bundles/coordinator/plugin.rb +10 -0
  25. data/lib/samagotchi/bundles/coordinator/skill_coordinator.md +2 -1
  26. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  27. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  28. data/lib/samagotchi/config.rb +7 -25
  29. data/lib/samagotchi/context_note.rb +2 -6
  30. data/lib/samagotchi/effective_model.rb +1 -1
  31. data/lib/samagotchi/engine.rb +91 -213
  32. data/lib/samagotchi/engine_peers.rb +2 -0
  33. data/lib/samagotchi/event_routes.rb +83 -0
  34. data/lib/samagotchi/hook_routes.rb +16 -37
  35. data/lib/samagotchi/host_config.rb +1 -1
  36. data/lib/samagotchi/host_keys.rb +0 -2
  37. data/lib/samagotchi/paths.rb +13 -0
  38. data/lib/samagotchi/plugin/api.rb +11 -5
  39. data/lib/samagotchi/plugin/peer.rb +5 -8
  40. data/lib/samagotchi/prompt_files.rb +2 -4
  41. data/lib/samagotchi/session.rb +2 -1
  42. data/lib/samagotchi/session_manager.rb +6 -16
  43. data/lib/samagotchi/session_model_settings.rb +227 -0
  44. data/lib/samagotchi/session_setup.rb +2 -1
  45. data/lib/samagotchi/sessions_command.rb +12 -3
  46. data/lib/samagotchi/system_prompt.rb +18 -13
  47. data/lib/samagotchi/terminal_ui/attached_loop.rb +36 -15
  48. data/lib/samagotchi/terminal_ui/formatting.rb +4 -0
  49. data/lib/samagotchi/tool_activity.rb +20 -1
  50. data/lib/samagotchi/tool_declarations.rb +4 -0
  51. data/lib/samagotchi/tool_output_cut.rb +11 -2
  52. data/lib/samagotchi/toolkit.rb +60 -22
  53. data/lib/samagotchi/toolkit_binding.rb +7 -5
  54. data/lib/samagotchi/toolkit_command.rb +28 -5
  55. data/lib/samagotchi/tools/builtins.rb +2 -1
  56. data/lib/samagotchi/tools/delegate.rb +32 -6
  57. data/lib/samagotchi/tools/list_sessions.rb +2 -2
  58. data/lib/samagotchi/tools/peers.rb +5 -2
  59. data/lib/samagotchi/tools/register_reminder.rb +1 -0
  60. data/lib/samagotchi/tools/registry.rb +13 -2
  61. data/lib/samagotchi/tools/task_wait.rb +4 -6
  62. data/lib/samagotchi/tools/tool_index.rb +15 -4
  63. data/lib/samagotchi/turn_note.rb +4 -1
  64. data/lib/samagotchi/version.rb +1 -1
  65. data/lib/samagotchi/web/app.rb +18 -17
  66. data/lib/samagotchi/web/message_parts.rb +1 -1
  67. data/lib/samagotchi/web/public/answer_refs.js +1 -0
  68. data/lib/samagotchi/web/public/app.js +20 -18
  69. data/lib/samagotchi/web/public/bubbles.js +1 -1
  70. data/lib/samagotchi/web/public/cards_place.js +6 -10
  71. data/lib/samagotchi/web/public/chains.js +1 -1
  72. data/lib/samagotchi/web/public/chip_popover.js +3 -1
  73. data/lib/samagotchi/web/public/composer.js +13 -6
  74. data/lib/samagotchi/web/public/context_chips.js +25 -12
  75. data/lib/samagotchi/web/public/data.js +5 -1
  76. data/lib/samagotchi/web/public/format.js +5 -0
  77. data/lib/samagotchi/web/public/index.html +1 -0
  78. data/lib/samagotchi/web/public/sessions_list.js +2 -3
  79. data/lib/samagotchi/web/public/stage_frame.js +1 -1
  80. data/lib/samagotchi/web/public/stage_view.js +8 -2
  81. data/lib/samagotchi/web/public/stream_handlers.js +6 -3
  82. data/lib/samagotchi/web/public/timing.js +1 -1
  83. data/lib/samagotchi/web/public/toolkit_chip.js +3 -1
  84. data/lib/samagotchi/web/public/turn_html.js +55 -3
  85. data/lib/samagotchi/web/session_hub.rb +8 -0
  86. metadata +3 -1
@@ -46,6 +46,7 @@ require_relative "editor"
46
46
  require_relative "message_parts"
47
47
  require_relative "session_summary"
48
48
  require_relative "../log"
49
+ require_relative "../paths"
49
50
  require_relative "../redacted_inspect"
50
51
 
51
52
  module Samagotchi
@@ -896,9 +897,9 @@ module Samagotchi
896
897
  # session part is what the page reads of it (id, status, used memory
897
898
  # names, the model notes its prompt carried: the first turn's build or
898
899
  # a /model records them). ?turn_id=: that turn's answer (a queued
899
- # turn's page re-reads after the next one started). ?recent=1 (the page since this
900
- # release): the timing's records are the newest turn's (#recent_timing),
901
- # which the page merges by id; without it, the whole timing as before.
900
+ # turn's page re-reads after the next one started). The timing's records
901
+ # are the newest turn's (#recent_timing), which the page merges by id
902
+ # (the page still sends ?recent=1, which is accepted and not needed).
902
903
  def handle_tail_read(req, id)
903
904
  return error_response(404, "not_found", "Session not found: #{id}") unless session_exists?(id)
904
905
 
@@ -909,7 +910,14 @@ module Samagotchi
909
910
  state = nil unless state.is_a?(Hash)
910
911
  if state
911
912
  status, used, notes = state.values_at("status", "used_memory_names", "prompt_notes")
912
- messages = last_assistant_for_display([tail["answer"]].compact)
913
+ # No session file read here: the folder the answer's file refs must
914
+ # stay under is the worker's (its tail's working_directory), else the
915
+ # hub's session list entry's (a worker from before the field, or read
916
+ # through its snapshot); nil then without a hub or before it saw the
917
+ # session: refs aren't checked.
918
+ cwd = tail["working_directory"]
919
+ cwd = @hub&.working_directory(id) unless cwd.is_a?(String) && !cwd.empty?
920
+ messages = last_assistant_for_display([tail["answer"]].compact, cwd: cwd)
913
921
  else
914
922
  session = @session_class.load(id, state_dir: default_state_dir)
915
923
  status = displayed_status(session)
@@ -919,9 +927,7 @@ module Samagotchi
919
927
  end
920
928
  session_json = { id: id, status: status, used_memory_names: Array(used), prompt_notes: Array(notes) }
921
929
  live_metrics = state && state["metrics"]
922
- # Without recent=1: a tab opened before this release (it replaces its
923
- # timing with the reply's). Remove this branch after the next release.
924
- timing = req.params["recent"] == "1" ? recent_timing(id, live_metrics) : timing_payload(id, live_metrics: live_metrics)
930
+ timing = recent_timing(id, live_metrics)
925
931
  json_response(200, {
926
932
  tail: true,
927
933
  session: session_json,
@@ -951,9 +957,9 @@ module Samagotchi
951
957
  end
952
958
 
953
959
  # The worker's light read (Bridge GET tail): {"session_state_snapshot",
954
- # "answer", "cards", "event_seq", "event_id"}. A worker from before the
955
- # route answers 404 not_found: its GET snapshot, in the same shape (the
956
- # answer by AnswerTail). Anything else (no worker, a refused connect, a
960
+ # "working_directory", "answer", "cards", "event_seq", "event_id"}. A
961
+ # worker from before the route answers 404 not_found: its GET snapshot,
962
+ # in the same shape (the answer by AnswerTail; no working_directory). Anything else (no worker, a refused connect, a
957
963
  # 500, a timeout) is nil: the caller reads the disk, with no second
958
964
  # try at a worker that failed once.
959
965
  # @return [Hash, nil]
@@ -1703,7 +1709,7 @@ module Samagotchi
1703
1709
  turn_count: (live && turn_count(live_metrics)) || turns.size)
1704
1710
  end
1705
1711
 
1706
- # The timing a ?tail=1&recent=1 read sends: a live worker's recent
1712
+ # The timing a ?tail=1 read sends: a live worker's recent
1707
1713
  # records as its metrics hold them (the newest finished turn's and
1708
1714
  # the unsaved ones, the running turn's finished calls), no
1709
1715
  # analytics.json read; with no worker the disk's, trimmed to the
@@ -2000,12 +2006,7 @@ module Samagotchi
2000
2006
  home_label(File.expand_path(default_state_dir))
2001
2007
  end
2002
2008
 
2003
- def home_label(dir)
2004
- home = Dir.home
2005
- return "~" if dir == home
2006
-
2007
- dir.start_with?("#{home}/") ? "~#{dir.delete_prefix(home)}" : dir
2008
- end
2009
+ def home_label(dir) = Paths.tilde(dir)
2009
2010
 
2010
2011
  # <body> data for the page: where sessions are stored, where an
2011
2012
  # all-view new chat starts (the server's cwd), and, when ?dir names a
@@ -53,7 +53,7 @@ module Samagotchi
53
53
  # The row shows 300 chars and the rest on hover; a read of a big file
54
54
  # would otherwise ride along with every reload. A longer one keeps its
55
55
  # head and tail (ToolOutputCut.preview).
56
- OUTPUT_MAX = 2000
56
+ OUTPUT_MAX = ToolOutputCut::WEB_PREVIEW_CHARS
57
57
 
58
58
  QWEN_THINK = %r{<think>(.*?)</think>}m
59
59
  QWEN_OPEN_THINK = %r{\A(.*?)</think>}m # the template opened the block
@@ -67,6 +67,7 @@ export function applyFinalAnswer(messages, bubbles, normalize) {
67
67
  if (typeof message.html === "string") {
68
68
  bubble.classList.add("markdown");
69
69
  bubble.setAttribute(COPY_SOURCE_ATTR, copySource(message));
70
+ // The server's rendered markdown, sanitized there (Web::MarkdownRenderer#sanitize): set as it is, not templated.
70
71
  bubble.innerHTML = message.html;
71
72
  }
72
73
  setAnswerRefs(bubble, message.display_refs);
@@ -38,12 +38,13 @@ import { MODEL_KEY } from "./model_pick.js";
38
38
  import { emptySessionState, sessionStateFrom } from "./session_state.js";
39
39
  import { createModelPicker } from "./model_picker.js";
40
40
  import { chipName, chipsTemplate, imageFiles, restoredChips, thumbsTemplate, turnRefs, turnText, uploadChips } from "./images.js";
41
- import { normalize, firstPreview, failedTurnText, deleteConfirmText, goneSessionNotice, withLiveStatus, withoutWorker } from "./format.js";
41
+ import { normalize, firstPreview, failedTurnText, deleteConfirmText, goneSessionNotice, cantText, withLiveStatus, withoutWorker } from "./format.js";
42
42
  import { createTurnView, turnHistoryNodes } from "./turn_view.js";
43
43
  import { elementOf, emptyAnswerTemplate } from "./turn_html.js";
44
44
  import { createBubbles, hintEl, outputBubbleTemplate, setBubbleDelivery } from "./bubbles.js";
45
45
  import { createCardsPlace } from "./cards_place.js";
46
46
  import { createStageView, FOLD_MS } from "./stage_view.js";
47
+ import { reducedMotion, STAGE_GAP } from "./stage_frame.js";
47
48
  import { CONTINUE_OPENER, chainNeighbours, isOpener, continueRefusal, continuedText, freshLink, openChildrenTemplate } from "./chains.js";
48
49
  import { createTaskStopper } from "./task_stop.js";
49
50
  import { appendQuote } from "./annotations.js";
@@ -313,8 +314,6 @@ const {
313
314
  // The plugin cards and a snapshot's notices in the history (cards_place.js).
314
315
  const cards = createCardsPlace({
315
316
  historyEl,
316
- appendToHistory: (el) => appendToHistory(el),
317
- isNearBottom: () => isNearBottom(),
318
317
  turnDom,
319
318
  findInTurnRoots: (selector) => findInTurnRoots(selector),
320
319
  selected: () => selected,
@@ -1063,7 +1062,7 @@ async function restartWorker() {
1063
1062
  const result = await restartSession(id);
1064
1063
  showToast(result.version ? `Restarted on chi ${result.version}` : "Restarting: the new worker is starting");
1065
1064
  } catch (e) {
1066
- showToast(`Can't restart: ${String(e.message).replace(/ \(\d+\)$/, "")}`, null, { sticky: true });
1065
+ showToast(cantText("restart", e), null, { sticky: true });
1067
1066
  } finally {
1068
1067
  restartingIds.delete(id);
1069
1068
  updateInfoBar();
@@ -1482,6 +1481,8 @@ const composerCommands = createComposerCommands({
1482
1481
  followWokenWorker: () => followWokenWorker(),
1483
1482
  refreshHistory: () => refreshHistory(),
1484
1483
  failed: (line, text) => addCommandBubble({ label: null, line, text, failed: true }),
1484
+ // As handleSendTurn says a turn's failure after a switch.
1485
+ failedElsewhere: (line, text) => alert(`${line}: ${text}`),
1485
1486
  ownedByTui: () => {
1486
1487
  current.owner = "tui";
1487
1488
  updateComposerMode();
@@ -1742,7 +1743,6 @@ const centerEl = $("#center");
1742
1743
  // A leaving stage (the hand-off's slide) no longer counts: the reserve eases
1743
1744
  // down over the slide (index.html, #center.reserve-easing).
1744
1745
  const stageEl = view === "stage" ? turnDom.root : null;
1745
- const STAGE_GAP = 10;
1746
1746
  function dockReserve() {
1747
1747
  const shown = stageEl && !stageEl.hidden && !stageEl.classList.contains("leaving");
1748
1748
  return dockEl.offsetHeight + (shown ? stageEl.offsetHeight + STAGE_GAP : 0);
@@ -1760,7 +1760,7 @@ function updateReserve({ fromStage = false } = {}) {
1760
1760
  const changed = centerEl.style.getPropertyValue("--dock-h") !== reserve;
1761
1761
  if (changed) centerEl.style.setProperty("--dock-h", reserve);
1762
1762
  if (changed && follow) {
1763
- if (fromStage) easeScrollTo(historyEl, historyEnd, { ms: leaving ? FOLD_MS : FOLLOW_MS, reduce: matchMedia("(prefers-reduced-motion: reduce)").matches });
1763
+ if (fromStage) easeScrollTo(historyEl, historyEnd, { ms: leaving ? FOLD_MS : FOLLOW_MS, reduce: reducedMotion() });
1764
1764
  else if (historyEl.dataset.following === undefined) historyEl.scrollTop = historyEl.scrollHeight;
1765
1765
  }
1766
1766
  placeToast();
@@ -1864,12 +1864,11 @@ $("#infoStopBtn").addEventListener("click", async () => {
1864
1864
  stoppingId = id;
1865
1865
  try {
1866
1866
  await stopSession(id);
1867
- if (id === selected) {
1868
- // The worker is gone: don't wait for its stream to notice.
1869
- streamControl?.close();
1870
- current.status = "stopped";
1871
- workerGone(id);
1872
- }
1867
+ if (id === selected) stoppedWorker(id);
1868
+ } catch (e) {
1869
+ // A refusal (a terminal holds it) or a worker that won't stop: said, as
1870
+ // a failed restart is.
1871
+ showToast(cantText(`stop ${id.slice(0, 8)}`, e), null, { sticky: true });
1873
1872
  } finally {
1874
1873
  stoppingId = null;
1875
1874
  }
@@ -1963,6 +1962,14 @@ function markArchived(ids, archived) {
1963
1962
  // and a batch share. The ids in the answer flip in the list at once. When
1964
1963
  // the open session's worker was stopped (it, or a delegate of the archived
1965
1964
  // one), its stream ending is a stop, not a lost worker.
1965
+ // The open session's worker was stopped from here (the stop button, an
1966
+ // archive): don't wait for its stream to notice.
1967
+ function stoppedWorker(id) {
1968
+ streamControl?.close();
1969
+ current.status = "stopped";
1970
+ workerGone(id);
1971
+ }
1972
+
1966
1973
  // @return {ok: true, result} | {ok: false, error}
1967
1974
  async function archiveOne(id, archive) {
1968
1975
  // A live worker in the tree is stopped by the archive: the open
@@ -1972,12 +1979,7 @@ async function archiveOne(id, archive) {
1972
1979
  try {
1973
1980
  const result = archive ? await archiveSession(id) : await unarchiveSession(id);
1974
1981
  markArchived(archive ? result.archived : result.unarchived, archive);
1975
- if (mine && mine === selected && (result.stopped || []).includes(mine)) {
1976
- // Its worker is gone: don't wait for its stream to notice.
1977
- streamControl?.close();
1978
- current.status = "stopped";
1979
- workerGone(mine);
1980
- }
1982
+ if (mine && mine === selected && (result.stopped || []).includes(mine)) stoppedWorker(mine);
1981
1983
  return { ok: true, result };
1982
1984
  } catch (error) {
1983
1985
  return { ok: false, error };
@@ -285,7 +285,7 @@ export function createBubbles({
285
285
  }
286
286
 
287
287
  return {
288
- removeHintIfEmpty, appendChunk, addEndBubble, makeUserBubble, bubbleById, applyPromptOps, showRecap,
288
+ removeHintIfEmpty, placeFollowing, appendChunk, addEndBubble, makeUserBubble, bubbleById, applyPromptOps, showRecap,
289
289
  markRecapStale, showGuardrailWarning, showSteers, showHookNotice, addCommandBubble, commandBubble, addNoteBubble,
290
290
  };
291
291
  }
@@ -32,22 +32,20 @@ export function turnAnswerEl(turnEls) {
32
32
  }
33
33
 
34
34
  // @param historyEl the history
35
- // @param appendToHistory (el) puts +el+ at the history's end (or the stage's)
36
- // @param isNearBottom () => whether the history follows its end
37
35
  // @param turnDom the running turn's view (its card(el) takes a turn's card)
38
36
  // @param findInTurnRoots (selector) => the matching elements in the history
39
37
  // and the stage
40
38
  // @param selected () => the open session's id
41
39
  // @param clientId this tab's id in the session's events
42
40
  // @param questionCards the history's question cards (question_cards.js)
43
- // @param bubbles {removeHintIfEmpty, showGuardrailWarning, showHookNotice}
41
+ // @param bubbles {removeHintIfEmpty, placeFollowing, showGuardrailWarning, showHookNotice}
44
42
  // (bubbles.js createBubbles)
45
43
  // @param followWokenWorker () follows the worker a card's command woke
46
44
  // @param api {getSession, sendCommand} (data.js)
47
45
  // @return {cardElement(id), upsertCard(card, {before}), placeSnapshotCards(entries),
48
46
  // refreshCardBodies(), onCardAction(event)}
49
47
  export function createCardsPlace({
50
- historyEl, appendToHistory, isNearBottom, turnDom, findInTurnRoots, selected, clientId, questionCards, bubbles,
48
+ historyEl, turnDom, findInTurnRoots, selected, clientId, questionCards, bubbles,
51
49
  followWokenWorker, api = { getSession, sendCommand },
52
50
  }) {
53
51
  function cardElement(id) {
@@ -73,10 +71,7 @@ export function createCardsPlace({
73
71
  const el = drawCard(document.createElement(isNoticeCard(card) ? "details" : "div"), card);
74
72
  const asks = (card.actions || []).some((a) => a && a.command);
75
73
  if (card.in_turn && !before && !asks && turnDom.card(el)) return;
76
- const follow = isNearBottom();
77
- if (before) historyEl.insertBefore(el, before);
78
- else appendToHistory(el);
79
- if (follow) historyEl.scrollTop = historyEl.scrollHeight;
74
+ bubbles.placeFollowing(el, before);
80
75
  }
81
76
 
82
77
  // A snapshot's cards and notices, each above the first turn after it; a
@@ -170,8 +165,9 @@ export function createCardsPlace({
170
165
  button.disabled = true;
171
166
  api.sendCommand(id, button.dataset.command, { clientId, card: true })
172
167
  .then(() => {
173
- // The command woke the worker: follow its stream if none is open.
174
- followWokenWorker();
168
+ // The command woke the worker: follow its stream if none is open
169
+ // (and the page still shows that session).
170
+ if (selected() === id) followWokenWorker();
175
171
  })
176
172
  .catch((error) => {
177
173
  err.textContent = error.message;
@@ -206,7 +206,7 @@ export function chainRowsTemplate(f, { activeId = null, now = Date.now() } = {})
206
206
  export function continueRefusal(error, sessions = []) {
207
207
  const body = error?.body || {};
208
208
  if (error?.code === "continued" && body.next_id) return { kind: "open", id: body.next_id };
209
- const text = String(error?.message || "refused").replace(/\s*\(\d{3}\)$/, "");
209
+ const text = String(error?.message || "refused");
210
210
  if (error?.code === "open_children" && Array.isArray(body.ids) && body.ids.length) {
211
211
  const byId = new Map(sessions.map((s) => [s.id, s]));
212
212
  const children = body.ids.map((id) => {
@@ -84,10 +84,12 @@ export function createChipPopover({ id, className, width: widest, el, anchor: an
84
84
  show(data, false);
85
85
  }
86
86
 
87
+ // The click goes on to the document: another chip's popover (or the
88
+ // context popover) open meanwhile closes there. This one's own closer
89
+ // leaves its chip's click alone.
87
90
  el.addEventListener("click", (e) => {
88
91
  const anchor = e.target.closest(anchorSel);
89
92
  if (!anchor) return;
90
- e.stopPropagation();
91
93
  if (isOpen()) close();
92
94
  else open(anchor);
93
95
  });
@@ -60,20 +60,26 @@ export function routeSessionLine(prompt, { commands = [], images = 0, delivery =
60
60
  // followWokenWorker() the command woke the worker: follow its stream
61
61
  // refreshHistory()
62
62
  // failed(line, text) a failed command's bubble
63
+ // failedElsewhere(line, text) a command to a session no longer open failed
63
64
  // ownedByTui() a plain terminal holds the session
64
65
  // composer: {busy(), clear(), done()} the composer's send button and text
65
66
  export function createComposerCommands(deps) {
66
- const { session, clientId, sendCommand, followWokenWorker, refreshHistory, failed, ownedByTui, composer } = deps;
67
+ const { session, clientId, sendCommand, followWokenWorker, refreshHistory, failed, failedElsewhere, ownedByTui,
68
+ composer } = deps;
67
69
 
68
- // A line typed in the composer (routeComposerLine's {command}).
70
+ // The page may open another session while a command is in flight: what
71
+ // follows is about the session it went to (+id+), so the open one's
72
+ // worker, owner and history are left alone then.
69
73
  async function send(line) {
74
+ const id = session();
70
75
  composer.busy();
71
76
  try {
72
- await sendCommand(session(), line, { clientId });
77
+ await sendCommand(id, line, { clientId });
73
78
  composer.clear();
74
79
  refreshHistory();
75
- followWokenWorker();
80
+ if (session() === id) followWokenWorker();
76
81
  } catch (e) {
82
+ if (session() !== id) return failedElsewhere(line, e.message);
77
83
  if (e.code === "owned_by_tui") {
78
84
  ownedByTui();
79
85
  return;
@@ -93,10 +99,11 @@ export function createComposerCommands(deps) {
93
99
  if (!id) return false;
94
100
  try {
95
101
  await sendCommand(id, line, { clientId });
96
- followWokenWorker();
102
+ if (session() === id) followWokenWorker();
97
103
  return true;
98
104
  } catch (e) {
99
- failed(line, e.message);
105
+ if (session() === id) failed(line, e.message);
106
+ else failedElsewhere(line, e.message);
100
107
  return false;
101
108
  }
102
109
  }
@@ -9,6 +9,8 @@
9
9
 
10
10
  import { html, nothing, render } from "./vendor/lit-html.js";
11
11
  import { keepFocus } from "./keep_focus.js";
12
+ import { popoverPlace } from "./chip_popover.js";
13
+ import { cantText } from "./format.js";
12
14
 
13
15
  // "now", "3m", "2h", "4d" since an ISO time; "" for none.
14
16
  export function ageText(iso, now = Date.now()) {
@@ -177,13 +179,24 @@ export function createContextChips({ el, api, toast = () => {}, doc = document,
177
179
  render(popoverTemplate(row, { text }), pop);
178
180
  }
179
181
 
180
- // Above the chip, inside the window with a 16px gutter.
182
+ // At the chip as the session bar's chips place theirs (popoverPlace):
183
+ // above it, or below when the room above is short; capped to the room
184
+ // (it scrolls), inside the window with a 16px gutter.
181
185
  function place(chip) {
182
- const rect = chip.getBoundingClientRect();
183
186
  const width = Math.min(380, win.innerWidth - 32);
184
187
  pop.style.width = `${width}px`;
185
- pop.style.left = `${Math.max(16, Math.min(rect.left, win.innerWidth - width - 16))}px`;
186
- pop.style.bottom = `${Math.max(16, win.innerHeight - rect.top + 8)}px`;
188
+ pop.style.maxHeight = "";
189
+ const at = popoverPlace(chip.getBoundingClientRect(), { width, height: pop.offsetHeight,
190
+ innerWidth: win.innerWidth, innerHeight: win.innerHeight });
191
+ pop.style.left = `${at.left}px`;
192
+ pop.style.top = at.top === undefined ? "" : `${at.top}px`;
193
+ pop.style.bottom = at.bottom === undefined ? "" : `${at.bottom}px`;
194
+ if (at.maxHeight !== undefined) pop.style.maxHeight = `${at.maxHeight}px`;
195
+ }
196
+
197
+ function placeAtOpenChip() {
198
+ const chip = openName && el.querySelector(".ctx-chip.open");
199
+ if (chip) place(chip);
187
200
  }
188
201
 
189
202
  function open(name, chip) {
@@ -231,7 +244,7 @@ export function createContextChips({ el, api, toast = () => {}, doc = document,
231
244
  } catch (e) {
232
245
  if (id !== sessionId || openName !== ADD) return;
233
246
  // The same form again: what the user typed stays; the URL gets focus.
234
- drawAdd({ busy: false, error: String(e.message).replace(/ \(\d+\)$/, "") });
247
+ drawAdd({ busy: false, error: e.message });
235
248
  pop.querySelector('input[name="url"]')?.focus();
236
249
  }
237
250
  }
@@ -255,8 +268,10 @@ export function createContextChips({ el, api, toast = () => {}, doc = document,
255
268
  if (!row) return;
256
269
  text = data.text || "";
257
270
  fill(row);
271
+ // The text makes it taller: placed again.
272
+ placeAtOpenChip();
258
273
  } catch (e) {
259
- toast(`Can't read ${name}: ${String(e.message).replace(/ \(\d+\)$/, "")}`);
274
+ toast(cantText(`read ${name}`, e));
260
275
  }
261
276
  }
262
277
  async function detach() {
@@ -271,14 +286,15 @@ export function createContextChips({ el, api, toast = () => {}, doc = document,
271
286
  close();
272
287
  await refresh();
273
288
  } catch (e) {
274
- toast(`Can't detach ${row.name}: ${String(e.message).replace(/ \(\d+\)$/, "")}`);
289
+ toast(cantText(`detach ${row.name}`, e));
275
290
  }
276
291
  }
277
292
 
278
293
  el.addEventListener("click", (e) => {
279
294
  const chip = e.target.closest(".ctx-chip");
280
295
  if (!chip) return;
281
- e.stopPropagation();
296
+ // On to the document: a session-bar chip's popover closes (the close
297
+ // below leaves clicks in +el+ alone).
282
298
  if (chip.dataset.act === "add") {
283
299
  if (openName === ADD) close();
284
300
  else openAdd(chip);
@@ -301,10 +317,7 @@ export function createContextChips({ el, api, toast = () => {}, doc = document,
301
317
  doc.addEventListener("keydown", (e) => {
302
318
  if (e.key === "Escape" && openName) close();
303
319
  });
304
- win.addEventListener("resize", () => {
305
- const chip = openName && el.querySelector(".ctx-chip.open");
306
- if (chip) place(chip);
307
- });
320
+ win.addEventListener("resize", placeAtOpenChip);
308
321
  win.addEventListener("focus", () => refresh());
309
322
  // The ages move on by themselves.
310
323
  win.setInterval(() => { if (rows.length) drawChips(); }, 60_000);
@@ -6,7 +6,11 @@ export function api(path, opts = {}) {
6
6
  }).then(async (r) => {
7
7
  const j = await r.json().catch(() => ({}));
8
8
  if (!r.ok) {
9
- const error = new Error(`${j.detail || j.error || r.statusText} (${r.status})`);
9
+ // The server's words as they are: what the page shows the user. The
10
+ // status goes in only when it sent none (a proxy's bare 502, a crash's
11
+ // 500): then it is all there is to go on.
12
+ const words = j.detail || j.error;
13
+ const error = new Error(words || `${r.statusText || "HTTP error"} (${r.status})`);
10
14
  error.status = r.status;
11
15
  // The server's error code ({error: "owned_by_tui", detail}): what
12
16
  // the page matches on, never the message's words.
@@ -168,6 +168,11 @@ export function goneSessionNotice(id, why) {
168
168
  return `Session ${String(id).slice(0, 8)} was ${why === "deleted" ? "deleted" : "not found"}.`;
169
169
  }
170
170
 
171
+ // A failed action's toast: what failed and why (api()'s error says why).
172
+ export function cantText(what, error) {
173
+ return `Can't ${what}: ${String(error?.message ?? error)}`;
174
+ }
175
+
171
176
  // A session in the list after its live status changed (a turn started or
172
177
  // ended): that is activity now, so its card's "N min ago" says so too, and
173
178
  // the change came from a worker's stream, so the card says "live".
@@ -162,6 +162,7 @@ button.task-stop:hover:not(:disabled){background:var(--danger-bg-dim);filter:non
162
162
  .activity-alias{color:var(--text-faint);white-space:nowrap}
163
163
  .activity-duration{color:var(--text-faint);font-size:11px;font-variant-numeric:tabular-nums;white-space:nowrap}
164
164
  .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}
165
+ .activity-output.tail{display:flex;flex-direction:column-reverse}
165
166
  /* A row's status and tool name never wrap (a phone broke them letter by
166
167
  letter); the params take what is left. The output goes on its own line
167
168
  under the call instead of a column beside it: on a desktop it lines up
@@ -264,10 +264,9 @@ export function batchTargets(picked, shownIds, archive, sessionsById) {
264
264
  });
265
265
  }
266
266
 
267
- // A refusal's reason as the card shows it: the server's words, without
268
- // the api() status suffix.
267
+ // A refusal's reason as the card shows it: the server's words.
269
268
  function refusalText(error) {
270
- return String(error?.message || "refused").replace(/\s*\(\d{3}\)$/, "");
269
+ return String(error?.message || "refused");
271
270
  }
272
271
 
273
272
  // Runs +act+ over +ids+ one at a time (a live session's stop waits on the
@@ -13,7 +13,7 @@ import { easeScrollTo } from "./scroll.js";
13
13
  const GROW_MS = 180;
14
14
  const GROW_EASE = "cubic-bezier(.2,.7,.3,1)";
15
15
  // The room between the stage and the composer card (index.html's margin-bottom).
16
- const STAGE_GAP = 10;
16
+ export const STAGE_GAP = 10;
17
17
 
18
18
  export function reducedMotion() {
19
19
  return matchMedia("(prefers-reduced-motion: reduce)").matches;
@@ -127,6 +127,7 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
127
127
  let endedAt = 0;
128
128
  let thinkingLine = null; // { step, text } from the live step's ticker
129
129
  let flash = null; // { kind, text, until } a notice or nudge in the trail
130
+ let flashTimer = null; // its end's redraw
130
131
  let collapsed = readCollapsed();
131
132
  let cloudOpen = false;
132
133
  // A ref's "show in turn" opened a collapsed stage (not kept).
@@ -252,7 +253,8 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
252
253
  if (!what) return;
253
254
  flash = { ...what, until: Date.now() + FLASH_MS };
254
255
  scheduleRefresh();
255
- setTimeout(scheduleRefresh, FLASH_MS + 50);
256
+ clearTimeout(flashTimer);
257
+ flashTimer = setTimeout(scheduleRefresh, FLASH_MS + 50);
256
258
  }
257
259
 
258
260
  // The answer reads from its top (no tail pin in the stage): scrolled to
@@ -408,6 +410,7 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
408
410
  promptSlot.classList.remove("full");
409
411
  thinkingLine = null;
410
412
  flash = null;
413
+ clearTimeout(flashTimer);
411
414
  setCloud(false);
412
415
  stage.classList.remove("plain", "ended", "waiting", "held-wait", "asking", "in-use");
413
416
  stage.style.removeProperty("--live-held");
@@ -590,8 +593,11 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
590
593
  // composer's delivery line).
591
594
  current: () => view.current(),
592
595
  phase: () => liveSlots(view.current().turn, { pending: pendingCard() || asksCard() }).phase,
596
+ // Another session opens, or this one renders again: a turn still
597
+ // sliding away is dropped (its history is about to be drawn anew),
598
+ // never moved into whatever history shows now.
593
599
  reset() {
594
- if (folding) finishFold();
600
+ folding = null;
595
601
  release();
596
602
  },
597
603
  // The stage's extras while it holds a turn (a card, a prompt for its
@@ -150,12 +150,15 @@ export function createStreamHandlers(deps) {
150
150
  // An after_turn hook presented the answer (AnswerDisplay) or attached
151
151
  // refs to it (AnswerRefs: refs, a count; display null when only refs):
152
152
  // it comes after turn_completed, so re-read the answer once that read
153
- // is done.
153
+ // is done: the session's open then, so a switch meanwhile leaves the
154
+ // other session's answers alone.
154
155
  answer_display: async (data) => {
156
+ const id = selected();
155
157
  try {
156
158
  await liveTurn.endRead;
157
- if (!selected() || (!data.display && !data.refs)) return;
158
- const d = await getSession(selected(), { tail: true, recent: true });
159
+ if (!id || id !== selected() || (!data.display && !data.refs)) return;
160
+ const d = await getSession(id, { tail: true, recent: true });
161
+ if (id !== selected()) return;
159
162
  if (Array.isArray(d.messages)) applyFinalMarkdown(d.messages);
160
163
  } catch (_) {
161
164
  } finally {
@@ -278,7 +278,7 @@ function noMatch(record) {
278
278
  // every assistant message is one step. Its calls take the turn's records by
279
279
  // tool, in call order (each the next record of its tool, consumed with the
280
280
  // ones it skipped), not by iteration: an empty retry spends an iteration
281
- // that left no message (as the TUI's join, attached_loop.rb join_duration).
281
+ // that left no message (as the TUI's join, attached_loop.rb join_record).
282
282
  // The last message is the answer when it has text and made no call (and the
283
283
  // turn is no canceled multi-step one); its thinking stays behind as a step,
284
284
  // as it does live.
@@ -5,7 +5,8 @@
5
5
  // worker's snapshot or a command_ran; the server's own reading of the
6
6
  // session file has no lists). Left out is reachable (find_tools and
7
7
  // call_tool reach them) and removed (only /toolkit add brings one back).
8
- // Changing it is /toolkit (no popover yet).
8
+ // Changing it is /toolkit (no popover yet). A kit with a prompt text has
9
+ // it on the summary too (prompt), and the tooltip ends with it.
9
10
 
10
11
  import { html, nothing } from "./vendor/lit-html.js";
11
12
 
@@ -24,6 +25,7 @@ export function chipModel(summary) {
24
25
  parts.push(summary.left_out.length ? `Not loaded: ${summary.left_out.join(", ")}.` : "Nothing left out.");
25
26
  }
26
27
  parts.push("/toolkit lists them; /toolkit NAME, add TOOL or remove TOOL changes them from the next turn.");
28
+ if (summary.prompt) parts.push(`Prompt: ${summary.prompt}`);
27
29
  return { text: `tools ${label}${summary.fallback ? " ?" : ""}`, title: parts.join(" ") };
28
30
  }
29
31