samagotchi 0.18.1 → 0.19.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 (61) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +85 -1
  3. data/README.md +4 -3
  4. data/docs/architecture.md +15 -2
  5. data/docs/cli.md +34 -8
  6. data/docs/configuration.md +8 -4
  7. data/docs/memory.md +4 -0
  8. data/docs/sessions.md +6 -4
  9. data/lib/samagotchi/bridge.rb +25 -9
  10. data/lib/samagotchi/bridge_client/event_stream.rb +37 -1
  11. data/lib/samagotchi/bridge_client.rb +9 -5
  12. data/lib/samagotchi/bundle_command.rb +2 -1
  13. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +7 -4
  14. data/lib/samagotchi/bundles/source-links/manifest.yml +2 -2
  15. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  16. data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
  17. data/lib/samagotchi/engine.rb +9 -3
  18. data/lib/samagotchi/guardrail_wiring.rb +8 -2
  19. data/lib/samagotchi/guardrails/approval.rb +10 -4
  20. data/lib/samagotchi/guardrails/approvals.rb +60 -12
  21. data/lib/samagotchi/host_registry.rb +22 -4
  22. data/lib/samagotchi/idle_scheduler.rb +1 -1
  23. data/lib/samagotchi/installed_versions.rb +91 -0
  24. data/lib/samagotchi/live_versions.rb +5 -3
  25. data/lib/samagotchi/memory_bundle/builder.rb +24 -1
  26. data/lib/samagotchi/memory_bundle/index_sync.rb +1 -9
  27. data/lib/samagotchi/memory_bundle/installer.rb +33 -0
  28. data/lib/samagotchi/memory_bundle/status.rb +5 -1
  29. data/lib/samagotchi/memory_bundle/uninstaller.rb +6 -2
  30. data/lib/samagotchi/model_overlay.rb +39 -0
  31. data/lib/samagotchi/model_profile.rb +4 -0
  32. data/lib/samagotchi/relay_desk.rb +9 -0
  33. data/lib/samagotchi/session_manager.rb +166 -11
  34. data/lib/samagotchi/session_metrics.rb +36 -13
  35. data/lib/samagotchi/sessions_command.rb +43 -4
  36. data/lib/samagotchi/system_prompt.rb +10 -1
  37. data/lib/samagotchi/terminal_ui/attached_loop.rb +48 -3
  38. data/lib/samagotchi/terminal_ui/image_input.rb +11 -4
  39. data/lib/samagotchi/terminal_ui/version_lines.rb +48 -0
  40. data/lib/samagotchi/tools/builtins.rb +21 -5
  41. data/lib/samagotchi/turn_note.rb +3 -2
  42. data/lib/samagotchi/update_command.rb +33 -12
  43. data/lib/samagotchi/version.rb +1 -1
  44. data/lib/samagotchi/web/app.rb +64 -11
  45. data/lib/samagotchi/web/public/app.js +102 -29
  46. data/lib/samagotchi/web/public/data.js +6 -0
  47. data/lib/samagotchi/web/public/format.js +4 -2
  48. data/lib/samagotchi/web/public/index.html +11 -0
  49. data/lib/samagotchi/web/public/question_card.js +12 -0
  50. data/lib/samagotchi/web/public/question_cards.js +17 -11
  51. data/lib/samagotchi/web/public/scope.js +15 -6
  52. data/lib/samagotchi/web/public/session_state.js +4 -0
  53. data/lib/samagotchi/web/public/turn_events.js +107 -3
  54. data/lib/samagotchi/web/public/update_notice.js +87 -14
  55. data/lib/samagotchi/web/server.rb +28 -1
  56. data/lib/samagotchi/web/session_hub.rb +30 -3
  57. data/lib/samagotchi/web/session_summary.rb +5 -0
  58. data/lib/samagotchi/worker.rb +25 -8
  59. data/lib/samagotchi/worker_idle_exit.rb +31 -2
  60. data/lib/samagotchi/worker_sidecar.rb +7 -5
  61. metadata +3 -1
@@ -123,6 +123,12 @@ export function stopSession(id, opts = {}) {
123
123
  return api(`/api/sessions/${id}/stop`, { method: "POST", body: JSON.stringify({}), ...opts });
124
124
  }
125
125
 
126
+ // Hands the session to a new worker on the newest chi installed: 200
127
+ // {status, from_version, version}, or 409 with the reason in words (detail).
128
+ export function restartSession(id, opts = {}) {
129
+ return api(`/api/sessions/${encodeURIComponent(id)}/restart`, { method: "POST", body: JSON.stringify({}), ...opts });
130
+ }
131
+
126
132
  // Hides the session (and its delegates) from the lists, kept for good; the
127
133
  // server stops a live idle worker first and answers 409 while a turn runs.
128
134
  export function archiveSession(id, opts = {}) {
@@ -86,7 +86,9 @@ export function previewForCard(session) {
86
86
 
87
87
  const DELETE_PREVIEW_CHARS = 40;
88
88
 
89
- // The confirm before deleting a session (the info bar's and a card's).
89
+ // The confirm before deleting a session (the info bar's and a card's). The
90
+ // list's owner can be stale (a worker idle-exits), so a worker is only
91
+ // "stopped if it's still running", never promised.
90
92
  export function deleteConfirmText(session) {
91
93
  const n = normalize(session.first_preview || session.last_prompt || "");
92
94
  const preview = n.length > DELETE_PREVIEW_CHARS ? `${n.slice(0, DELETE_PREVIEW_CHARS)}\u2026` : n;
@@ -95,7 +97,7 @@ export function deleteConfirmText(session) {
95
97
  `Delete session ${shortId}${preview ? ` \u2014 "${preview}"` : ""}?`,
96
98
  "Its history, notes and images are removed; this can't be undone.",
97
99
  ];
98
- if (session.owner === "worker") lines.push("Its worker is running and will be stopped first.");
100
+ if (session.owner === "worker") lines.push("If its worker is still running, it is stopped first.");
99
101
  return lines.join("\n");
100
102
  }
101
103
 
@@ -242,6 +242,11 @@ main{flex:1;display:flex;flex-direction:column;gap:16px;padding:16px;overflow:au
242
242
  .question-error{color:var(--danger);font-size:12px;margin-bottom:8px}
243
243
  .question-error.hidden{display:none}
244
244
  .question-result{margin-top:6px;font-size:12px;color:var(--ok);font-weight:600}
245
+ /* 4.02: a resolved card whose summary already carries the result hides the
246
+ body line; a hand-toggled one keeps it (its expanded body shows it). */
247
+ .question-result.hidden{display:none}
248
+ /* N1: an approval's verdict sits above its command block, not after it. */
249
+ .question-result.verdict{margin-top:0;margin-bottom:8px}
245
250
  .bubble.question.cancelled .question-result{color:var(--text-muted)}
246
251
  .question-submit{align-self:flex-end;font-size:13px}
247
252
  .question-dismiss{font-size:13px;margin-left:8px}
@@ -554,6 +559,12 @@ body.stage-view #dock,body.stage-view #dock:focus-within{background:transparent;
554
559
  #infoBar .meta .dir{font-family:ui-monospace,monospace;font-size:11px;color:var(--text-muted);word-break:break-all}
555
560
  #infoBar .meta .model{font-size:11px;padding:2px 6px;border-radius:6px;background:var(--bg-hover);color:var(--text-3)}
556
561
  #infoBar .meta .model.served-mismatch{background:var(--warn-bg);color:var(--warn-bright);border:1px solid var(--warn-border)}
562
+ /* The open session's worker runs an older chi: its version, and Restart when it can. */
563
+ #infoBar .meta .worker-badge{display:inline-flex;align-items:center;gap:6px;font-size:11px;padding:2px 2px 2px 6px;border-radius:6px;background:var(--warn-bg);color:var(--warn-bright);border:1px solid var(--warn-border);white-space:nowrap}
564
+ #infoBar .meta .worker-badge:not(:has(button)){padding-right:6px}
565
+ #infoBar .meta .worker-restart{padding:1px 8px;border-radius:5px;font-size:11px;font-weight:600;background:var(--bg-raised);color:var(--warn-bright);border:1px solid var(--warn-border)}
566
+ #infoBar .meta .worker-restart:hover:not(:disabled){background:var(--warn-border);color:#fff}
567
+ #infoBar .meta .worker-restart:disabled{opacity:.7;cursor:default}
557
568
  #infoBar .preview-line{font-size:13px;word-break:break-word;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;line-clamp:2;overflow:hidden;line-height:1.4;max-height:2.8em}
558
569
  #infoBar .preview-line .preview, #infoBar .preview-line .mem{word-break:break-word}
559
570
  #infoBar .mem{font-size:12px;color:var(--text-muted)}
@@ -161,6 +161,18 @@ export function approvalAllowed(pq, answer) {
161
161
  return resultText(pq, { answer }).startsWith("Allowed");
162
162
  }
163
163
 
164
+ // What the card says when an answer or a dismissal didn't go through. 503
165
+ // (the server found no live bridge) reads as the session not running; any
166
+ // other failure — the worker's own (the server maps it to a 502
167
+ // bridge_error) or the connection's — shows what it said, so a worker that
168
+ // failed the request is not mistaken for one that is gone.
169
+ export function answerErrorText(error) {
170
+ const status = error?.status;
171
+ const message = String(error?.message ?? error ?? "");
172
+ if (status === 503 || /not_live/.test(message)) return "Session is not running — restart it to answer.";
173
+ return message;
174
+ }
175
+
164
176
  // What a new question does to the card on the page: "same" (the card
165
177
  // already shows it: a replay), "keep" (the card is resolved, so it stays in
166
178
  // the history and the new one goes under it), "replace" (the card is still
@@ -15,8 +15,8 @@
15
15
  import { dismissQuestion, sendAnswer, sendCommand } from "./data.js";
16
16
  import { diffHtml } from "./diff_view.js";
17
17
  import {
18
- approvalAllowed, approvalView, CONTINUE_REASON_PLACEHOLDER, isContinue, nextCardAction, relayedToView, resultText,
19
- summaryText,
18
+ answerErrorText, approvalAllowed, approvalView, CONTINUE_REASON_PLACEHOLDER, isContinue, nextCardAction,
19
+ relayedToView, resultText, summaryText,
20
20
  } from "./question_card.js";
21
21
  import { sessionHash } from "./route.js";
22
22
  import { clientLabel, continueLine, isOwn } from "./turn_events.js";
@@ -148,9 +148,7 @@ export function createQuestionCards({
148
148
  }).catch((e) => {
149
149
  submit.disabled = false;
150
150
  card.classList.remove("submitting");
151
- err.textContent = /503|not_live/.test(e.message)
152
- ? "Session is not running — restart it to answer."
153
- : e.message;
151
+ err.textContent = answerErrorText(e);
154
152
  err.classList.remove("hidden");
155
153
  });
156
154
  });
@@ -169,9 +167,7 @@ export function createQuestionCards({
169
167
  api.dismissQuestion(sessionId(), pq.id).catch((e) => {
170
168
  submit.disabled = false;
171
169
  dismiss.disabled = false;
172
- err.textContent = /503|not_live/.test(e.message)
173
- ? "Session is not running — restart it to answer."
174
- : e.message;
170
+ err.textContent = answerErrorText(e);
175
171
  err.classList.remove("hidden");
176
172
  });
177
173
  });
@@ -210,7 +206,7 @@ export function createQuestionCards({
210
206
  api.sendAnswer(sessionId(), { id: pq.id, selected: [label], ...(freeform ? { freeform } : {}) }).catch((e) => {
211
207
  card.querySelectorAll("button, input").forEach((el) => { el.disabled = false; });
212
208
  card.classList.remove("submitting");
213
- err.textContent = /503|not_live/.test(e.message) ? "Session is not running — restart it to answer." : e.message;
209
+ err.textContent = answerErrorText(e);
214
210
  err.classList.remove("hidden");
215
211
  });
216
212
  };
@@ -320,14 +316,20 @@ export function createQuestionCards({
320
316
  const note = doc.createElement("div");
321
317
  note.className = "question-result";
322
318
  note.textContent = result;
319
+ // N1: an approval's verdict goes before its command block, not after it:
320
+ // with the details expanded the command reads like another tool call.
321
+ const allowed = approvalAllowed(pending, answer);
322
+ if (allowed !== null) note.classList.add("verdict");
323
323
  if (cancelled) {
324
324
  questionEl.classList.add("cancelled");
325
325
  } else {
326
326
  questionEl.classList.add("answered");
327
- if (approvalAllowed(pending, answer) === false) questionEl.classList.add("denied");
327
+ if (allowed === false) questionEl.classList.add("denied");
328
328
  markQuestionSelection(answer && Array.isArray(answer.selected) ? answer.selected : []);
329
329
  }
330
- questionEl.appendChild(note);
330
+ const before = allowed !== null ? questionEl.querySelector(".approval-details") : null;
331
+ if (before) questionEl.insertBefore(note, before);
332
+ else questionEl.appendChild(note);
331
333
  questionEl.classList.remove("submitting");
332
334
  disableQuestionInputs();
333
335
  questionEl.querySelector(".question-relayed")?.classList.add("hidden");
@@ -335,6 +337,10 @@ export function createQuestionCards({
335
337
  // unless the user opened or closed it by hand.
336
338
  const summary = questionEl.querySelector("summary");
337
339
  if (summary) summary.textContent = summaryText(pending, { answer, cancelled, reason });
340
+ // 4.02: a reopened card's summary already carries the result (the
341
+ // snapshot re-renders it resolved), and the body line repeats it. It
342
+ // stays for a hand-toggled card, whose expanded body the user looks at.
343
+ if (summary && !questionEl.dataset.toggled) note.classList.add("hidden");
338
344
  if (!questionEl.dataset.toggled) questionEl.open = false;
339
345
  pending = null;
340
346
  }
@@ -10,14 +10,23 @@ export function scopeDir(search) {
10
10
 
11
11
  // The same place in the all view: the page without ?dir, keeping the hash.
12
12
  // +fromDir+ (the project view's folder) rides along as ?from=, so the all
13
- // view can link back to it.
14
- export function allScopeHref(hash, fromDir = null) {
15
- return `/${fromDir ? `?from=${dirParam(fromDir)}` : ""}${hash || ""}`;
13
+ // view can link back to it. +search+ (the page's location.search) keeps
14
+ // its ?view= (VIEW_PARAM) on the new page.
15
+ export function allScopeHref(hash, fromDir = null, search = "") {
16
+ const params = [fromDir ? `from=${dirParam(fromDir)}` : "", viewParam(search)].filter(Boolean);
17
+ return `/${params.length ? `?${params.join("&")}` : ""}${hash || ""}`;
16
18
  }
17
19
 
18
- // A folder's project view, keeping the hash.
19
- export function projectScopeHref(dir, hash) {
20
- return `/?dir=${dirParam(dir)}${hash || ""}`;
20
+ // A folder's project view, keeping the hash and +search+'s ?view=.
21
+ export function projectScopeHref(dir, hash, search = "") {
22
+ const view = viewParam(search);
23
+ return `/?dir=${dirParam(dir)}${view ? `&${view}` : ""}${hash || ""}`;
24
+ }
25
+
26
+ // "view=<v>" from a location.search string, or "" without one.
27
+ function viewParam(search) {
28
+ const view = new URLSearchParams(search || "").get("view");
29
+ return view ? `view=${encodeURIComponent(view)}` : "";
21
30
  }
22
31
 
23
32
  // A folder for the query string, its slashes left readable (as chi web
@@ -29,6 +29,10 @@ export function emptySessionState() {
29
29
  owner: null,
30
30
  // The session's commands (its worker's, plugins' too) for the / list.
31
31
  commands: [],
32
+ // The chi its live worker runs and what it can do (the session card's
33
+ // worker_version / worker_features): the info bar's stale-worker badge.
34
+ workerVersion: null,
35
+ workerFeatures: [],
32
36
  timing: normalizeTiming(),
33
37
  };
34
38
  }
@@ -20,10 +20,69 @@ export function workerGoneText({ turnRunning, stopped = false }) {
20
20
  return `\u2715 canceled (${stopped ? "session stopped" : "worker exited"})`;
21
21
  }
22
22
 
23
+ // How long a turn's answer waits for the after_turn hooks' answer_display
24
+ // before the page stops waiting and pops what it has (4.14).
25
+ export const DISPLAY_WAIT_MS = 20000;
26
+
27
+ // The wait between a turn_completed with display_pending and its
28
+ // answer_display. The worker can die in between (a crash, a stop, an idle
29
+ // exit) and never send one, so the wait also ends on the caller's finish()
30
+ // (workerGone, a re-render) and by the timeout: it must never hold the
31
+ // turn's answer bubble forever.
32
+ // @return {{promise, finish, settled: () => Boolean}}
33
+ export function displayWait({
34
+ timeoutMs = DISPLAY_WAIT_MS, setTimeoutImpl = setTimeout, clearTimeoutImpl = clearTimeout,
35
+ } = {}) {
36
+ let settle = null;
37
+ let done = false;
38
+ const promise = new Promise((resolve) => { settle = resolve; });
39
+ const finish = () => {
40
+ if (done) return;
41
+ done = true;
42
+ clearTimeoutImpl(timer);
43
+ settle();
44
+ };
45
+ const timer = setTimeoutImpl(finish, timeoutMs);
46
+ return { promise, finish, settled: () => done };
47
+ }
48
+
23
49
  // This tab's id in the events (`origin.client_id`, turn_enqueued's
24
- // client_id): new per page load, like the TUI's `tui:<pid>`.
25
- export function newClientId() {
26
- return `web:${Math.random().toString(36).slice(2, 10).padEnd(8, "0")}`;
50
+ // client_id): kept in sessionStorage per tab, so a reload mid-turn still
51
+ // recognizes this tab's own prompt (4.03), which a fresh random id every
52
+ // load labelled "web". Falls back to a fresh id when sessionStorage is
53
+ // unavailable (a private window that refuses it) or holds nonsense.
54
+ export const CLIENT_ID_KEY = "chi_client_id";
55
+ export function newClientId(storage = safeSessionStorage()) {
56
+ const kept = readClientId(storage);
57
+ if (kept) return kept;
58
+ const id = `web:${Math.random().toString(36).slice(2, 10).padEnd(8, "0")}`;
59
+ writeClientId(storage, id);
60
+ return id;
61
+ }
62
+
63
+ function safeSessionStorage() {
64
+ try {
65
+ return globalThis.sessionStorage || null;
66
+ } catch (_) {
67
+ return null;
68
+ }
69
+ }
70
+
71
+ function readClientId(storage) {
72
+ try {
73
+ const value = storage?.getItem(CLIENT_ID_KEY);
74
+ return typeof value === "string" && /^web:/.test(value) ? value : null;
75
+ } catch (_) {
76
+ return null;
77
+ }
78
+ }
79
+
80
+ function writeClientId(storage, id) {
81
+ try {
82
+ storage?.setItem(CLIENT_ID_KEY, id);
83
+ } catch (_) {
84
+ // Full or refused storage: the id stays for this page only.
85
+ }
27
86
  }
28
87
 
29
88
  export function isOwn(clientId, myId) {
@@ -190,6 +249,17 @@ export function startPageReply(text, { images = 0 } = {}) {
190
249
  return images ? null : webLocalReply(text);
191
250
  }
192
251
 
252
+ // The start page's reply to a first message that needs no session at all
253
+ // (2.29): its own command's reply (startPageReply), or the hint for a
254
+ // command word no command answers (a typo like /modle) — the hint the page
255
+ // shows in a session too, as a command bubble. Decided before the session
256
+ // is created, so a first /stats or /modle leaves none behind. A null here
257
+ // means the message goes to a session (a prompt, or anything with images).
258
+ export function startPageReplyOrHint(text, { commands = [], images = 0 } = {}) {
259
+ if (images) return null;
260
+ return startPageReply(text) || unknownCommandHint(text, commands);
261
+ }
262
+
193
263
  // The continue card's answers, as the terminal's continue prompt reads them.
194
264
  export function continueLine(choice, reason = "") {
195
265
  const why = String(reason || "").trim();
@@ -257,6 +327,40 @@ export function restoreOnAck(enqueuedId, early, { myId, sentIds }) {
257
327
  return restoreAction(event, { myId, sentIds, composerEmpty: true });
258
328
  }
259
329
 
330
+ // The /turn this kept entry was for failed: no ack comes, so nothing would
331
+ // take it back. The whole map goes: this tab sends one turn at a time, so
332
+ // every entry waits for an ack that is now never coming (an older one is
333
+ // left over from a send that failed the same way).
334
+ export function dropEarlyRestores(early) {
335
+ const count = early.size;
336
+ early.clear();
337
+ return count;
338
+ }
339
+
340
+ // The composer's text with a restored prompt put back into it: the restored
341
+ // text alone when nothing is typed, else the typed text with the restored
342
+ // prompt under it (the user typed between the restore and the ack; their
343
+ // text must not be replaced).
344
+ export function restoreInto(refill, current) {
345
+ const restored = String(refill ?? "");
346
+ const typed = String(current ?? "");
347
+ if (!restored) return typed;
348
+ if (!typed.trim()) return restored;
349
+ return `${typed.replace(/\n+$/, "")}\n${restored}`;
350
+ }
351
+
352
+ // What the composer holds once a /turn ack lands (4.13): the composer still
353
+ // carries the text that was just sent, so that text goes (it is the history's
354
+ // now) — but text typed since the send stays, and a prompt restored before
355
+ // this ack (restoreOnAck's refill) is put back without replacing it.
356
+ // @param sent the composer's text when the turn was sent
357
+ // @param current the composer's text as the ack lands
358
+ // @param refill the prompt an early restore handed back, if any
359
+ export function composerAfterAck({ sent = "", current = "", refill = "" } = {}) {
360
+ const kept = String(current ?? "") === String(sent ?? "") ? "" : current;
361
+ return restoreInto(refill, kept);
362
+ }
363
+
260
364
  // What an event does to the prompt bubbles, as a list of ops for app.js:
261
365
  // add — a bubble for a prompt this tab hasn't shown (state queued /
262
366
  // started / steered, labelled by its sender)
@@ -1,15 +1,88 @@
1
- // An open tab keeps its JS across a `chi web` restart (its event streams
2
- // just reconnect), so after an upgrade old page code talks to a new server.
3
- // The page knows the version that served it (body data-version); each
4
- // GET /api/events snapshot names the one serving now.
5
-
6
- // The toast's text when the chi web serving now is another version than
7
- // the one that served this page, else null: also when either side doesn't
8
- // say (an older chi web, a page from before data-version), and when the
9
- // toast was already shown for this version (+shown+: the reconnects'
10
- // snapshots carry it again).
11
- export function updateNotice(loaded, served, shown = null) {
12
- const known = (v) => typeof v === "string" && v !== "";
13
- if (!known(loaded) || !known(served) || loaded === served || served === shown) return null;
14
- return `chi was updated to ${served}`;
1
+ // What the page says about chi versions. An open tab keeps its JS across a
2
+ // `chi web` restart (its event streams just reconnect), so after an upgrade
3
+ // old page code talks to a new server: the page knows the version that
4
+ // served it (body data-version), and each GET /api/events snapshot names
5
+ // the one serving now and the newest installed (`chi` frames when that
6
+ // changes).
7
+
8
+ const known = (v) => typeof v === "string" && v !== "";
9
+
10
+ // Gem::Version's order, near enough: segments of digits or letters, a
11
+ // missing one counts as 0, and letters (a prerelease: 0.19.0.pre1) sort
12
+ // below any number. <0, 0 or >0.
13
+ export function compareVersions(a, b) {
14
+ const parts = (v) => (v.match(/[0-9]+|[a-z]+/gi) || []).map((p) => (/^[0-9]+$/.test(p) ? Number(p) : p));
15
+ const x = parts(a);
16
+ const y = parts(b);
17
+ for (let i = 0; i < Math.max(x.length, y.length); i++) {
18
+ const p = i < x.length ? x[i] : 0;
19
+ const q = i < y.length ? y[i] : 0;
20
+ if (p === q) continue;
21
+ if (typeof p === "number" && typeof q === "number") return p - q;
22
+ if (typeof p === "number") return 1;
23
+ if (typeof q === "number") return -1;
24
+ return p < q ? -1 : 1;
25
+ }
26
+ return 0;
27
+ }
28
+
29
+ // Whether +a+ is a newer version than +b+ (false when either is unknown).
30
+ export function newerVersion(a, b) {
31
+ return known(a) && known(b) && compareVersions(a, b) > 0;
32
+ }
33
+
34
+ // The toast for the versions as the page knows them, or null:
35
+ // - loaded ≠ served: chi web restarted on another version; reload the page;
36
+ // - installed newer than served: chi web runs an older chi than installed;
37
+ // only its terminal can restart it.
38
+ // Each has a key; one in +shown+ (a Set: what this tab already showed, as
39
+ // every reconnect's snapshot says it again) is not shown twice.
40
+ // @return {{key, text, reload: boolean} | null}
41
+ export function versionNotice({ loaded, served, installed, shown = new Set() } = {}) {
42
+ let notice = null;
43
+ if (known(loaded) && known(served) && loaded !== served) {
44
+ notice = { key: `reload:${served}`, text: `chi was updated to ${served}`, reload: true };
45
+ } else if (newerVersion(installed, served)) {
46
+ notice = {
47
+ key: `installed:${installed}`,
48
+ text: `chi ${installed} is installed; this chi web runs ${served}. ` +
49
+ "Restart it: Ctrl-C in its terminal, then chi web",
50
+ reload: false,
51
+ };
52
+ }
53
+ return notice && !shown.has(notice.key) ? notice : null;
54
+ }
55
+
56
+ // The info bar's badge for the open session's worker when it runs an older
57
+ // chi than the newest installed (or than chi web, while that is unknown),
58
+ // else null. A worker with the "restart" feature offers Restart; an older
59
+ // one can only be stopped (any message then starts it on the newest chi).
60
+ // @return {{text, title, newest, restart: boolean} | null}
61
+ export function workerBadge({ workerVersion, features, installed, served, sessionId = "" } = {}) {
62
+ const newest = known(installed) ? installed : served;
63
+ if (!newerVersion(newest, workerVersion)) return null;
64
+ if (Array.isArray(features) && features.includes("restart")) {
65
+ return {
66
+ text: `chi ${workerVersion}`,
67
+ title: `This session's worker runs chi ${workerVersion}; chi ${newest} is installed. ` +
68
+ `Restart hands the session to a new worker on chi ${newest}.`,
69
+ newest,
70
+ restart: true,
71
+ };
72
+ }
73
+ const stop = `chi sessions stop ${String(sessionId).slice(0, 8)}`;
74
+ return {
75
+ text: `chi ${workerVersion} · ${stop}`,
76
+ title: `This session's worker runs chi ${workerVersion}, which can't restart itself; chi ${newest} is installed. ` +
77
+ `Stop it (${stop}), then any message starts it on chi ${newest}.`,
78
+ newest,
79
+ restart: false,
80
+ };
81
+ }
82
+
83
+ // What the Restart confirm asks.
84
+ export function restartConfirmText(workerVersion, newest) {
85
+ return `Restart this session's worker on chi ${newest} (it runs ${workerVersion})?\n\n` +
86
+ "The conversation stays, and this page and attached terminals move to the new worker. " +
87
+ "What lives only in the worker goes: kept /btw answers and plugins' in-memory state.";
15
88
  }
@@ -28,12 +28,16 @@ module Samagotchi
28
28
  class Server
29
29
  # WEBrick logs every exception out of its request loop as an ERROR with
30
30
  # a backtrace, among them a browser dropping a kept-alive connection
31
- # (sock.eof? raising ECONNRESET): harmless, so not logged.
31
+ # (sock.eof? raising ECONNRESET): harmless, so not logged. Nor is the
32
+ # line it logs after answering a body-less POST (no Content-Length),
33
+ # when it tries to drain a body nobody read: the response already went.
32
34
  class Log < WEBrick::Log
33
35
  DROPPED = [Errno::ECONNRESET, Errno::EPIPE, Errno::ECONNABORTED].freeze
36
+ DROPPED_LINES = ["HTTPRequest#fixup: WEBrick::HTTPStatus::LengthRequired occurred."].freeze
34
37
 
35
38
  def error(msg)
36
39
  return if DROPPED.any? { |klass| msg.is_a?(klass) }
40
+ return if msg.is_a?(String) && DROPPED_LINES.include?(msg)
37
41
 
38
42
  super
39
43
  end
@@ -125,6 +129,8 @@ module Samagotchi
125
129
  annotate_presets: annotate_presets, hub: hub, lan: lan_option)
126
130
  Samagotchi::Log.info(:web, "start", url: "http://#{host}:#{port}", version: Samagotchi::VERSION)
127
131
  Samagotchi::Log.info(:web, "lan", ip: lan.ip, interface: lan.interface) if lan
132
+ # Subscribed before the first scan, which makes the first check.
133
+ hub.subscribe(method(:on_hub_event))
128
134
  hub.start
129
135
  # Said once the port is bound: a second chi web racing for it gets
130
136
  # the in-use line instead.
@@ -162,6 +168,27 @@ module Samagotchi
162
168
 
163
169
  class LanListenError < StandardError; end
164
170
 
171
+ # The hub's `chi` event: a newer chi installed than this chi web runs
172
+ # is said once in its terminal (the page says it too).
173
+ def self.on_hub_event(event)
174
+ return unless event.type == "chi"
175
+
176
+ line = installed_line(event.data[:installed])
177
+ return unless line
178
+
179
+ Samagotchi::Log.info(:web, "newer_installed", installed: event.data[:installed], version: Samagotchi::VERSION)
180
+ puts line
181
+ $stdout.flush
182
+ end
183
+
184
+ # nil unless +installed+ is newer than this chi web.
185
+ def self.installed_line(installed, running = Samagotchi::VERSION)
186
+ return nil unless InstalledVersions.newer?(installed, running)
187
+
188
+ "chi #{installed} is installed; this chi web runs #{running}. Restart it: Ctrl-C here, then chi web " \
189
+ "(new sessions already run #{installed})"
190
+ end
191
+
165
192
  # The LAN address's listener, next to the loopback one WEBrick made.
166
193
  # The loopback socket is bound by now: on a failure it is closed
167
194
  # here, as WEBrick never starts.
@@ -9,6 +9,8 @@ require_relative "../session_manager"
9
9
  require_relative "../bridge_client"
10
10
  require_relative "../worker_sidecar"
11
11
  require_relative "../log"
12
+ require_relative "../installed_versions"
13
+ require_relative "../version"
12
14
  require_relative "session_summary"
13
15
 
14
16
  module Samagotchi
@@ -37,7 +39,9 @@ module Samagotchi
37
39
  FULL_PROBE_INTERVAL = 10.0
38
40
 
39
41
  # What a subscriber gets: `session` with {session: summary} for an
40
- # upsert, `session_gone` with {id:} for a removal. seq is monotonic per
42
+ # upsert, `session_gone` with {id:} for a removal, `chi` with
43
+ # {version:, installed:} when the newest installed chi changed (checked
44
+ # with the full probe; #installed has it for a new tab). seq is monotonic per
41
45
  # hub, for the log's sake: there is no replay.
42
46
  Event = Struct.new(:type, :seq, :data, keyword_init: true)
43
47
 
@@ -68,9 +72,12 @@ module Samagotchi
68
72
  # @param manager [#session_owner, #retention_sweep_if_due] SessionManager,
69
73
  # or a stand-in
70
74
  # @param now [#call] a monotonic clock in seconds (specs drive it)
75
+ # @param installed_versions [#newest] what is installed (InstalledVersions)
71
76
  def initialize(state_dir:, manager: SessionManager, now: nil, scan_interval: SCAN_INTERVAL,
72
- full_probe_interval: FULL_PROBE_INTERVAL)
77
+ full_probe_interval: FULL_PROBE_INTERVAL, installed_versions: InstalledVersions.new)
73
78
  @state_dir = state_dir
79
+ @installed_versions = installed_versions
80
+ @installed = nil
74
81
  @manager = manager
75
82
  @now = now || -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
76
83
  @scan_interval = scan_interval
@@ -120,11 +127,19 @@ module Samagotchi
120
127
  full = full_probe_due?
121
128
  scan_files
122
129
  @sessions.each_key { |id| refresh(id, probe: full) }
123
- sweep if full
130
+ if full
131
+ sweep
132
+ check_installed
133
+ end
124
134
  end
125
135
  nil
126
136
  end
127
137
 
138
+ # The newest installed chi as of the last full probe (nil: unknown).
139
+ def installed
140
+ @monitor.synchronize { @installed }
141
+ end
142
+
128
143
  # Rescan one session now (its file, its folder, its owner), for the
129
144
  # page's own actions: no waiting for the next tick.
130
145
  def touch(id)
@@ -202,6 +217,18 @@ module Samagotchi
202
217
  nil
203
218
  end
204
219
 
220
+ # A change (the first look too, unless it finds nothing) is a `chi`
221
+ # event: chi web's terminal line and the open tabs hear it.
222
+ def check_installed
223
+ newest = @installed_versions.newest
224
+ return if newest == @installed
225
+
226
+ @installed = newest
227
+ emit("chi", version: VERSION, installed: newest)
228
+ rescue StandardError => e
229
+ Log.warn(:web, "installed_check_failed", error: e.class.name, msg: e.message)
230
+ end
231
+
205
232
  # The state dir's files: a new id is parsed and emitted, an id gone
206
233
  # from disk is dropped, a changed file is re-parsed. Skipped when the
207
234
  # dir's mtime says nothing was renamed in or unlinked since.
@@ -30,6 +30,7 @@ module Samagotchi
30
30
  def build(session, owner:, session_dir:, status: displayed_status(session, owner: owner), root_cache: nil)
31
31
  used = Array(session.used_memory_names)
32
32
  up = bridge_up?(session_dir, owner)
33
+ sidecar = up ? WorkerSidecar.read(session_dir) : nil
33
34
  {
34
35
  id: session.id,
35
36
  status: status,
@@ -59,6 +60,10 @@ module Samagotchi
59
60
  # A worker is reachable: its Bridge sidecar is there and the
60
61
  # owner lock is held. A sidecar a dead worker left is not up.
61
62
  bridge_up: up,
63
+ # The chi the live worker runs and what it can do (its sidecar;
64
+ # nil and [] without one): the page's stale-worker badge.
65
+ worker_version: sidecar&.version,
66
+ worker_features: sidecar ? sidecar.features : [],
62
67
  # For the tab's notifications (notify.js): an open question, the
63
68
  # running turn's open card with actions, and how the last turn
64
69
  # ended. A question saved by a worker that died is not open, nor a