samagotchi 0.121.0 → 0.121.1

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 (98) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +82 -1
  3. data/bin/chi +3 -2
  4. data/docs/architecture.md +36 -11
  5. data/docs/bundle-authoring.md +2 -1
  6. data/docs/cli.md +10 -4
  7. data/docs/configuration.md +7 -3
  8. data/docs/guardrails.md +48 -19
  9. data/docs/internals/anthropic.md +1 -1
  10. data/docs/internals/background-tasks.md +1 -1
  11. data/docs/internals/participants.md +3 -1
  12. data/docs/internals/remote-sessions.md +15 -11
  13. data/docs/internals/web-push.md +22 -11
  14. data/docs/plugins.md +6 -2
  15. data/docs/sessions.md +15 -0
  16. data/docs/testing.md +41 -1
  17. data/lib/samagotchi/answer_command.rb +1 -1
  18. data/lib/samagotchi/bridge.rb +8 -8
  19. data/lib/samagotchi/bridge_client/event_stream.rb +11 -27
  20. data/lib/samagotchi/bridge_client/stream_open.rb +79 -0
  21. data/lib/samagotchi/bridge_client/stream_request.rb +8 -2
  22. data/lib/samagotchi/bridge_client.rb +52 -65
  23. data/lib/samagotchi/bridge_secret.rb +9 -7
  24. data/lib/samagotchi/bundle_command/check.rb +27 -2
  25. data/lib/samagotchi/bundles/claude-peer/manifest.yml +2 -2
  26. data/lib/samagotchi/bundles/claude-peer/scripts/pair +20 -6
  27. data/lib/samagotchi/bundles/codex-peer/manifest.yml +2 -2
  28. data/lib/samagotchi/bundles/codex-peer/scripts/pair +14 -3
  29. data/lib/samagotchi/bundles/github-pr/manifest.yml +2 -2
  30. data/lib/samagotchi/bundles/github-pr/plugin.rb +2 -6
  31. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  32. data/lib/samagotchi/child_move.rb +5 -7
  33. data/lib/samagotchi/children_status.rb +2 -1
  34. data/lib/samagotchi/guardrail_wiring.rb +19 -2
  35. data/lib/samagotchi/guardrails/parent_approvals.rb +9 -0
  36. data/lib/samagotchi/guardrails_command.rb +41 -6
  37. data/lib/samagotchi/image_store.rb +6 -3
  38. data/lib/samagotchi/interrupted_turn.rb +6 -3
  39. data/lib/samagotchi/live_versions.rb +6 -7
  40. data/lib/samagotchi/memory_bundle/profile.rb +1 -1
  41. data/lib/samagotchi/memory_bundle/uninstaller.rb +6 -2
  42. data/lib/samagotchi/plugin/api.rb +8 -5
  43. data/lib/samagotchi/plugin/sessions.rb +13 -4
  44. data/lib/samagotchi/push_command.rb +1 -1
  45. data/lib/samagotchi/relay_verifier.rb +2 -3
  46. data/lib/samagotchi/relay_watcher.rb +2 -2
  47. data/lib/samagotchi/remote_attach_command.rb +1 -1
  48. data/lib/samagotchi/reply_wait/poll.rb +8 -2
  49. data/lib/samagotchi/reply_wait.rb +4 -3
  50. data/lib/samagotchi/review_command.rb +1 -1
  51. data/lib/samagotchi/run_options.rb +1 -0
  52. data/lib/samagotchi/session.rb +4 -0
  53. data/lib/samagotchi/session_access/local.rb +18 -8
  54. data/lib/samagotchi/session_access/remote.rb +5 -0
  55. data/lib/samagotchi/session_access/web_bridge_client.rb +31 -19
  56. data/lib/samagotchi/session_access.rb +21 -9
  57. data/lib/samagotchi/session_continue.rb +2 -3
  58. data/lib/samagotchi/session_manager.rb +118 -28
  59. data/lib/samagotchi/sessions_command.rb +10 -5
  60. data/lib/samagotchi/system_one/client.rb +6 -1
  61. data/lib/samagotchi/system_one/fake_backend.rb +0 -2
  62. data/lib/samagotchi/system_one/log.rb +21 -6
  63. data/lib/samagotchi/system_one/self_protect.rb +39 -43
  64. data/lib/samagotchi/terminal_ui/attach_launcher.rb +8 -3
  65. data/lib/samagotchi/terminal_ui/attached_loop.rb +4 -2
  66. data/lib/samagotchi/terminal_ui/local_link.rb +28 -19
  67. data/lib/samagotchi/toolkit.rb +68 -2
  68. data/lib/samagotchi/tools/delegate.rb +1 -1
  69. data/lib/samagotchi/tools/delegate_relay.rb +9 -7
  70. data/lib/samagotchi/turn_checkpoint.rb +15 -9
  71. data/lib/samagotchi/version.rb +1 -1
  72. data/lib/samagotchi/web/app.rb +123 -65
  73. data/lib/samagotchi/web/participant_access.rb +26 -7
  74. data/lib/samagotchi/web/participant_review.rb +2 -1
  75. data/lib/samagotchi/web/participant_view.rb +7 -3
  76. data/lib/samagotchi/web/participants_admin.rb +13 -0
  77. data/lib/samagotchi/web/public/app.js +1 -1
  78. data/lib/samagotchi/web/public/notifications.js +21 -1
  79. data/lib/samagotchi/web/public/participant.html +3 -2
  80. data/lib/samagotchi/web/public/participant.js +23 -9
  81. data/lib/samagotchi/web/public/participants_panel.js +46 -11
  82. data/lib/samagotchi/web/public/push.js +4 -3
  83. data/lib/samagotchi/web/public/push_devices.js +17 -3
  84. data/lib/samagotchi/web/public/review_history.js +14 -2
  85. data/lib/samagotchi/web/public/review_start.js +1 -3
  86. data/lib/samagotchi/web/public/session_actions.js +14 -2
  87. data/lib/samagotchi/web/public/storage.js +16 -0
  88. data/lib/samagotchi/web/push/client.rb +5 -0
  89. data/lib/samagotchi/web/push/store.rb +16 -3
  90. data/lib/samagotchi/web/push_routes.rb +3 -2
  91. data/lib/samagotchi/web/session_access_routes.rb +25 -17
  92. data/lib/samagotchi/web/session_create.rb +4 -4
  93. data/lib/samagotchi/web/session_hub.rb +6 -4
  94. data/lib/samagotchi/web/session_summary.rb +8 -16
  95. data/lib/samagotchi/worker.rb +1 -1
  96. data/lib/samagotchi/worker_link.rb +213 -0
  97. data/lib/samagotchi/worker_sidecar.rb +15 -37
  98. metadata +3 -1
@@ -24,7 +24,9 @@ import { appendQuote } from "./annotations.js";
24
24
  import { messageAuthorData, participantQuote, participantSource } from "./participant_annotate.js";
25
25
  import { mountComposer } from "./composer_mount.js";
26
26
  import { PROMPT_LINE_PX } from "./composer_size.js";
27
- import { forgetReviewHistory, readReviewHistory, recordReviewComment, rememberReviewHistory } from "./review_history.js";
27
+ import {
28
+ forgetReviewHistory, pruneReviewHistory, readReviewHistory, recordReviewComment, rememberReviewHistory,
29
+ } from "./review_history.js";
28
30
 
29
31
  // A link's #code= opened in a browser that already has a cookie: traded
30
32
  // first, the page reloads without it.
@@ -54,6 +56,8 @@ let usageTimer = null;
54
56
  let failures = 0;
55
57
  let loadSeq = 0;
56
58
  let ended = false;
59
+ // The other links' ↑/↓ lists went at this page's first read (pruneReviewHistory).
60
+ let historyPruned = false;
57
61
  // The Plan view: the subject picked, what it last drew, and which view a
58
62
  // narrow screen shows (the thread first).
59
63
  let subjectPath = null;
@@ -104,6 +108,8 @@ function showEnded(json) {
104
108
  document.title = "chi · review link ended";
105
109
  }
106
110
 
111
+ const OFFLINE_TEXT = "Can't reach the session — retrying…";
112
+
107
113
  // Reads the review data, draws it and streams from it. One that can't be
108
114
  // read (unreachable, a 5xx) keeps what the page shows, says it is
109
115
  // retrying, and reads again with a backoff.
@@ -115,18 +121,22 @@ async function load() {
115
121
  if (!ok) {
116
122
  $("head").hidden = false;
117
123
  if (!data) $("title").textContent = "Review";
118
- $("offline").hidden = !retries(status);
119
- if (retries(status)) {
120
- scheduleRetry(retryDelay(failures++));
121
- } else {
122
- $("note").textContent = status === 404 ? "This session is gone." : `The review couldn't be read (HTTP ${status}).`;
123
- }
124
+ // Said over the thread (#offline), not in the header's rows: the
125
+ // thread stays where it is.
126
+ $("offline").textContent = retries(status) ? OFFLINE_TEXT
127
+ : status === 404 ? "This session is gone." : `The review couldn't be read (HTTP ${status}).`;
128
+ $("offline").hidden = false;
129
+ if (retries(status)) scheduleRetry(retryDelay(failures++));
124
130
  return;
125
131
  }
126
132
  failures = 0;
127
133
  $("offline").hidden = true;
128
134
  data = json;
129
- if (!ownerView) rememberReviewHistory(sessionId, data?.viewer?.id);
135
+ if (!ownerView) {
136
+ rememberReviewHistory(sessionId, data?.viewer?.id);
137
+ if (!historyPruned) pruneReviewHistory(sessionId, data?.viewer?.id);
138
+ historyPruned = true;
139
+ }
130
140
  live = liveFrom(data.current_turn);
131
141
  liveStale = false;
132
142
  render();
@@ -468,6 +478,7 @@ async function sendComment() {
468
478
  if (!prompt) return;
469
479
  sending = true;
470
480
  $("send").disabled = true;
481
+ showError("");
471
482
  let reply;
472
483
  try {
473
484
  reply = await api(`/api/sessions/${encodeURIComponent(sessionId)}/turn`, {
@@ -481,7 +492,8 @@ async function sendComment() {
481
492
  if (ended) return;
482
493
  if (ok) {
483
494
  recordReviewComment(data?.viewer?.id, prompt);
484
- comment.value = "";
495
+ // What was typed while the comment was in flight stays in the box.
496
+ if (comment.value.trim() === prompt) comment.value = "";
485
497
  composer.fit();
486
498
  composer.drawFoldControls();
487
499
  composer.refreshHistory();
@@ -522,6 +534,8 @@ const composer = ownerView ? null : mountComposer({
522
534
  fold: { session: () => sessionId || null },
523
535
  });
524
536
  if (composer) {
537
+ // An error under the box (a refusal, the images toast) goes at the next edit.
538
+ $("comment").addEventListener("input", () => showError(""));
525
539
  // The session composer's layout (composer.css), and the fold saved in this browser.
526
540
  document.body.classList.add("has-session");
527
541
  composer.newVisit();
@@ -101,6 +101,19 @@ export function chipTemplate(chip) {
101
101
  title=${chip.title} aria-label=${chip.title} aria-haspopup="dialog"><span class="pc-dot" aria-hidden="true"></span>${chip.text}</button>`;
102
102
  }
103
103
 
104
+ // A cap field's dollars (a leading $ allowed): {value}, {value: null} when
105
+ // empty (the Invite then leaves the default cap), or {error}. Both forms read
106
+ // it, so one rule and one wording.
107
+ // @return {{value: number|null}|{error: string}}
108
+ export const CAP_ERROR = "A cap is dollars, 0 to 1000.";
109
+ export function capValue(text) {
110
+ const clean = String(text ?? "").trim();
111
+ if (!clean) return { value: null };
112
+ const value = Number(clean.replace(/^\$/, ""));
113
+ if (!Number.isFinite(value) || value < 0 || value > 1000) return { error: CAP_ERROR };
114
+ return { value };
115
+ }
116
+
104
117
  // An email as chi binds a link to it (Grants.clean_identity, which has the
105
118
  // last word): one @, no spaces, commas, quotes or angle brackets.
106
119
  const EMAIL_RE = /^[^@\s,<>"\\]+@[^@\s,<>"\\]+$/;
@@ -118,15 +131,17 @@ export function inviteBody({ name, kind, days, cap, identity }) {
118
131
  if (email) body.identity = email;
119
132
  const d = Number(days);
120
133
  body.ttl = Math.round((EXPIRY_DAYS.includes(d) ? d : DEFAULT_EXPIRY_DAYS) * 24 * 60 * 60);
121
- const capText = String(cap ?? "").trim();
122
- if (capText) {
123
- const value = Number(capText.replace(/^\$/, ""));
124
- if (!Number.isFinite(value) || value < 0 || value > 1000) return { error: "A cap is dollars, 0 to 1000." };
125
- body.cap = value;
126
- }
134
+ const capped = capValue(cap);
135
+ if (capped.error) return { error: capped.error };
136
+ if (capped.value !== null) body.cap = capped.value;
127
137
  return { body };
128
138
  }
129
139
 
140
+ // The toast for an invite whose panel closed before its answer came.
141
+ export function lateInviteText(name, sessionId) {
142
+ return `${name}'s review link for ${String(sessionId).slice(0, 8)} is ready (the panel closed before it came); it shows only once.`;
143
+ }
144
+
130
145
  // What Revoke asks first: it can't be undone (chi keeps only the link's
131
146
  // digest), as a delete asks.
132
147
  export function revokeConfirmText(name) {
@@ -206,7 +221,7 @@ export function panelTemplate(grants, { now, sessionId, invite = null, error = n
206
221
  // The control: el (#infoText, where the chip is drawn), session() the open
207
222
  // session's id, api {list(id), invite(id, body), revoke(id, pid), cap(id,
208
223
  // pid, cap)}, onChange() (the chip's count or presence changed: redraw the
209
- // bar), toast(text), onNames() (the participants' names changed: name the
224
+ // bar), toast(text, action, {sticky}) (app.js showToast), onNames() (the participants' names changed: name the
210
225
  // badges drawn before them, participant_names.js nameUnnamedBadges).
211
226
  // startReview({onError}): Start a review of it (a review fork of the open
212
227
  // session): the created session, or null (its refusal to onError(text), true
@@ -300,7 +315,10 @@ export function createParticipantsControl({ el, session, api, onChange = () => {
300
315
  const asked = ask();
301
316
  try {
302
317
  const answer = await api.invite(asked.id, body);
303
- if (!stillOpen(asked)) return dropped(asked);
318
+ if (!stillOpen(asked)) {
319
+ lateInvite(asked, answer.participant?.name || body.name, answer.link);
320
+ return dropped(asked);
321
+ }
304
322
  invite = { name: answer.participant?.name || body.name, link: answer.link, qr_svg: answer.qr_svg, lan: answer.lan,
305
323
  warning: answer.warning || null, identity: answer.participant?.identity || null, copied: false };
306
324
  error = null;
@@ -314,10 +332,27 @@ export function createParticipantsControl({ el, session, api, onChange = () => {
314
332
  if (shell.isOpen()) draw();
315
333
  }
316
334
 
335
+ // An invite answered after its panel closed: the grant exists and its link
336
+ // shows only once, so a sticky toast keeps it (Copy link; a failed copy
337
+ // shows the link itself to select), never a later panel.
338
+ function lateInvite(asked, name, link) {
339
+ if (!link) return;
340
+ const copy = async () => {
341
+ try {
342
+ await win.navigator.clipboard.writeText(link);
343
+ toast(`${name}'s review link copied`);
344
+ } catch {
345
+ toast(`${name}'s review link: ${link}`, null, { sticky: true });
346
+ }
347
+ };
348
+ toast(lateInviteText(name, asked.id), { label: "Copy link", run: copy }, { sticky: true });
349
+ }
350
+
317
351
  async function submitCap(form) {
318
- const value = Number(String(form.elements.cap.value).trim().replace(/^\$/, ""));
319
- if (!Number.isFinite(value)) {
320
- error = "A cap is dollars, 0 to 1000.";
352
+ // A cleared field is no cap to set (Number("") would be a $0 cap).
353
+ const { value, error: problem } = capValue(form.elements.cap.value);
354
+ if (problem || value == null) {
355
+ error = CAP_ERROR;
321
356
  return draw();
322
357
  }
323
358
  const asked = ask();
@@ -81,14 +81,15 @@ export async function subscribe({ reg, key, post }) {
81
81
  return { subscription: sub, answer };
82
82
  }
83
83
 
84
- // Unsubscribes this device and tells chi web to forget it.
84
+ // Tells chi web to forget this device, then unsubscribes it: a DELETE
85
+ // that fails (a flaky connection) throws before anything local changes, so
86
+ // the bell stays on with its subscription and the next tap tries again.
85
87
  // @param del (endpoint) => Promise
86
88
  // @return whether there was a subscription
87
89
  export async function unsubscribe({ reg, del }) {
88
90
  const sub = await reg.pushManager.getSubscription();
89
91
  if (!sub) return false;
90
- const endpoint = sub.endpoint;
92
+ await del(sub.endpoint);
91
93
  await sub.unsubscribe();
92
- await del(endpoint);
93
94
  return true;
94
95
  }
@@ -98,6 +98,17 @@ export function sheetTemplate(model) {
98
98
  }<div class="pd-note">Approvals, questions, step limits, cards and failed turns; quiet while a chi web page is in front.</div>`;
99
99
  }
100
100
 
101
+ // Puts every switch in +pop+ back to +model+: lit skips a .checked binding
102
+ // whose value is the one it last set, so a switch the app then refused
103
+ // (iOS's Don't Allow, a failed PATCH) would keep its clicked state.
104
+ export function syncSwitches(pop, model) {
105
+ const rows = [model.thisRow, ...model.others];
106
+ for (const input of pop.querySelectorAll("input[data-pd]")) {
107
+ if (input.dataset.pd === "this") input.checked = model.on;
108
+ else if (input.dataset.pd === "done") input.checked = rows.some((r) => r.id === input.dataset.id && r.done);
109
+ }
110
+ }
111
+
101
112
  // The sheet at the bell. +state+() the bell's state; +endpoint+() this
102
113
  // device's push endpoint (a Promise; null without a subscription);
103
114
  // +toggleThis+() the bell's subscribe / unsubscribe (resolves to its
@@ -111,14 +122,17 @@ export function createPushDevices({ button, api, state, endpoint, toggleThis, no
111
122
  // Each load's turn: an older answer that arrives late is dropped.
112
123
  let loads = 0;
113
124
 
114
- const draw = () => {
115
- if (shell.isOpen()) render(sheetTemplate(sheetModel({ state: state(), info, busy, error, now: now() })), shell.pop);
125
+ const paint = () => {
126
+ const model = sheetModel({ state: state(), info, busy, error, now: now() });
127
+ render(sheetTemplate(model), shell.pop);
128
+ syncSwitches(shell.pop, model);
116
129
  };
130
+ const draw = () => { if (shell.isOpen()) paint(); };
117
131
 
118
132
  const shell = createChipPopover({
119
133
  id: "pushDevices", className: "ctx-popover push-devices", width: 340, el: doc.createElement("span"),
120
134
  anchor: "#notifyBtn", openers: "#notifyBtn", summary: () => ({}), sheet: true, label: "Notifications", doc, win,
121
- show: () => render(sheetTemplate(sheetModel({ state: state(), info, busy, error, now: now() })), shell.pop),
135
+ show: () => paint(),
122
136
  onClose: () => { error = ""; },
123
137
  });
124
138
 
@@ -4,8 +4,9 @@
4
4
  // /api/history (the owner may open a link in the same browser, on the same
5
5
  // origin: each grant's list and the owner's stay apart). Read and written
6
6
  // through storage.js (guarded): a list that can't be read or written is just
7
- // not there. A link that ends forgets its list.
8
- import { readStored, removeStored, writeStored } from "./storage.js";
7
+ // not there. A link that ends forgets its list; one that ended while no page
8
+ // was open goes when the next link's page loads (pruneReviewHistory).
9
+ import { readStored, removeStored, storedKeys, writeStored } from "./storage.js";
9
10
 
10
11
  export const REVIEW_HISTORY_PREFIX = "chi_review_history:";
11
12
  const REVIEW_HISTORY_SESSION_PREFIX = "chi_review_history_session:";
@@ -58,3 +59,14 @@ export function forgetReviewHistory(grantId, sessionId) {
58
59
  const key = reviewHistoryKey(grantId || remembered);
59
60
  if (key) removeStored(key);
60
61
  }
62
+
63
+ // A participant page's first read (+sessionId+, its grant +grantId+): every
64
+ // other grant's list and every other session's pointer go, so the lists of
65
+ // links that ended while no page was open don't stay for good. Without
66
+ // either id (the owner's view, an older server's data) nothing goes.
67
+ export function pruneReviewHistory(sessionId, grantId) {
68
+ if (!sessionId || !grantId) return;
69
+ const keep = new Set([reviewHistoryKey(grantId), `${REVIEW_HISTORY_SESSION_PREFIX}${sessionId}`]);
70
+ for (const key of storedKeys(REVIEW_HISTORY_PREFIX)) if (!keep.has(key)) removeStored(key);
71
+ for (const key of storedKeys(REVIEW_HISTORY_SESSION_PREFIX)) if (!keep.has(key)) removeStored(key);
72
+ }
@@ -19,10 +19,8 @@ export function readSubjects(text) {
19
19
  return { subjects: [...new Set(found)], error: null };
20
20
  }
21
21
 
22
- export const parseSubjects = (text) => readSubjects(text).subjects;
23
-
24
22
  // The files as the form shows them again: a path with a space, a comma or
25
- // a quote in quotes, so parseSubjects reads it back whole.
23
+ // a quote in quotes, so readSubjects reads it back whole.
26
24
  export function formatSubjects(subjects) {
27
25
  return (subjects || []).map((s) => (/[\s,"]/.test(s) ? `"${s}"` : s)).join(", ");
28
26
  }
@@ -76,9 +76,21 @@ export function createSessionActions({
76
76
  // +stopHolds+ is the sheet's "Stop all": the worker's stop-all step, then
77
77
  // the stop. A delegate or a background task outlives the worker either way
78
78
  // (docs/sessions.md, "Stopping a worker"), which is why the sheet says so.
79
- async function stopSelected(id = selected(), { stopHolds = false } = {}) {
80
- if (!id) return;
79
+ // One Stop per session at a time (as reviewingIds): a second tap while the
80
+ // holds read (or the stop) is under way would be a second confirm.
81
+ const stopAsks = new Set();
81
82
 
83
+ async function stopSelected(id = selected(), options = {}) {
84
+ if (!id || stopAsks.has(id)) return;
85
+ stopAsks.add(id);
86
+ try {
87
+ await stopOne(id, options);
88
+ } finally {
89
+ stopAsks.delete(id);
90
+ }
91
+ }
92
+
93
+ async function stopOne(id, { stopHolds = false }) {
82
94
  if (!stopHolds) {
83
95
  let holds = [];
84
96
  try {
@@ -21,3 +21,19 @@ export function writeStored(key, value) {
21
21
  export function removeStored(key) {
22
22
  try { globalThis.localStorage.removeItem(key); } catch (_) {}
23
23
  }
24
+
25
+ // The stored keys that start with +prefix+ ([] when they can't be read), a
26
+ // copy: removing one while walking them is safe.
27
+ export function storedKeys(prefix) {
28
+ try {
29
+ const storage = globalThis.localStorage;
30
+ const keys = [];
31
+ for (let i = 0; i < storage.length; i++) {
32
+ const key = storage.key(i);
33
+ if (typeof key === "string" && key.startsWith(prefix)) keys.push(key);
34
+ }
35
+ return keys;
36
+ } catch (_) {
37
+ return [];
38
+ }
39
+ }
@@ -76,6 +76,11 @@ module Samagotchi
76
76
  result_for(response, ms(started))
77
77
  rescue *NETWORK_ERRORS => e
78
78
  Result.new(status: :failed, code: nil, ms: ms(started), error: e.is_a?(Timeout::Error) ? "timeout" : e.class.name)
79
+ rescue StandardError => e
80
+ # Anything else is this device's failure too (a p256dh off the
81
+ # curve on disk: Crypto.encrypt raises), never the other devices'
82
+ # or the Notifier's flush. Its class, never its message.
83
+ Result.new(status: :failed, code: nil, ms: ms(started), error: e.class.name)
79
84
  end
80
85
 
81
86
  private
@@ -201,11 +201,14 @@ module Samagotchi
201
201
  added
202
202
  end
203
203
 
204
- # Removes the subscription with this short id or endpoint.
204
+ # Removes the subscription with this short id or endpoint, only one
205
+ # subscribed on +origin+ (nil: any; a push service's 404/410).
205
206
  # @return [Sub, nil] the one removed
206
- def remove(id_or_endpoint)
207
+ def remove(id_or_endpoint, origin: nil)
207
208
  update do |subs|
208
- gone = subs.find { |sub| sub.id == id_or_endpoint || sub.endpoint == id_or_endpoint }
209
+ gone = subs.find do |sub|
210
+ (sub.id == id_or_endpoint || sub.endpoint == id_or_endpoint) && (origin.nil? || sub.origin == origin)
211
+ end
209
212
  [subs - [gone], gone]
210
213
  end
211
214
  end
@@ -307,6 +310,7 @@ module Samagotchi
307
310
  unless p256dh&.bytesize == 65 && p256dh.getbyte(0) == 4
308
311
  raise Invalid, "a push subscription's p256dh must be a P-256 public key (65 bytes, base64url)"
309
312
  end
313
+ raise Invalid, "a push subscription's p256dh is not a point on P-256" unless point?(p256dh)
310
314
  raise Invalid, "a push subscription's auth must be 16 bytes (base64url)" unless decoded(keys["auth"])&.bytesize == 16
311
315
 
312
316
  [endpoint, keys["p256dh"], keys["auth"]]
@@ -331,6 +335,15 @@ module Samagotchi
331
335
  @services.any? { |entry| entry.start_with?(".") ? host.end_with?(entry) && host.length > entry.length : host == entry }
332
336
  end
333
337
 
338
+ # The key as Crypto.encrypt builds it: 65 bytes starting 0x04 can
339
+ # still lie off the curve (encrypt would raise on every push).
340
+ def point?(raw)
341
+ Crypto.ec_public(raw)
342
+ true
343
+ rescue ArgumentError, OpenSSL::OpenSSLError
344
+ false
345
+ end
346
+
334
347
  def decoded(text)
335
348
  Crypto.unb64u(text)
336
349
  rescue ArgumentError
@@ -86,7 +86,8 @@ module Samagotchi
86
86
  [204, { "Cache-Control" => "no-store" }, []]
87
87
  end
88
88
 
89
- # {endpoint} → 204: this device's bell turned off.
89
+ # {endpoint} → 204: this device's bell turned off (only a device
90
+ # subscribed on this origin, as the other device routes).
90
91
  def handle_push_unsubscribe(req)
91
92
  return push_off unless @push
92
93
 
@@ -94,7 +95,7 @@ module Samagotchi
94
95
  endpoint = body["endpoint"] if body.is_a?(Hash)
95
96
  return error_response(400, "missing_fields", "endpoint is required") unless endpoint.is_a?(String) && !endpoint.empty?
96
97
 
97
- gone = @push.store.remove(endpoint)
98
+ gone = @push.store.remove(endpoint, origin: @push.origin)
98
99
  Log.info(:web, "push_unsubscribed", device: gone.id) if gone
99
100
  [204, { "Cache-Control" => "no-store" }, []]
100
101
  end
@@ -256,7 +256,8 @@ module Samagotchi
256
256
  # timeout and a margin, on top of +wait+); 409
257
257
  # owned_by_tui (a chi REPL has it), 409 scratch (a leftover scratch
258
258
  # session, as chi --attach here says); 504 worker_timeout (its Bridge
259
- # didn't come in time); 404 not_found.
259
+ # didn't come in time); 409 worker_newer (a worker whose protocol is newer
260
+ # than this chi's, not resumed); 404 not_found.
260
261
  def handle_wake(req, id)
261
262
  body = body_hash(req) or return error_response(400, "invalid_json", "invalid JSON body")
262
263
  wait = body.fetch("wait", WAKE_MAX_WAIT)
@@ -284,7 +285,9 @@ module Samagotchi
284
285
  # rediscovery and its features after a restart, and before its wake
285
286
  # whether the worker is leaving (its recap on leave: the wake waits
286
287
  # for it, so the client says so). 200 {session_id, live, stopped,
287
- # owner: worker|tui|null, leaving, leave_seconds (its leave's budget
288
+ # owner: worker|tui|null, leaving, stuck (a worker holds the lock with
289
+ # no Bridge past WorkerLink::START_LIMIT), skew (its protocol is
290
+ # newer than this chi's), leave_seconds (its leave's budget
288
291
  # left, nil unless leaving), worker: {epoch, started_at, version,
289
292
  # features}|null, default_model (this machine's config, as the
290
293
  # wake's), parent_strategy}; 404 not_found.
@@ -292,7 +295,7 @@ module Samagotchi
292
295
  state = session_access.worker(id)
293
296
  leaving = state.leaving?
294
297
  json_response(200, { session_id: id, live: state.live?, stopped: state.stopped, owner: state.owner&.kind,
295
- leaving: leaving, leave_seconds: (state.owner.leave_left.round(1) if leaving),
298
+ leaving: leaving, stuck: state.stuck?, skew: state.skew?, leave_seconds: (state.owner.leave_left.round(1) if leaving),
296
299
  worker: state.sidecar && worker_json(state.sidecar),
297
300
  default_model: ModelProfile.default_model_name,
298
301
  parent_strategy: strategy_json(session_access.parent_strategy(id)) })
@@ -348,23 +351,28 @@ module Samagotchi
348
351
 
349
352
  # GET /api/sessions/:id/stats: the live worker's metrics for /stats
350
353
  # (its stats route; a worker from before it answers only its state's
351
- # metrics). 200 {session_id, metrics}; 503 not_live, 504
352
- # worker_timeout, 502 bridge_error.
354
+ # metrics). 200 {session_id, metrics}; 503 not_live, 504 worker_timeout,
355
+ # 502 bridge_secret or bridge_error.
353
356
  def handle_stats(_req, id)
354
357
  @session_class.load(id, state_dir: default_state_dir)
355
- client = bridge_client(id) or return with_message(not_live(id))
356
-
357
- status, json = client.get("stats")
358
- metrics = json["metrics"] if status == 200 && json.is_a?(Hash)
359
- if status == 404 && !(json.is_a?(Hash) && json["error"] == "unknown_session")
360
- status, json = client.get("state")
361
- metrics = json.dig("session_state_snapshot", "metrics") if status == 200 && json.is_a?(Hash)
358
+ request = lambda do |client|
359
+ response = client.get_response("stats")
360
+ if response.status == 404 && response.json&.dig("error") != "unknown_session"
361
+ response = client.get_response("state")
362
+ end
363
+ response
362
364
  end
363
- return json_response(200, { session_id: id, metrics: metrics }) if status == 200
364
- return with_message(worker_timeout("the stats were not read")) if status.nil?
365
- return with_message(not_live(id)) if status == 404
366
-
367
- with_message(error_response(502, "bridge_error", "the stats were not read: the session's worker answered #{status}"))
365
+ with_message(relay(id, bridge_client(id), request, what: "the stats were not read", cant: "read stats") do |reply|
366
+ next unless reply.status == 200
367
+
368
+ json = reply.json
369
+ metrics = if json.is_a?(Hash) && json["metrics"]
370
+ json["metrics"]
371
+ else
372
+ json&.dig("session_state_snapshot", "metrics")
373
+ end
374
+ json_response(200, { session_id: id, metrics: metrics })
375
+ end)
368
376
  end
369
377
 
370
378
  # GET /api/sessions/:id/children/counts: the session's delegates'
@@ -28,7 +28,7 @@ module Samagotchi
28
28
  # (request_toolkit, request_parent_answers, request_llm_context,
29
29
  # request_thinking). Included in App: its ROUTES name handle_create, and
30
30
  # its helpers (json_response, error_response, parse_json, request_body,
31
- # await_bridge_sidecar, session_to_json, record_history, scope_dir, …) and
31
+ # await_worker_link, session_to_json, record_history, scope_dir, …) and
32
32
  # ivars (@manager, @session_class, @state_dir, @hub) answer. See
33
33
  # docs/architecture.md (chi web's routes).
34
34
  module SessionCreate
@@ -130,7 +130,7 @@ module Samagotchi
130
130
  end
131
131
  # The worker's Bridge is the single live transport: wait (bounded) for
132
132
  # it so the client can attach without client-side polling.
133
- port = await_bridge_sidecar(session.id)&.port
133
+ port = await_worker_link(session.id).then { |link| link.live? ? link.port : nil }
134
134
  # The projection holds it before the page hears back (no tick wait).
135
135
  @hub&.touch(session.id)
136
136
  record_history(body, prompt) if !idle && PromptHistory.shell_line?(prompt.to_s.strip)
@@ -172,7 +172,7 @@ module Samagotchi
172
172
  model: model.empty? ? nil : model)
173
173
  return error if error
174
174
 
175
- port = await_bridge_sidecar(session.id)&.port
175
+ port = await_worker_link(session.id).then { |link| link.live? ? link.port : nil }
176
176
  moved = session.moved_children || []
177
177
  # The moved delegates' cards fold under the new link now.
178
178
  [session.continues, session.id, *moved].each { |sid| @hub&.touch(sid) }
@@ -228,7 +228,7 @@ module Samagotchi
228
228
  rescue ArgumentError => e
229
229
  return error_response(404, "not_found", e.message)
230
230
  end
231
- port = await_bridge_sidecar(session.id)&.port
231
+ port = await_worker_link(session.id).then { |link| link.live? ? link.port : nil }
232
232
  @hub&.touch(session.id)
233
233
  json_response(201, session_to_json(session).merge(bridge_port: port, model_warning: session.model_warning))
234
234
  end
@@ -7,7 +7,7 @@ require "time"
7
7
  require_relative "../session"
8
8
  require_relative "../session_manager"
9
9
  require_relative "../bridge_client"
10
- require_relative "../worker_sidecar"
10
+ require_relative "../worker_link"
11
11
  require_relative "../log"
12
12
  require_relative "../installed_versions"
13
13
  require_relative "../version"
@@ -353,11 +353,13 @@ module Samagotchi
353
353
  def live_stamp(owner, dir)
354
354
  return nil unless owner
355
355
 
356
- { pid: owner.pid, kind: owner.kind, bridge_started_at: bridge_started_at(dir) }
356
+ { pid: owner.pid, kind: owner.kind, bridge_started_at: bridge_started_at(dir, owner) }
357
357
  end
358
358
 
359
- def bridge_started_at(dir)
360
- WorkerSidecar.read(dir)&.started_at
359
+ # The Live worker's start (the owner already read is the one the link answers by).
360
+ def bridge_started_at(dir, owner)
361
+ link = WorkerLink.resolve(dir, unlink: false, owner: owner)
362
+ link.live? ? link.started_at : nil
361
363
  end
362
364
 
363
365
  def emit(type, data)
@@ -7,7 +7,7 @@ require_relative "../session_metrics"
7
7
  require_relative "../llm_context_strategy"
8
8
  require_relative "../host_registry"
9
9
  require_relative "../bridge_client"
10
- require_relative "../worker_sidecar"
10
+ require_relative "../worker_link"
11
11
  require_relative "../bridge/pending_card"
12
12
 
13
13
  module Samagotchi
@@ -37,8 +37,9 @@ module Samagotchi
37
37
  def build(session, owner:, session_dir:, status: displayed_status(session, owner: owner), root_cache: nil,
38
38
  registry: nil)
39
39
  used = Array(session.used_memory_names)
40
- up = bridge_up?(session_dir, owner)
41
- sidecar = up ? WorkerSidecar.read(session_dir) : nil
40
+ link = WorkerLink.resolve(session_dir, unlink: false, owner: owner)
41
+ up = link.live?
42
+ sidecar = up ? link.sidecar : nil
42
43
  saved = SessionMetrics.saved_summary(session_dir, budget_tokens: budget_resolver(session, registry))
43
44
  recap_card = RecapStore.card(session_dir)
44
45
  {
@@ -93,8 +94,9 @@ module Samagotchi
93
94
  # Hidden from the strip and the list unless "include archived".
94
95
  archived: ArchiveStore.archived?(session_dir),
95
96
  project_root: session.project_root(cache: root_cache),
96
- # A worker is reachable: its Bridge sidecar is there and the
97
- # owner lock is held. A sidecar a dead worker left is not up.
97
+ # A worker is reachable: WorkerLink answers Live (the lock held by
98
+ # a worker whose sidecar names its pid). A sidecar a dead worker
99
+ # left is not up.
98
100
  bridge_up: up,
99
101
  # The chi the live worker runs and what it can do (its sidecar;
100
102
  # nil and [] without one): the page's stale-worker badge.
@@ -103,7 +105,7 @@ module Samagotchi
103
105
  # For the tab's notifications (notify.js): an open question, the
104
106
  # running turn's open card with actions, and how the last turn
105
107
  # ended. A question saved by a worker that died is not open, nor a
106
- # chi REPL's (SessionManager.worker_live?'s rule: only a worker shares it).
108
+ # chi REPL's (only a worker shares it).
107
109
  pending_question: session.waiting_question(live: !!owner&.worker?),
108
110
  pending_card: up ? Bridge::PendingCard.read(session_dir) : nil,
109
111
  last_turn: session.last_turn&.to_file
@@ -129,16 +131,6 @@ module Samagotchi
129
131
  owner ? session.status : Session::STATUS_IDLE
130
132
  end
131
133
 
132
- # @return [Boolean] the sidecar file is there and the session has a
133
- # live owner (the file alone says nothing: the worker may be gone).
134
- # The owner lock, not WorkerSidecar.live's port probe: a list builds
135
- # this for every session, and the lock costs no connect.
136
- def bridge_up?(session_dir, owner)
137
- !owner.nil? && File.file?(WorkerSidecar.path(session_dir))
138
- rescue StandardError
139
- false
140
- end
141
-
142
134
  def first_preview_for(session)
143
135
  raw = session.first_preview || session.last_prompt || ""
144
136
  norm = raw.to_s.gsub(/\s+/, " ").strip
@@ -720,7 +720,7 @@ module Samagotchi
720
720
  # ends the sender's wait on it (chi send --wait, chi web's /wait).
721
721
  # @param origin [TurnOrigin] a participant's
722
722
  def refuse_participant_turn(origin, reason)
723
- TurnCheckpoint.record_refused(@session_dir, origin.enqueued_id, reason: reason)
723
+ TurnCheckpoint.record_refused(@session_dir, origin.enqueued_id, reason: reason, author: origin.participant_author)
724
724
  Participants.log(@session_id, :turn, state_dir: @state_dir, participant: origin.participant_id,
725
725
  enqueued_id: origin.enqueued_id, status: "refused", reason: reason)
726
726
  @engine.announce(type: :turn_dequeued, enqueued_id: origin.enqueued_id, client_id: origin.client_id)