samagotchi 0.124.0 → 0.126.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 (73) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +53 -1
  3. data/docs/architecture.md +4 -2
  4. data/docs/cli.md +20 -8
  5. data/docs/guardrails.md +22 -0
  6. data/docs/internals/participants.md +96 -3
  7. data/docs/internals/remote-sessions.md +2 -0
  8. data/docs/internals/web-push.md +10 -6
  9. data/docs/sessions.md +5 -2
  10. data/lib/samagotchi/bridge.rb +4 -3
  11. data/lib/samagotchi/bridge_client.rb +8 -1
  12. data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +25 -4
  13. data/lib/samagotchi/bundles/known-names/manifest.yml +2 -2
  14. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  15. data/lib/samagotchi/child_reports.rb +3 -0
  16. data/lib/samagotchi/children_status.rb +6 -1
  17. data/lib/samagotchi/engine.rb +43 -7
  18. data/lib/samagotchi/guardrail_wiring.rb +10 -1
  19. data/lib/samagotchi/llm/chat_loop.rb +1 -0
  20. data/lib/samagotchi/process_group.rb +10 -1
  21. data/lib/samagotchi/reminder_carry.rb +92 -0
  22. data/lib/samagotchi/reminder_store.rb +49 -0
  23. data/lib/samagotchi/session.rb +40 -3
  24. data/lib/samagotchi/session_inbox.rb +14 -8
  25. data/lib/samagotchi/session_manager.rb +39 -8
  26. data/lib/samagotchi/session_participants.rb +10 -2
  27. data/lib/samagotchi/sessions_command.rb +1 -1
  28. data/lib/samagotchi/terminal_ui/attached_loop/composer.rb +225 -0
  29. data/lib/samagotchi/terminal_ui/attached_loop/exit_commands.rb +138 -0
  30. data/lib/samagotchi/terminal_ui/attached_loop/questions.rb +225 -0
  31. data/lib/samagotchi/terminal_ui/attached_loop/queueing.rb +154 -0
  32. data/lib/samagotchi/terminal_ui/attached_loop/sending.rb +302 -0
  33. data/lib/samagotchi/terminal_ui/attached_loop.rb +16 -1253
  34. data/lib/samagotchi/terminal_ui/exit_lines.rb +141 -0
  35. data/lib/samagotchi/terminal_ui/join_replay.rb +235 -0
  36. data/lib/samagotchi/verdict.rb +20 -0
  37. data/lib/samagotchi/verify_anchor.rb +28 -0
  38. data/lib/samagotchi/verify_fresh.rb +275 -0
  39. data/lib/samagotchi/verify_fresh_origin.rb +71 -0
  40. data/lib/samagotchi/version.rb +1 -1
  41. data/lib/samagotchi/web/app.rb +10 -3
  42. data/lib/samagotchi/web/participant_access.rb +15 -4
  43. data/lib/samagotchi/web/participant_review.rb +7 -1
  44. data/lib/samagotchi/web/participant_view.rb +46 -5
  45. data/lib/samagotchi/web/participants_admin.rb +15 -4
  46. data/lib/samagotchi/web/public/annotate_bar.css +8 -4
  47. data/lib/samagotchi/web/public/annotate_bar.js +43 -7
  48. data/lib/samagotchi/web/public/app.js +134 -8
  49. data/lib/samagotchi/web/public/claim_link.js +347 -0
  50. data/lib/samagotchi/web/public/data.js +2 -1
  51. data/lib/samagotchi/web/public/index.html +1 -0
  52. data/lib/samagotchi/web/public/notify.js +13 -2
  53. data/lib/samagotchi/web/public/participant.html +3 -1
  54. data/lib/samagotchi/web/public/participant.js +113 -9
  55. data/lib/samagotchi/web/public/participants_panel.js +15 -0
  56. data/lib/samagotchi/web/public/route.js +3 -2
  57. data/lib/samagotchi/web/public/send_turn.js +6 -1
  58. data/lib/samagotchi/web/public/session_state.js +5 -0
  59. data/lib/samagotchi/web/public/stream_handlers.js +3 -0
  60. data/lib/samagotchi/web/public/tokens.css +2 -1
  61. data/lib/samagotchi/web/public/verdict_card.js +65 -0
  62. data/lib/samagotchi/web/public/verdict_fresh.js +120 -0
  63. data/lib/samagotchi/web/public/verify_card.css +17 -0
  64. data/lib/samagotchi/web/public/verify_preset.js +18 -0
  65. data/lib/samagotchi/web/push/attention.rb +25 -5
  66. data/lib/samagotchi/web/push/notifier.rb +4 -3
  67. data/lib/samagotchi/web/session_control.rb +2 -0
  68. data/lib/samagotchi/web/session_summary.rb +25 -1
  69. data/lib/samagotchi/web/verify_fresh_routes.rb +110 -0
  70. data/lib/samagotchi/worker.rb +125 -12
  71. data/lib/samagotchi/worker_idle_exit.rb +7 -6
  72. data/lib/samagotchi/worker_inbound.rb +4 -4
  73. metadata +19 -1
@@ -0,0 +1,347 @@
1
+ // A link to a claim (docs/internals/participants.md, Deep links): one turn
2
+ // of a session and a quote in it, on either page.
3
+ //
4
+ // participant page: <chi web>/#s=<id>&t=<turn8>&q=<quote>&p=<prefix>
5
+ // owner's page: #/s/<id>?t=<turn8>&q=<quote>&p=<prefix>
6
+ //
7
+ // t is the turn's id, its first 8 characters (a turn-opening message's
8
+ // turn_id, data-turn-id in the DOM); q the selection's rendered text,
9
+ // normalized and at most QUOTE_MAX characters; p the PREFIX_MAX characters
10
+ // before it in the same bubble, to tell two equal quotes apart (left out
11
+ // when there are none). URLSearchParams encodes both, so a link is
12
+ // URL-safe and a space is one "+".
13
+ //
14
+ // Normalized: whitespace collapsed, and what the participant page shows
15
+ // instead (ParticipantView::Redact): secret shapes "[redacted]" (so a link
16
+ // never carries one, and matches the participant's redacted text), and on
17
+ // the owner's page the session's folder relative ("<cwd>/x" → "x",
18
+ // "<cwd>" → "."), the home folder "~". Not the owner's token (Redact's
19
+ // viewer secrets): the page doesn't hold it.
20
+ //
21
+ // Opening one: the quote's bubble scrolled to and marked (the quote itself
22
+ // by the CSS Highlight API, where the browser has it); a quote not found
23
+ // marks the turn; a turn not found (one the page doesn't stamp) is looked
24
+ // for in the whole page, and then is the caller's (top + a toast).
25
+ //
26
+ // The text work is pure; the DOM part reads only Element/Range/TreeWalker,
27
+ // so it runs under `node --test` with happy-dom. A module of its own:
28
+ // static JS is cached at unversioned URLs, and a new export on an old
29
+ // module would blank the page.
30
+
31
+ export const QUOTE_MAX = 200;
32
+ export const PREFIX_MAX = 32;
33
+ export const TURN_CHARS = 8;
34
+ // The CSS highlight's name (::highlight(chi-claim)) and the marked
35
+ // element's class.
36
+ export const HIGHLIGHT = "chi-claim";
37
+ export const HIT_CLASS = "claim-hit";
38
+
39
+ const escapeRegExp = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
40
+
41
+ export const MARK = "[redacted]";
42
+ // ParticipantView::Redact's PATTERNS and VALUES
43
+ // (lib/samagotchi/web/participant_view.rb), as JS: keep them in step.
44
+ // spec/fixtures/redact_cases.json holds cases both sides read the same
45
+ // (participant_view_spec.rb, claim_link.test.js).
46
+ const SECRET_PATTERNS = [
47
+ /-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----[\s\S]*?(?:-----END [A-Z0-9 ]*PRIVATE KEY-----|$)/g,
48
+ /\bsk-(?:ant-|proj-)?[A-Za-z0-9_-]{16,}/g,
49
+ /\bgh[pousr]_[A-Za-z0-9]{20,}/g,
50
+ /\bgithub_pat_[A-Za-z0-9_]{20,}/g,
51
+ /\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/g,
52
+ /\bxox[abprs]-[A-Za-z0-9-]{10,}/g,
53
+ /\bchp_[A-Za-z0-9_-]{20,}/g,
54
+ /\bchc_[A-Za-z0-9_-]{43}/g,
55
+ /\bAIza[0-9A-Za-z_-]{30,}/g,
56
+ /\b[rs]k_(?:live|test)_[A-Za-z0-9]{16,}/g,
57
+ /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g,
58
+ /https:\/\/hooks\.slack\.com\/services\/[A-Za-z0-9/_-]+/g,
59
+ ];
60
+ // The value after group 1 is the secret.
61
+ const SECRET_VALUES = [
62
+ /(\b(?:Authorization|X-Chi-Bridge):\s*(?:Bearer\s+|Basic\s+|Token\s+)?)[^\s"'`]+/gi,
63
+ /((?<![A-Za-z0-9])[A-Za-z0-9_-]*?(?:token|api[_-]?key|access[_-]?key|secret|password|passwd)["']?\s*[=:]\s*["']?)[^\s"'&`,;}]+/gi,
64
+ /(\b[a-z][a-z0-9+.-]*:\/\/[^/\s:@]+:)[^@\s/]+(?=@)/gi,
65
+ ];
66
+
67
+ // The secrets in +raw+ as [{start, end, text: MARK}]; overlapping ones
68
+ // (a token= value holding a key shape) one span, as Redact's passes in a
69
+ // row leave one mark.
70
+ function secretSpans(raw) {
71
+ const found = [];
72
+ for (const re of SECRET_PATTERNS) for (const m of raw.matchAll(re)) found.push([m.index, m.index + m[0].length]);
73
+ for (const re of SECRET_VALUES) {
74
+ for (const m of raw.matchAll(re)) found.push([m.index + m[1].length, m.index + m[0].length]);
75
+ }
76
+ found.sort((a, b) => a[0] - b[0]);
77
+ const spans = [];
78
+ for (const [start, end] of found) {
79
+ const last = spans[spans.length - 1];
80
+ if (last && start < last.end) last.end = Math.max(last.end, end);
81
+ else spans.push({ start, end, text: MARK });
82
+ }
83
+ return spans;
84
+ }
85
+
86
+ // The spans of +raw+ that read differently on the participant page:
87
+ // [{start, end, text}], in order, not overlapping. Secrets first, then
88
+ // the session's folder (it is usually under home), as Redact.text.
89
+ function replacements(raw, { home = "", cwd = "" } = {}) {
90
+ const spans = secretSpans(raw);
91
+ const taken = (s, e) => spans.some((r) => s < r.end && e > r.start);
92
+ if (cwd.length > 1) {
93
+ for (const m of raw.matchAll(new RegExp(`${escapeRegExp(cwd)}(/|(?=\\b|$))`, "g"))) {
94
+ const end = m.index + m[0].length;
95
+ if (!taken(m.index, end)) spans.push({ start: m.index, end, text: m[1] === "/" ? "" : "." });
96
+ }
97
+ }
98
+ if (home.length > 1) {
99
+ for (const m of raw.matchAll(new RegExp(`${escapeRegExp(home)}(?=/|\\b|$)`, "g"))) {
100
+ const end = m.index + m[0].length;
101
+ if (!taken(m.index, end)) spans.push({ start: m.index, end, text: "~" });
102
+ }
103
+ }
104
+ return spans.sort((a, b) => a.start - b.start);
105
+ }
106
+
107
+ // +raw+ normalized, with where each of its characters came from: text,
108
+ // and starts[i]/ends[i] the raw offsets character i covers.
109
+ export function normalizeWithMap(raw, opts = {}) {
110
+ const source = String(raw ?? "");
111
+ const spans = replacements(source, opts);
112
+ let text = "";
113
+ const starts = [];
114
+ const ends = [];
115
+ let space = null;
116
+ const put = (ch, start, end) => {
117
+ if (/\s/.test(ch)) {
118
+ if (space) space.end = end;
119
+ else if (text) space = { start, end };
120
+ return;
121
+ }
122
+ if (space) {
123
+ text += " ";
124
+ starts.push(space.start);
125
+ ends.push(space.end);
126
+ space = null;
127
+ }
128
+ text += ch;
129
+ starts.push(start);
130
+ ends.push(end);
131
+ };
132
+ let i = 0;
133
+ let next = 0;
134
+ while (i < source.length) {
135
+ const span = spans[next];
136
+ if (span && span.start === i) {
137
+ // A folder shown "." or "" (its "/" gone) maps to the whole span.
138
+ for (const ch of span.text) put(ch, span.start, span.end);
139
+ i = span.end;
140
+ next += 1;
141
+ continue;
142
+ }
143
+ put(source[i], i, i + 1);
144
+ i += 1;
145
+ }
146
+ return { text, starts, ends };
147
+ }
148
+
149
+ export function normalizeClaim(raw, opts = {}) {
150
+ return normalizeWithMap(raw, opts).text;
151
+ }
152
+
153
+ // The first +max+ characters of +text+ (or the last, +fromEnd+), never
154
+ // half a surrogate pair.
155
+ function clip(text, max, fromEnd = false) {
156
+ const chars = Array.from(text);
157
+ if (chars.length <= max) return text;
158
+ return (fromEnd ? chars.slice(-max) : chars.slice(0, max)).join("");
159
+ }
160
+
161
+ // The claim's q and p for a selection: +selected+ its text, +before+ and
162
+ // +after+ the bubble's text around it. Normalized together, so a secret
163
+ // the selection cuts into is read whole and marked whole (never a piece
164
+ // of it in the link), and the prefix ends as the page reads it (one space
165
+ // between it and the quote, or none). q is "" when the selection holds no
166
+ // text.
167
+ export function claimQuote(selected, before = "", opts = {}, after = "") {
168
+ const pre = String(before ?? "");
169
+ const from = pre.length;
170
+ const to = from + String(selected ?? "").length;
171
+ const { text, starts, ends } = normalizeWithMap(`${pre}${selected ?? ""}${after ?? ""}`, opts);
172
+ let a = 0;
173
+ while (a < text.length && ends[a] <= from) a += 1;
174
+ let b = text.length;
175
+ while (b > a && starts[b - 1] >= to) b -= 1;
176
+ while (a < b && text[a] === " ") a += 1;
177
+ while (b > a && text[b - 1] === " ") b -= 1;
178
+ const q = text.slice(a, b);
179
+ if (!q) return { q: "", p: "" };
180
+ return { q: clip(q, QUOTE_MAX), p: clip(text.slice(0, a), PREFIX_MAX, true) };
181
+ }
182
+
183
+ // "t=…&q=…&p=…" for a claim {turnId, q, p}; empty parts left out.
184
+ export function claimParams({ turnId = "", q = "", p = "" } = {}) {
185
+ const params = new URLSearchParams();
186
+ if (turnId) params.set("t", String(turnId).slice(0, TURN_CHARS));
187
+ if (q) params.set("q", q);
188
+ if (q && p) params.set("p", p);
189
+ return params.toString();
190
+ }
191
+
192
+ // The participant page's link: +base+ the participant link's origin
193
+ // ("https://chi.example/", "http://192.168.1.5:4567/"), no token.
194
+ export function participantClaimLink(base, sessionId, claim) {
195
+ const params = claimParams(claim);
196
+ const root = String(base || "").replace(/#.*$/, "").replace(/\/?$/, "/");
197
+ return `${root}#s=${encodeURIComponent(sessionId)}${params ? `&${params}` : ""}`;
198
+ }
199
+
200
+ // The owner's page's hash for the claim.
201
+ export function ownerClaimHash(sessionId, claim) {
202
+ const params = claimParams(claim);
203
+ return `#/s/${encodeURIComponent(sessionId)}${params ? `?${params}` : ""}`;
204
+ }
205
+
206
+ // {sessionId, turn, q, p} from either page's hash, or null when it names
207
+ // no turn (a bare session link). The owner's page reads both forms: the
208
+ // participant link opened in the owner's browser shows the owner's page.
209
+ export function claimFromHash(hash) {
210
+ const raw = String(hash || "");
211
+ let id = null;
212
+ let query = "";
213
+ const owner = /^#\/s\/([^/?#]+)\?(.*)$/.exec(raw);
214
+ if (owner) {
215
+ try { id = decodeURIComponent(owner[1]); } catch { return null; }
216
+ query = owner[2];
217
+ } else if (raw.startsWith("#") && !raw.startsWith("#/")) {
218
+ query = raw.slice(1);
219
+ } else {
220
+ return null;
221
+ }
222
+ let params;
223
+ try { params = new URLSearchParams(query); } catch { return null; }
224
+ id ||= params.get("s");
225
+ const turn = params.get("t") || "";
226
+ if (!id || !/^[0-9a-f-]{1,36}$/i.test(turn)) return null;
227
+ return { sessionId: id, turn: turn.toLowerCase(), q: params.get("q") || "", p: params.get("p") || "" };
228
+ }
229
+
230
+ // Where the claim's text is in +texts+ (each root's raw text): {at, start,
231
+ // end} raw offsets in texts[at], or null. The prefix and the quote
232
+ // together first, then the quote alone (the bubble's text before it may
233
+ // read differently on the other page).
234
+ export function findClaim(texts, { q = "", p = "" } = {}, opts = {}) {
235
+ if (!q) return null;
236
+ const maps = texts.map((t) => normalizeWithMap(t, opts));
237
+ const needles = p ? [[`${p}${q}`, p.length], [q, 0]] : [[q, 0]];
238
+ for (const [needle, skip] of needles) {
239
+ for (let at = 0; at < maps.length; at += 1) {
240
+ const found = maps[at].text.indexOf(needle);
241
+ if (found < 0) continue;
242
+ const first = found + skip;
243
+ const last = found + needle.length - 1;
244
+ return { at, start: maps[at].starts[first], end: maps[at].ends[last] };
245
+ }
246
+ }
247
+ return null;
248
+ }
249
+
250
+ // ---- The DOM ----
251
+
252
+ // The turn-opening element whose data-turn-id starts with +turn+ in
253
+ // +container+, and the roots (+rootSelector+) from it to the next turn's
254
+ // opening: {anchor, roots}, or null.
255
+ export function turnRoots(container, turn, rootSelector) {
256
+ const anchors = [...container.querySelectorAll("[data-turn-id]")];
257
+ const at = anchors.findIndex((a) => turn && String(a.dataset.turnId || "").toLowerCase().startsWith(turn));
258
+ if (at < 0) return null;
259
+ const anchor = anchors[at];
260
+ const next = anchors[at + 1] || null;
261
+ const FOLLOWING = 4;
262
+ const roots = [...container.querySelectorAll(rootSelector)].filter((el) =>
263
+ (anchor === el || anchor.contains(el) || (anchor.compareDocumentPosition(el) & FOLLOWING))
264
+ && (!next || (!next.contains(el) && (el.compareDocumentPosition(next) & FOLLOWING))));
265
+ return { anchor, roots };
266
+ }
267
+
268
+ // The turn id of the turn +node+ is in (the last turn opening at or before
269
+ // it in +container+), or null.
270
+ export function turnIdOf(container, node) {
271
+ let found = null;
272
+ const PRECEDING = 2;
273
+ for (const anchor of container.querySelectorAll("[data-turn-id]")) {
274
+ if (anchor === node || anchor.contains(node) || (node.compareDocumentPosition(anchor) & PRECEDING)) found = anchor;
275
+ else break;
276
+ }
277
+ return found?.dataset.turnId || null;
278
+ }
279
+
280
+ // +root+'s text nodes and their text joined: {text, nodes: [{node, start}]}.
281
+ function textOf(root, doc) {
282
+ const walker = doc.createTreeWalker(root, 4 /* NodeFilter.SHOW_TEXT */);
283
+ const nodes = [];
284
+ let text = "";
285
+ for (let n = walker.nextNode(); n; n = walker.nextNode()) {
286
+ nodes.push({ node: n, start: text.length });
287
+ text += n.data;
288
+ }
289
+ return { text, nodes };
290
+ }
291
+
292
+ // The text node and offset of raw offset +at+ in a textOf result.
293
+ function pointAt({ nodes }, at, end = false) {
294
+ for (let i = nodes.length - 1; i >= 0; i -= 1) {
295
+ const { node, start } = nodes[i];
296
+ if (at > start || (at === start && !end) || i === 0) return { node, offset: Math.min(Math.max(at - start, 0), node.data.length) };
297
+ }
298
+ return null;
299
+ }
300
+
301
+ // The claim for a selection in +root+ (+range+ in it), as claimQuote's.
302
+ export function selectionClaim(root, range, opts = {}, doc = globalThis.document) {
303
+ const before = doc.createRange();
304
+ before.setStart(root, 0);
305
+ before.setEnd(range.startContainer, range.startOffset);
306
+ const after = doc.createRange();
307
+ after.setStart(range.endContainer, range.endOffset);
308
+ after.setEnd(root, root.childNodes.length);
309
+ return claimQuote(range.toString(), before.toString(), opts, after.toString());
310
+ }
311
+
312
+ // Finds the claim +claim+ ({turn, q, p}) in +container+ and shows it: the
313
+ // match's root (or the turn's opening) scrolled to the middle and marked
314
+ // HIT_CLASS, its closed <details> opened, the quote highlighted (CSS
315
+ // Highlight API). Without the turn (no opening carries its id: a
316
+ // non-person turn), the quote in any root of the page. @return "quote",
317
+ // "turn" or null (neither the turn nor the quote).
318
+ export function showClaim(container, claim, { roots: rootSelector, opts = {}, doc = globalThis.document, win = globalThis.window } = {}) {
319
+ clearClaim(container, { doc, win });
320
+ const turn = turnRoots(container, claim.turn, rootSelector);
321
+ const roots = turn ? turn.roots : [...container.querySelectorAll(rootSelector)];
322
+ const texts = roots.map((root) => textOf(root, doc));
323
+ const hit = findClaim(texts.map((t) => t.text), claim, opts);
324
+ if (!hit && !turn) return null;
325
+ const el = hit ? roots[hit.at] : turn.anchor;
326
+ for (let d = el.parentElement?.closest("details"); d; d = d.parentElement?.closest("details")) d.open = true;
327
+ el.classList.add(HIT_CLASS);
328
+ el.scrollIntoView?.({ block: "center" });
329
+ if (hit) {
330
+ const start = pointAt(texts[hit.at], hit.start);
331
+ const end = pointAt(texts[hit.at], hit.end, true);
332
+ const highlights = win?.CSS?.highlights;
333
+ if (start && end && highlights && typeof win.Highlight === "function") {
334
+ const range = doc.createRange();
335
+ range.setStart(start.node, start.offset);
336
+ range.setEnd(end.node, end.offset);
337
+ highlights.set(HIGHLIGHT, new win.Highlight(range));
338
+ }
339
+ }
340
+ return hit ? "quote" : "turn";
341
+ }
342
+
343
+ // The mark and highlight showClaim left, gone.
344
+ export function clearClaim(container, { win = globalThis.window } = {}) {
345
+ for (const el of container.querySelectorAll(`.${HIT_CLASS}`)) el.classList.remove(HIT_CLASS);
346
+ win?.CSS?.highlights?.delete?.(HIGHLIGHT);
347
+ }
@@ -140,11 +140,12 @@ export function getSession(id, { tail, recent, turnId, timing, parts, cards, ...
140
140
  // image-only send, whose text is only its [image: …] placeholders).
141
141
  // delivery: what the message does to a running turn ("cut": /cut TEXT);
142
142
  // none = the server's default (the next step).
143
- export function sendTurn(id, prompt, { clientId, images, history = true, delivery = null, ...opts } = {}) {
143
+ export function sendTurn(id, prompt, { clientId, images, history = true, delivery = null, verify = null, ...opts } = {}) {
144
144
  const body = clientId ? { prompt, client_id: clientId } : { prompt };
145
145
  if (images?.length) body.images = images;
146
146
  if (delivery) body.delivery = delivery;
147
147
  if (history === false) body.history = false;
148
+ if (verify) body.verify = verify;
148
149
  return api(`/api/sessions/${id}/turn`, {
149
150
  method: "POST",
150
151
  body: JSON.stringify(body),
@@ -10,6 +10,7 @@
10
10
  <meta name="apple-mobile-web-app-title" content="Chi">
11
11
  <meta name="viewport" content="width=device-width,initial-scale=1">
12
12
  <link rel="stylesheet" href="/assets/tokens.css">
13
+ <link rel="stylesheet" href="/assets/verify_card.css">
13
14
  <link rel="stylesheet" href="/assets/annotate_bar.css">
14
15
  <link rel="stylesheet" href="/assets/composer.css">
15
16
  <link rel="stylesheet" href="/assets/forms.css">
@@ -20,6 +20,7 @@ export function notifyState({ hasNotification, secureContext, permission, wanted
20
20
  // (task): nobody started it, so nobody watched it.
21
21
  const DONE_MIN_SECONDS = 10;
22
22
  const UNWATCHED_ORIGINS = ["delegate_report", "context", "task"];
23
+ const VERDICT_REASON_MAX = 120;
23
24
 
24
25
  // The step-limit question (kind "continue": a turn ran out of iterations
25
26
  // and asks whether to go on) has its own reason: "out of steps".
@@ -52,7 +53,7 @@ const RELAYED_KINDS = new Set(["approval", "hook"]);
52
53
  // What changed between two summaries of one session that needs the user.
53
54
  // @param prev the summary held before (null for a session not seen yet)
54
55
  // @param next the new summary
55
- // @return null | {reason: "question"|"approval"|"card"|"failed"|"done", sessionId, key}
56
+ // @return null | {reason: "question"|"approval"|"card"|"failed"|"done"|"verdict", sessionId, key}
56
57
  // key names this one event, the same in every tab (the Notification tag);
57
58
  // holdMs, when there, delays it (a delegated child's approval)
58
59
  export function attentionFor(prev, next) {
@@ -75,6 +76,10 @@ export function attentionFor(prev, next) {
75
76
  // A delegated child's turn is reported by its parent's.
76
77
  if (next.parent_id) return null;
77
78
  const key = `${next.id}:${turn.ended_at}`;
79
+ const verdict = next.last_verdict;
80
+ if (verdict?.check_turn_id && verdict.check_turn_id !== prev?.last_verdict?.check_turn_id) {
81
+ return { reason: "verdict", sessionId: next.id, key: `${next.id}:${verdict.check_turn_id}` };
82
+ }
78
83
  if (turn.outcome === "failed") return { reason: "failed", sessionId: next.id, key };
79
84
  if (turn.outcome === "completed" && (Number(turn.seconds) >= DONE_MIN_SECONDS || UNWATCHED_ORIGINS.includes(turn.origin))) {
80
85
  return { reason: "done", sessionId: next.id, key };
@@ -157,13 +162,19 @@ export function attentionText(session, attention) {
157
162
  const name = String(session?.title || session?.auto_title || session?.recap || session?.first_preview || session?.last_prompt || "chi session").trim();
158
163
  const title = clip(name, 61);
159
164
  const seconds = Math.round(Number(session?.last_turn?.seconds) || 0);
165
+ const verdict = session?.last_verdict;
166
+ const who = verdict?.by_name || (verdict?.by ? "A participant" : "You");
167
+ const verdictReason = clip(String(verdict?.reason || ""), VERDICT_REASON_MAX);
168
+ const verdictIcon = { confirmed: "✅", refuted: "❌", unclear: "❓" }[verdict?.verdict] || "";
169
+ const verdictBody = `${who}: ${verdictIcon} ${verdict?.verdict || ""} — ${verdictReason}`;
160
170
  const body = {
161
171
  question: "needs an answer",
162
172
  approval: "needs approval",
163
173
  continue: "hit its step limit",
164
174
  card: "needs you",
165
175
  failed: "turn failed",
166
- done: `done in ${seconds} s`
176
+ done: `done in ${seconds} s`,
177
+ verdict: verdictBody
167
178
  }[attention.reason] || "";
168
179
  return { title, body };
169
180
  }
@@ -6,6 +6,7 @@
6
6
  <link rel="icon" href="/assets/favicon.svg" type="image/svg+xml">
7
7
  <meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
8
8
  <link rel="stylesheet" href="/assets/tokens.css">
9
+ <link rel="stylesheet" href="/assets/verify_card.css">
9
10
  <link rel="stylesheet" href="/assets/annotate_bar.css">
10
11
  <link rel="stylesheet" href="/assets/composer.css">
11
12
  <link rel="stylesheet" href="/assets/forms.css">
@@ -94,7 +95,7 @@ main{flex:1;min-height:0;overflow-y:auto;padding:16px 16px 8px}
94
95
  .p-tools{font-size:12.5px;color:var(--text-muted)}
95
96
  .p-tools summary{cursor:pointer;list-style:none;display:inline-flex;align-items:center;gap:6px;padding:2px 0}
96
97
  .p-tools summary::-webkit-details-marker{display:none}
97
- .p-tools summary::before{content:"▸";font-size:10px}
98
+ .p-tools summary::before{content:"▸"}
98
99
  .p-tools[open] summary::before{content:"▾"}
99
100
  .p-tools ul{list-style:none;padding:2px 0 0 14px;display:flex;flex-direction:column;gap:1px}
100
101
  .p-tools li{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
@@ -171,6 +172,7 @@ button.small{font-size:12px;padding:2px 8px}
171
172
  <div class="p-note" id="note"></div>
172
173
  </div>
173
174
  <div class="p-offline" id="offline" role="status" hidden>Can't reach the session — retrying…</div>
175
+ <div class="p-offline" id="claimNote" role="status" hidden></div>
174
176
  </header>
175
177
  <div class="p-body">
176
178
  <aside class="p-plan" id="plan" hidden aria-label="Under review">