samagotchi 0.170.0 → 0.171.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 (120) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +29 -1
  3. data/docs/architecture.md +2 -2
  4. data/docs/cli.md +37 -23
  5. data/docs/configuration.md +1 -1
  6. data/docs/internals/block-renderers.md +2 -2
  7. data/docs/internals/bridge.md +5 -4
  8. data/docs/internals/context-telemetry.md +13 -1
  9. data/docs/internals/engine.md +4 -4
  10. data/docs/internals/inline-widgets.md +1 -1
  11. data/docs/internals/objectives.md +1 -1
  12. data/docs/internals/participants.md +235 -119
  13. data/docs/internals/web-frontend.md +164 -33
  14. data/docs/internals/working-set.md +69 -5
  15. data/docs/releasing.md +2 -1
  16. data/docs/sessions.md +6 -3
  17. data/lib/samagotchi/bridge/turn_accumulator.rb +8 -4
  18. data/lib/samagotchi/bridge.rb +39 -12
  19. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  20. data/lib/samagotchi/delivery.rb +13 -0
  21. data/lib/samagotchi/engine.rb +5 -1
  22. data/lib/samagotchi/event_fanout.rb +22 -1
  23. data/lib/samagotchi/grants.rb +5 -0
  24. data/lib/samagotchi/guardrails/review_paths.rb +2 -2
  25. data/lib/samagotchi/image_store.rb +51 -12
  26. data/lib/samagotchi/kernel_loop.rb +7 -1
  27. data/lib/samagotchi/llm/stray_tool_call.rb +7 -4
  28. data/lib/samagotchi/llm_context_stale.rb +4 -1
  29. data/lib/samagotchi/participant_messages.rb +39 -0
  30. data/lib/samagotchi/participant_revise.rb +61 -0
  31. data/lib/samagotchi/participant_uploads.rb +98 -0
  32. data/lib/samagotchi/reply_wait/poll.rb +5 -1
  33. data/lib/samagotchi/revise_anchor.rb +9 -0
  34. data/lib/samagotchi/send_command.rb +5 -0
  35. data/lib/samagotchi/session_inbox.rb +20 -12
  36. data/lib/samagotchi/session_manager/delivery.rb +7 -0
  37. data/lib/samagotchi/session_stores.rb +2 -0
  38. data/lib/samagotchi/tool_runner.rb +39 -5
  39. data/lib/samagotchi/tools/memory.rb +26 -7
  40. data/lib/samagotchi/turn_anchors.rb +0 -3
  41. data/lib/samagotchi/turn_stages/verify_anchors.rb +13 -1
  42. data/lib/samagotchi/verify_anchor.rb +6 -0
  43. data/lib/samagotchi/version.rb +1 -1
  44. data/lib/samagotchi/web/app.rb +42 -77
  45. data/lib/samagotchi/web/participant_access.rb +129 -26
  46. data/lib/samagotchi/web/participant_images.rb +185 -0
  47. data/lib/samagotchi/web/participant_review.rb +19 -138
  48. data/lib/samagotchi/web/participant_show.rb +240 -0
  49. data/lib/samagotchi/web/participant_stream_filter.rb +25 -2
  50. data/lib/samagotchi/web/participant_view.rb +331 -24
  51. data/lib/samagotchi/web/public/annotate_bar.css +1 -2
  52. data/lib/samagotchi/web/public/annotate_bar.js +1 -2
  53. data/lib/samagotchi/web/public/app.js +124 -88
  54. data/lib/samagotchi/web/public/block_host.js +7 -4
  55. data/lib/samagotchi/web/public/bubbles.js +9 -1
  56. data/lib/samagotchi/web/public/cards_offscreen.js +69 -0
  57. data/lib/samagotchi/web/public/chip_popover.js +5 -4
  58. data/lib/samagotchi/web/public/claim_link.js +4 -4
  59. data/lib/samagotchi/web/public/composer.css +4 -6
  60. data/lib/samagotchi/web/public/composer_mode.js +11 -6
  61. data/lib/samagotchi/web/public/composer_mount.js +4 -4
  62. data/lib/samagotchi/web/public/ctx.js +12 -3
  63. data/lib/samagotchi/web/public/delivery_line.js +34 -5
  64. data/lib/samagotchi/web/public/history_shape.js +5 -1
  65. data/lib/samagotchi/web/public/images.js +7 -1
  66. data/lib/samagotchi/web/public/index.html +40 -4
  67. data/lib/samagotchi/web/public/info_bar_model.js +2 -1
  68. data/lib/samagotchi/web/public/markdown_place.js +5 -4
  69. data/lib/samagotchi/web/public/owner_claim.js +3 -3
  70. data/lib/samagotchi/web/public/owner_verify.js +0 -2
  71. data/lib/samagotchi/web/public/participant_bar.js +108 -0
  72. data/lib/samagotchi/web/public/participant_main.js +289 -0
  73. data/lib/samagotchi/web/public/participant_model.js +5 -292
  74. data/lib/samagotchi/web/public/participant_names.js +6 -1
  75. data/lib/samagotchi/web/public/participant_revise.js +66 -0
  76. data/lib/samagotchi/web/public/participants_panel.js +3 -12
  77. data/lib/samagotchi/web/public/question_cards.js +14 -3
  78. data/lib/samagotchi/web/public/refs.js +53 -36
  79. data/lib/samagotchi/web/public/reload_check.js +68 -11
  80. data/lib/samagotchi/web/public/revise_stack.js +21 -10
  81. data/lib/samagotchi/web/public/send_turn.js +24 -6
  82. data/lib/samagotchi/web/public/session_fields.js +1 -1
  83. data/lib/samagotchi/web/public/session_state.js +12 -4
  84. data/lib/samagotchi/web/public/session_sync.js +9 -2
  85. data/lib/samagotchi/web/public/session_view.js +17 -7
  86. data/lib/samagotchi/web/public/stage_asking.js +6 -4
  87. data/lib/samagotchi/web/public/stage_owner_wait.js +35 -0
  88. data/lib/samagotchi/web/public/stage_view.js +18 -4
  89. data/lib/samagotchi/web/public/stage_waiting.js +74 -6
  90. data/lib/samagotchi/web/public/storage.js +22 -0
  91. data/lib/samagotchi/web/public/stream_handlers.js +2 -1
  92. data/lib/samagotchi/web/public/tokens.css +1 -1
  93. data/lib/samagotchi/web/public/turn_block.js +3 -2
  94. data/lib/samagotchi/web/public/turn_events.js +15 -6
  95. data/lib/samagotchi/web/public/turn_html.js +9 -2
  96. data/lib/samagotchi/web/public/turn_range.js +1 -2
  97. data/lib/samagotchi/web/public/verify_target.js +2 -4
  98. data/lib/samagotchi/web/public/viewer.js +59 -0
  99. data/lib/samagotchi/web/public/viewer_self.js +53 -0
  100. data/lib/samagotchi/web/public/working_set.js +11 -5
  101. data/lib/samagotchi/web/public/working_set_view.js +53 -8
  102. data/lib/samagotchi/web/public/ws_cards.js +89 -0
  103. data/lib/samagotchi/web/public/ws_peek.js +106 -0
  104. data/lib/samagotchi/web/public/ws_preview.css +126 -0
  105. data/lib/samagotchi/web/public/ws_reader.js +319 -0
  106. data/lib/samagotchi/web/public/ws_timeline.js +200 -0
  107. data/lib/samagotchi/web/public/ws_way_in.js +25 -0
  108. data/lib/samagotchi/web/session_control.rb +39 -14
  109. data/lib/samagotchi/web/session_create.rb +1 -1
  110. data/lib/samagotchi/web/session_show.rb +75 -28
  111. data/lib/samagotchi/web/viewer.rb +70 -0
  112. data/lib/samagotchi/web/working_set_routes.rb +132 -0
  113. data/lib/samagotchi/worker.rb +19 -14
  114. data/lib/samagotchi/working_set.rb +260 -8
  115. metadata +20 -6
  116. data/lib/samagotchi/web/public/participant.html +0 -218
  117. data/lib/samagotchi/web/public/participant.js +0 -719
  118. data/lib/samagotchi/web/public/participant_annotate.js +0 -76
  119. data/lib/samagotchi/web/public/review_history.js +0 -72
  120. data/lib/samagotchi/web/public/tools_overview.js +0 -42
@@ -7,7 +7,7 @@ export const CLAIM_ROOTS = ".bubble.output, .bubble.user .user-message, .gen-tex
7
7
  // The owner page's claim links (used by app.js; claim_link.js does the text
8
8
  // and DOM work): a claim link opened on this page (#/s/<id>?t=…&q=…, or the
9
9
  // participant's #s=<id>&t=… opened in this browser) shown once its session is
10
- // drawn, and annotate's "Copy link" for a selection. The participant page has
10
+ // drawn, and annotate's "Copy link" for a selection. A participant's page has
11
11
  // its own copies on purpose (docs/internals/web-frontend.md, Two pages).
12
12
  //
13
13
  // +historyEl+: #history. +store+: the page's session store (selected,
@@ -22,7 +22,7 @@ export function createOwnerClaim({
22
22
  // A claim link waiting for its session to be drawn (selectFromHash sets it).
23
23
  let pendingClaim = null;
24
24
 
25
- // How the participant page shows this page's text (claim_link.js).
25
+ // How a participant's page shows this page's text (claim_link.js).
26
26
  function opts() {
27
27
  return { home, cwd: store.current.dir || "" };
28
28
  }
@@ -50,7 +50,7 @@ export function createOwnerClaim({
50
50
  }
51
51
 
52
52
  // Annotate's "Copy link" for a selection in a turn's answer, step or own
53
- // message: the participant page's link when the session has a person
53
+ // message: a participant's page's link when the session has a person
54
54
  // active (it opens in the browser their review link was opened in: one
55
55
  // link per browser, the cookie), else this page's own.
56
56
  function linkFor(source, range) {
@@ -12,8 +12,6 @@ import { OWNER_FINAL, verifyTurnOf } from "./verify_target.js";
12
12
  // verdict's card (verdict_fresh.js), Verify's claim from a selection, and
13
13
  // the composer's armed claim (anchor/set/clear: the one holder; the next
14
14
  // send makes it a Verify turn).
15
- // The participant page has its own copies on purpose
16
- // (docs/internals/web-frontend.md, Two pages).
17
15
  //
18
16
  // +historyEl+: #history. +store+: the page's session store (it writes
19
17
  // current.verdicts and current.verifyFresh through store.update; reads
@@ -0,0 +1,108 @@
1
+ // What the main page shows a participant in the owner's bar's places
2
+ // (the participant main page, scenes 1-5), as data and lit views, no page
3
+ // state: participant_main.js feeds it.
4
+ //
5
+ // the top row their name ("Alex · invited") and, on a desktop, when the
6
+ // link ends ("link ends 20 Nov")
7
+ // the bottom the composer card's footer: the status, context %, the
8
+ // row speed (the turn's time while one runs), their spending
9
+ // ("$0.42 of $2.00 used"), and on a desktop what their
10
+ // turns may do ("chi acts under the owner's rules")
11
+ // the closed the composer given way to one line: their limit reached,
12
+ // line the session stopped, the link ended
13
+ //
14
+ // Never the owner's cost, memories, the model or a path: only what the
15
+ // show's viewer (ParticipantShow#participant_main_viewer) and the
16
+ // session's numbers carry. See docs/internals/web-frontend.md (The
17
+ // participant's page).
18
+ import { html, nothing } from "./vendor/lit-html.js";
19
+ import { ctxBarText, speedText, costText } from "./ctx.js";
20
+ import { formatDuration } from "./timing.js";
21
+
22
+ // "20 Nov" (the viewer's locale's day and month) of an ISO time; "" when
23
+ // it doesn't parse.
24
+ export function endsText(iso) {
25
+ const at = Date.parse(iso || "");
26
+ if (!Number.isFinite(at)) return "";
27
+ return new Date(at).toLocaleDateString(undefined, { day: "numeric", month: "short" });
28
+ }
29
+
30
+ // "$0.42 of $2.00 used", "$0.42 used" without a cap; "" when their grant's
31
+ // exposure doesn't show the spend.
32
+ export function spentText(usage) {
33
+ const spent = Number(usage?.spent);
34
+ if (usage?.spent == null || !Number.isFinite(spent)) return "";
35
+ const shown = spent > 0 ? costText(spent) : "$0.00";
36
+ const cap = Number(usage?.cap);
37
+ return usage?.cap != null && Number.isFinite(cap) ? `${shown} of ${money(cap)} used` : `${shown} used`;
38
+ }
39
+
40
+ const money = (value) => `$${Number(value).toFixed(2)}`;
41
+
42
+ // What their turns may do, by the grant's mode (pair: they act; review:
43
+ // they only read).
44
+ export const RULES_TEXT = { pair: "chi acts under the owner's rules", review: "your turns only read" };
45
+
46
+ // The bottom row's items: [{key, text, tip}].
47
+ // status "idle" | "running" | "waiting"
48
+ // ctxPct the context's fill (store.current.ctxPct), windowSource its window's source
49
+ // speed {decode_tps, source} of the last generation, or null
50
+ // turnMs the running turn's time so far, or null
51
+ // usage the show's viewer.usage
52
+ // mode the show's viewer.mode
53
+ export function barItems({ status, ctxPct = null, windowSource = null, speed = null, turnMs = null, usage = null, mode = null }) {
54
+ const items = [{ key: "status", text: status, tip: STATUS_TIPS[status] || "" }];
55
+ items.push({ key: "ctx", text: ctxBarText(ctxPct, windowSource), tip: "How full the session's context is" });
56
+ if (turnMs != null) items.push({ key: "time", text: formatDuration(turnMs), tip: "This turn's time so far" });
57
+ else {
58
+ const tps = speedText(speed?.decode_tps, speed?.source);
59
+ if (tps) items.push({ key: "speed", text: tps, tip: "Decode speed of the last generation" });
60
+ }
61
+ const spent = spentText(usage);
62
+ if (spent) items.push({ key: "spent", text: spent, tip: "What your turns have cost, of the limit the owner set for you" });
63
+ const rules = RULES_TEXT[mode] || "";
64
+ return { items, rules };
65
+ }
66
+
67
+ const STATUS_TIPS = {
68
+ idle: "No turn runs now",
69
+ running: "A turn runs now",
70
+ waiting: "The running turn waits for the owner",
71
+ };
72
+
73
+ export function barTemplate({ items, rules }) {
74
+ return html`${items.map((item) => html`<span class=${`pb-pill pp-${item.key}`} data-key=${item.key} title=${item.tip || nothing}>${item.text}</span>`)}<span class="pb-gap"></span>${rules ? html`<span class="pb-rules">${rules}</span>` : nothing}`;
75
+ }
76
+
77
+ // The top row's chip and the link's end.
78
+ // +color+: their author colour (participant_model.js authorColor), as their badges.
79
+ export function whoTemplate({ name, endsAt, color = null }) {
80
+ const ends = endsText(endsAt);
81
+ return html`<span class="pb-who" title="You were invited to this session by its owner" style=${color ? `--who-color:${color}` : nothing}><b>${name || "you"}</b> · invited</span>${ends ? html`<span class="pb-ends" title=${`This link stops working on ${ends}`}>link ends ${ends}</span>` : nothing}`;
82
+ }
83
+
84
+ // The closed composer's line, or null when the composer stays:
85
+ // capReached the show's viewer.cap_reached (or a send refused for it)
86
+ // usage the show's viewer.usage (the cap's amount)
87
+ // refusal {code, text}: the server's words for a send refused as
88
+ // session_stopped, or the link that ended (token_revoked,
89
+ // token_expired): shown as they are
90
+ // → {kind, title, text}
91
+ export function closedLine({ capReached = false, usage = null, refusal = null } = {}) {
92
+ if (refusal?.text) {
93
+ const text = String(refusal.text);
94
+ return { kind: refusal.code || "refused", title: `${text[0].toUpperCase()}${text.slice(1)}${/[.!?]$/.test(text) ? "" : "."}`, text: "You can keep reading." };
95
+ }
96
+ if (!capReached) return null;
97
+ const cap = Number(usage?.cap);
98
+ const amount = usage?.cap != null && Number.isFinite(cap) ? `the ${money(cap)}` : "the limit";
99
+ return { kind: "cap_reached", title: `You have used ${amount} the owner set for you.`, text: "You can keep reading. Ask the owner to raise the limit to send more." };
100
+ }
101
+
102
+ export function closedTemplate(line) {
103
+ return html`<b>${line.title}</b>${line.text}`;
104
+ }
105
+
106
+ // The codes of a refused send that close the composer (the rest are a
107
+ // toast with the server's words): their state, not this message's.
108
+ export const CLOSING_CODES = Object.freeze(["cap_reached", "session_stopped", "token_revoked", "token_expired"]);
@@ -0,0 +1,289 @@
1
+ // The main page for a participant (an invited person's link): what is
2
+ // theirs on the owner's page, wired from app.js in a few lines. The
3
+ // owner's page never makes it (app.js: viewer.kind() is "participant").
4
+ //
5
+ // - The grant's session (<body data-session>) is the page's one session,
6
+ // whatever the hash says; no start page, no list.
7
+ // - Who they are (viewer_self.js), from each show's `viewer`: their own
8
+ // turns and prompts, the owner's labelled "owner".
9
+ // - The top row's name and the link's end, the bottom row in the bar's
10
+ // place, the closed composer (participant_bar.js).
11
+ // - The running turn: whose it is (turn_started's author and `mine`), so
12
+ // Stop turn shows on their own only and the delivery line says where a
13
+ // message goes; and whether it waits for the owner (question_requested
14
+ // / continue_offered's `kind`; stage_owner_wait.js draws it).
15
+ // - Their usage and limit, read again from the show at each turn's end
16
+ // (the light re-reads carry no viewer).
17
+ // - A link that ended mid-session (a 401 from chi web): the composer gives
18
+ // way to the server's sentence, instead of the owner's signed-out banner.
19
+ //
20
+ // See docs/internals/web-frontend.md (The participant's page).
21
+ import { render } from "./vendor/lit-html.js";
22
+ import { setViewerSelf } from "./viewer_self.js";
23
+ import { authorColor, participantAuthor } from "./participant_model.js";
24
+ import { barItems, barTemplate, CLOSING_CODES, closedLine, closedTemplate, whoTemplate } from "./participant_bar.js";
25
+
26
+ // The worker features a participant's composer reads: their message goes
27
+ // into their own running turn at its next step (Delivery::OWN_TURN_STEER_FEATURE).
28
+ export const PARTICIPANT_STEER = "participant_steer";
29
+ // A 401 whose words the page couldn't read (an auth proxy's own answer).
30
+ export const LINK_ENDED = "this link no longer opens the session";
31
+
32
+ // The events that end a turn: whose turn it was and its wait go, and the
33
+ // viewer is read again (their spend moved).
34
+ const TURN_ENDS = ["turn_completed", "turn_canceled", "turn_failed"];
35
+ // The owner's question, approval or Continue offer, and their answers:
36
+ // chi web sends a participant the kind alone, never a card's content.
37
+ const WAITS = ["question_requested", "continue_offered"];
38
+ const WAIT_ENDS = ["question_answered", "question_cancelled", "continue_resolved"];
39
+
40
+ // @param body the page's <body> (data-session, data-session-title)
41
+ // @param topBar #topBar: the logo, title, their name, the link's end
42
+ // @param infoBar #infoBar: the bottom row takes its place
43
+ // @param composer #composer: given way to the closed line
44
+ // @param store the session store (store.current: ctxPct, timing, workerFeatures)
45
+ // @param running() a turn runs (liveTurn.running)
46
+ // @param stage the stage view (turnDom): redraw()
47
+ // @param read(id) the session's show (data.js getSession)
48
+ // @param probe(id) a fetch Response of the session's brief read, whose 401 carries the ended link's words
49
+ // @param onClosed() the composer closed or opened again (the Revise buttons follow)
50
+ export function createParticipantMain({ body, topBar, infoBar, composer, store, running, stage, read, onClosed = () => {},
51
+ probe = (id) => fetch(`/api/sessions/${encodeURIComponent(id)}?brief=1`, { headers: { Accept: "application/json" } }) }) {
52
+ const sessionId = body.dataset.session || null;
53
+ let viewer = null;
54
+ // The running turn's author: {author, name, mine}; null between turns.
55
+ let turnBy = null;
56
+ // What the running turn waits for the owner on (a kind), or null.
57
+ let waitKind = null;
58
+ // The last generation's speed ({decode_tps, source}).
59
+ let speed = null;
60
+ // A refusal that closed the composer ({code, text}).
61
+ let refusal = null;
62
+ let reading = null;
63
+ // A second turn ended while a re-read was out: one more after it.
64
+ let readAgain = false;
65
+ // What the last show said the session waits for the owner on: a reload
66
+ // or a resync mid-wait gets no question event (no cards for them), only
67
+ // this; the replay's turn_started keeps it.
68
+ let showWait = null;
69
+ // Live turn starts so far: a re-read begun before one is stale about the wait.
70
+ let starts = 0;
71
+ // The session's title as the last show read it (chi web's participant title).
72
+ let title = null;
73
+
74
+ // The top row: a plain logo (not the owner's new-session button), their
75
+ // name and the link's end after the title.
76
+ const logo = document.createElement("span");
77
+ logo.className = "brand plain-brand";
78
+ logo.textContent = "chi";
79
+ topBar.prepend(logo);
80
+ const who = document.createElement("span");
81
+ who.id = "participantWho";
82
+ topBar.querySelector("#sessionTitleRow")?.after(who);
83
+ // The bottom row, in the owner's info line's place.
84
+ const row = document.createElement("div");
85
+ row.id = "participantBar";
86
+ infoBar.prepend(row);
87
+ infoBar.querySelector("#infoText")?.setAttribute("hidden", "");
88
+ const closed = document.createElement("div");
89
+ closed.id = "participantClosed";
90
+ closed.hidden = true;
91
+ composer.after(closed);
92
+ body.classList.add("participant-view");
93
+
94
+ const redraw = () => store.touch("infoBar");
95
+
96
+ function drawTop() {
97
+ const color = viewer?.id ? authorColor(participantAuthor(viewer.id)) : null;
98
+ render(whoTemplate({ name: viewer?.name, endsAt: viewer?.usage?.expires_at, color }), who);
99
+ }
100
+
101
+ function drawClosed() {
102
+ const line = closedLine({ capReached: !!viewer?.cap_reached, usage: viewer?.usage, refusal });
103
+ const flips = closed.hidden === !!line;
104
+ closed.hidden = !line;
105
+ composer.hidden = !!line;
106
+ body.classList.toggle("participant-closed", !!line);
107
+ if (line) {
108
+ closed.dataset.kind = line.kind;
109
+ render(closedTemplate(line), closed);
110
+ }
111
+ if (flips) onClosed();
112
+ }
113
+
114
+ // A show's viewer: who they are, their usage, whether the session waits.
115
+ // +fresh+ false: a re-read a turn started after (its wait is stale).
116
+ function shown(next, { fresh = true } = {}) {
117
+ if (!next || next.kind !== "participant") return;
118
+ viewer = next;
119
+ setViewerSelf(next);
120
+ // A session the owner started again (it runs, or was read not stopped)
121
+ // takes the stopped line back.
122
+ if (refusal?.code === "session_stopped" && !next.session_stopped) refusal = null;
123
+ if (fresh) {
124
+ showWait = next.waiting_for_owner || null;
125
+ if (running()) waitKind = showWait;
126
+ }
127
+ drawTop();
128
+ drawClosed();
129
+ stage.redraw?.();
130
+ redraw();
131
+ }
132
+
133
+ // The viewer again from the show (its usage moved, or its cap).
134
+ async function reread() {
135
+ if (!sessionId) return null;
136
+ if (reading) {
137
+ readAgain = true;
138
+ return reading;
139
+ }
140
+ const at = starts;
141
+ reading = read(sessionId).then((data) => {
142
+ if (typeof data?.session?.title === "string" && data.session.title) title = data.session.title;
143
+ shown(data?.viewer, { fresh: at === starts });
144
+ }, () => {}).finally(() => {
145
+ reading = null;
146
+ if (readAgain) {
147
+ readAgain = false;
148
+ reread();
149
+ }
150
+ });
151
+ return reading;
152
+ }
153
+
154
+ function started(data) {
155
+ turnBy = { author: data?.author || null, name: data?.author_name || null, mine: !!data?.mine };
156
+ // A replayed start (a load or resync mid-turn) is the turn the show read:
157
+ // its wait stands. A live one starts unwaited.
158
+ if (data?.replayed) waitKind = showWait;
159
+ else {
160
+ starts += 1;
161
+ waitKind = null;
162
+ showWait = null;
163
+ }
164
+ if (refusal?.code === "session_stopped") {
165
+ refusal = null;
166
+ drawClosed();
167
+ }
168
+ }
169
+
170
+ // The stream's handlers (stream_handlers.js) with a participant's own
171
+ // reading of the events chi web sends them bare or cut down.
172
+ function wrapHandlers(handlers) {
173
+ const wrapped = { ...handlers };
174
+ wrapped.turn_started = (data) => {
175
+ started(data);
176
+ handlers.turn_started?.(data);
177
+ stage.redraw?.();
178
+ };
179
+ for (const type of TURN_ENDS) {
180
+ wrapped[type] = (data) => {
181
+ waitKind = null;
182
+ if (!data?.replayed) showWait = null;
183
+ const out = handlers[type]?.(data);
184
+ turnBy = null;
185
+ reread();
186
+ redraw();
187
+ return out;
188
+ };
189
+ }
190
+ // No card: the stage's waiting look says for what kind of thing.
191
+ for (const type of WAITS) {
192
+ wrapped[type] = (data) => {
193
+ waitKind = data?.kind || "question";
194
+ stage.redraw?.();
195
+ redraw();
196
+ };
197
+ }
198
+ for (const type of WAIT_ENDS) {
199
+ wrapped[type] = (data) => {
200
+ waitKind = null;
201
+ if (!data?.replayed) showWait = null;
202
+ stage.redraw?.();
203
+ redraw();
204
+ };
205
+ }
206
+ wrapped.generation_completed = (data) => {
207
+ if (data?.speed && Number.isFinite(Number(data.speed.decode_tps))) speed = data.speed;
208
+ return handlers.generation_completed?.(data);
209
+ };
210
+ return wrapped;
211
+ }
212
+
213
+ function lastSpeed() {
214
+ if (speed) return speed;
215
+ const records = store.current.timing?.turnRecords || [];
216
+ const last = [...records].reverse().find((r) => Number(r.decode_tps) > 0);
217
+ return last ? { decode_tps: last.decode_tps, source: last.tps_source } : null;
218
+ }
219
+
220
+ function drawBar() {
221
+ const isRunning = running();
222
+ const status = isRunning ? (waitKind ? "waiting" : "running") : "idle";
223
+ const startedAt = Date.parse(store.current.timing?.activeTurn?.started_at || "");
224
+ const turnMs = isRunning && Number.isFinite(startedAt) ? Math.max(0, Date.now() - startedAt) : null;
225
+ render(barTemplate(barItems({
226
+ status, ctxPct: store.current.ctxPct, windowSource: store.current.ctxWindowSource, speed: lastSpeed(), turnMs,
227
+ usage: viewer?.usage, mode: viewer?.mode,
228
+ })), row);
229
+ }
230
+
231
+ // A refused send (send_turn.js): the codes that close the composer
232
+ // close it (the cap: from the show read again); @return whether it was one.
233
+ function refused(error) {
234
+ const code = error?.code;
235
+ if (!CLOSING_CODES.includes(code)) return false;
236
+ if (code === "cap_reached") {
237
+ viewer = viewer ? { ...viewer, cap_reached: true } : viewer;
238
+ drawClosed();
239
+ reread();
240
+ return true;
241
+ }
242
+ refusal = { code, text: error.body?.detail || error.message };
243
+ drawClosed();
244
+ return true;
245
+ }
246
+
247
+ // chi web refused the page's credential (data.js onAuthLost): their link
248
+ // ended. The server's sentence: a refused send's own (refused), else the
249
+ // 401 of a brief read.
250
+ let ended = false;
251
+ async function linkEnded() {
252
+ if (ended) return;
253
+ ended = true;
254
+ let found = null;
255
+ try {
256
+ const res = await probe(sessionId);
257
+ const answer = await res.json();
258
+ if (res.status === 401 && answer?.detail) found = { code: answer.error || "token_revoked", text: answer.detail };
259
+ } catch (_) { /* unreachable or not JSON: the words below */ }
260
+ if (refusal?.code?.startsWith("token_")) return;
261
+ refusal = found || { code: "token_revoked", text: LINK_ENDED };
262
+ drawClosed();
263
+ }
264
+
265
+ return {
266
+ sessionId,
267
+ shown,
268
+ wrapHandlers,
269
+ drawBar,
270
+ refused,
271
+ linkEnded,
272
+ // The session title the top row shows: the one chi web gave the page.
273
+ title: () => title || body.dataset.sessionTitle || "",
274
+ // Whether their composer is closed (no Revise then: participant_revise.js).
275
+ closed: () => !closed.hidden,
276
+ // Stop turn: on their own turn only.
277
+ canCancel: () => !!turnBy?.mine,
278
+ // What the stage's waiting look says, while a turn runs.
279
+ ownerWait: () => (running() ? waitKind : null),
280
+ // The delivery line's input (delivery_line.js viewerTurn): whose turn
281
+ // runs, and whether a message goes into it (their own, on a worker that
282
+ // takes it).
283
+ deliveryTurn: () => ({
284
+ own: !!turnBy?.mine,
285
+ steer: !!turnBy?.mine && (store.current.workerFeatures || []).includes(PARTICIPANT_STEER),
286
+ by: turnBy?.author && !turnBy.mine ? turnBy.name || "another participant" : null,
287
+ }),
288
+ };
289
+ }