samagotchi 0.26.0 → 0.28.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 (47) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +55 -1
  3. data/docs/architecture.md +6 -0
  4. data/docs/cli.md +6 -3
  5. data/docs/configuration.md +17 -3
  6. data/docs/hooks.md +1 -1
  7. data/docs/internals/prompt-caching.md +38 -7
  8. data/docs/sessions.md +1 -1
  9. data/lib/samagotchi/bridge.rb +37 -1
  10. data/lib/samagotchi/bridge_client.rb +9 -0
  11. data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
  12. data/lib/samagotchi/bundles/mcp/plugin.rb +2 -1
  13. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  14. data/lib/samagotchi/config.rb +8 -0
  15. data/lib/samagotchi/desktop/macos/App.swift +6 -4
  16. data/lib/samagotchi/desktop/macos/ChiRunner.swift +5 -3
  17. data/lib/samagotchi/desktop/macos/Panel.swift +30 -10
  18. data/lib/samagotchi/desktop/macos.rb +5 -1
  19. data/lib/samagotchi/engine.rb +1 -1
  20. data/lib/samagotchi/llm/openai_chat.rb +40 -4
  21. data/lib/samagotchi/llm/prompt_cache.rb +31 -5
  22. data/lib/samagotchi/log.rb +19 -3
  23. data/lib/samagotchi/session.rb +9 -0
  24. data/lib/samagotchi/session_manager.rb +4 -3
  25. data/lib/samagotchi/sessions_command.rb +26 -13
  26. data/lib/samagotchi/terminal_ui/repl_input.rb +13 -0
  27. data/lib/samagotchi/terminal_ui.rb +5 -0
  28. data/lib/samagotchi/thinking_tails.rb +1 -1
  29. data/lib/samagotchi/tool_activity.rb +3 -2
  30. data/lib/samagotchi/tool_view.rb +14 -3
  31. data/lib/samagotchi/tools/task_get.rb +3 -2
  32. data/lib/samagotchi/tools/task_runtime.rb +49 -12
  33. data/lib/samagotchi/tools/task_stop.rb +1 -1
  34. data/lib/samagotchi/tools/task_wait.rb +22 -10
  35. data/lib/samagotchi/turn_note.rb +4 -2
  36. data/lib/samagotchi/version.rb +1 -1
  37. data/lib/samagotchi/vision_context.rb +17 -4
  38. data/lib/samagotchi/web/app.rb +25 -0
  39. data/lib/samagotchi/web/markdown_renderer.rb +21 -6
  40. data/lib/samagotchi/web/public/app.js +4 -0
  41. data/lib/samagotchi/web/public/data.js +11 -0
  42. data/lib/samagotchi/web/public/index.html +19 -0
  43. data/lib/samagotchi/web/public/stage_model.js +8 -2
  44. data/lib/samagotchi/web/public/stage_view.js +14 -2
  45. data/lib/samagotchi/web/public/task_stop.js +124 -0
  46. data/lib/samagotchi/web/public/turn_view.js +24 -1
  47. metadata +2 -1
@@ -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,124 @@
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 watchers = new Map(); // key -> Set of fn(busy)
58
+ const keyOf = (taskId) => `${sessionId()}|${taskId}`;
59
+ const notify = (key, busy) => watchers.get(key)?.forEach((fn) => fn(busy));
60
+ return {
61
+ target: (row) => stopTarget(row, features()),
62
+ busy: (taskId) => inFlight.has(keyOf(taskId)),
63
+ // Call +fn+ whenever a stop for +taskId+ starts or ends (the key enters
64
+ // or leaves the in-flight set) — the session's own task, on this session.
65
+ // Returns unwatch. A task drawn in two rows (the stage's "now" slot and
66
+ // the step list) so both buttons stay in step; a watcher for a button
67
+ // that is gone unwatches itself.
68
+ watch(taskId, fn) {
69
+ const key = keyOf(taskId);
70
+ let set = watchers.get(key);
71
+ if (!set) watchers.set(key, (set = new Set()));
72
+ set.add(fn);
73
+ return () => set.delete(fn);
74
+ },
75
+ // true once the worker stopped it. A stop that went through stays in
76
+ // flight: the button goes with its row's completion a moment later.
77
+ async stop(taskId, command = null) {
78
+ const id = sessionId();
79
+ const key = keyOf(taskId);
80
+ if (!id || inFlight.has(key) || !confirm(confirmText(taskId, command))) return false;
81
+ inFlight.add(key);
82
+ notify(key, true);
83
+ try {
84
+ await stopTask(id, taskId);
85
+ return true;
86
+ } catch (e) {
87
+ inFlight.delete(key);
88
+ notify(key, false);
89
+ if (!quietFailure(e)) alert(e.message);
90
+ return false;
91
+ }
92
+ },
93
+ };
94
+ }
95
+
96
+ // The button itself (the one DOM part): a click asks, posts, and keeps it
97
+ // disabled while the stop is in flight. +command+ is read at the click;
98
+ // +onChange+ lets a view redraw (the stage's slot). It also watches the
99
+ // task's in-flight state, so a stop started from a second button of the
100
+ // same row (the stage draws one in the "now" slot and one in the step
101
+ // list) disables this one too — a button that is gone unwatched.
102
+ export function taskStopButton(stopper, taskId, { command = () => null, onChange = () => {} } = {}) {
103
+ const btn = document.createElement("button");
104
+ btn.type = "button";
105
+ btn.className = "ghost task-stop";
106
+ btn.textContent = "stop task";
107
+ btn.title = "Kill this background task; the model is told you stopped it";
108
+ btn.disabled = stopper.busy(taskId);
109
+ const unwatch = stopper.watch?.(taskId, (busy) => {
110
+ if (!btn.isConnected) { unwatch(); return; }
111
+ btn.disabled = busy;
112
+ });
113
+ btn.addEventListener("click", (e) => {
114
+ e.stopPropagation();
115
+ const pending = stopper.stop(taskId, command());
116
+ btn.disabled = stopper.busy(taskId);
117
+ onChange();
118
+ pending.then(() => {
119
+ btn.disabled = stopper.busy(taskId);
120
+ onChange();
121
+ });
122
+ });
123
+ return btn;
124
+ }
@@ -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.28.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