samagotchi 0.110.0 → 0.111.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 (55) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +24 -1
  3. data/docs/architecture.md +8 -5
  4. data/docs/cli.md +3 -0
  5. data/docs/configuration.md +43 -0
  6. data/docs/internals/engine.md +39 -10
  7. data/docs/internals/participants.md +1 -1
  8. data/docs/internals/remote-sessions.md +3 -3
  9. data/docs/internals/web-push.md +200 -0
  10. data/docs/internals/working-set.md +7 -6
  11. data/docs/sessions.md +1 -1
  12. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  13. data/lib/samagotchi/config.rb +4 -0
  14. data/lib/samagotchi/engine.rb +146 -490
  15. data/lib/samagotchi/engine_snapshots.rb +223 -0
  16. data/lib/samagotchi/event_fanout.rb +236 -0
  17. data/lib/samagotchi/model_binding.rb +138 -0
  18. data/lib/samagotchi/send_command/wait_target.rb +10 -3
  19. data/lib/samagotchi/send_command.rb +2 -1
  20. data/lib/samagotchi/session.rb +12 -3
  21. data/lib/samagotchi/session_access/remote.rb +2 -1
  22. data/lib/samagotchi/session_access.rb +7 -3
  23. data/lib/samagotchi/session_manager.rb +5 -0
  24. data/lib/samagotchi/turn_endings.rb +137 -0
  25. data/lib/samagotchi/version.rb +1 -1
  26. data/lib/samagotchi/web/app.rb +27 -1377
  27. data/lib/samagotchi/web/grants.rb +3 -12
  28. data/lib/samagotchi/web/message_display.rb +274 -0
  29. data/lib/samagotchi/web/private_files.rb +31 -0
  30. data/lib/samagotchi/web/public/app.js +30 -6
  31. data/lib/samagotchi/web/public/apple-touch-icon.png +0 -0
  32. data/lib/samagotchi/web/public/icons/chi-180.png +0 -0
  33. data/lib/samagotchi/web/public/icons/chi-192.png +0 -0
  34. data/lib/samagotchi/web/public/icons/chi-512.png +0 -0
  35. data/lib/samagotchi/web/public/index.html +4 -0
  36. data/lib/samagotchi/web/public/manifest.webmanifest +13 -0
  37. data/lib/samagotchi/web/public/notifications.js +137 -3
  38. data/lib/samagotchi/web/public/notify.js +1 -1
  39. data/lib/samagotchi/web/public/push.js +95 -0
  40. data/lib/samagotchi/web/public/stage_view.js +8 -0
  41. data/lib/samagotchi/web/public/sw.js +101 -0
  42. data/lib/samagotchi/web/public/turn_events.js +22 -0
  43. data/lib/samagotchi/web/push/attention.rb +105 -0
  44. data/lib/samagotchi/web/push/client.rb +103 -0
  45. data/lib/samagotchi/web/push/crypto.rb +126 -0
  46. data/lib/samagotchi/web/push/notifier.rb +230 -0
  47. data/lib/samagotchi/web/push/store.rb +285 -0
  48. data/lib/samagotchi/web/push.rb +73 -0
  49. data/lib/samagotchi/web/push_routes.rb +99 -0
  50. data/lib/samagotchi/web/server.rb +26 -2
  51. data/lib/samagotchi/web/session_control.rb +441 -0
  52. data/lib/samagotchi/web/session_create.rb +384 -0
  53. data/lib/samagotchi/web/session_show.rb +411 -0
  54. data/lib/samagotchi/worker.rb +3 -2
  55. metadata +25 -1
@@ -13,6 +13,7 @@ require_relative "../log"
13
13
  require_relative "../participant_name"
14
14
  require_relative "../session"
15
15
  require_relative "../redacted_inspect"
16
+ require_relative "private_files"
16
17
 
17
18
  module Samagotchi
18
19
  module Web
@@ -201,20 +202,10 @@ module Samagotchi
201
202
  end
202
203
 
203
204
  # Refuses a store others could read: the grants folder must be 0700
204
- # and the file 0600, both this user's. Only these two: the state dir
205
- # above them is often 0755, and that is fine. A folder not made yet
206
- # passes (it is made 0700).
205
+ # and the file 0600, both this user's (PrivateFiles).
207
206
  # @raise [Insecure]
208
207
  def self.check_permissions!(path)
209
- [File.dirname(path), path].each do |target|
210
- stat = File.stat(target)
211
- next if stat.mode.nobits?(0o077) && stat.uid == Process.uid
212
-
213
- raise Insecure, "#{target} can be read by other users: chmod 700 #{File.dirname(path)} and 600 #{path} " \
214
- "(it holds the participants' grants)"
215
- rescue Errno::ENOENT
216
- next
217
- end
208
+ PrivateFiles.check!(File.dirname(path), [path], holds: "the participants' grants", error: Insecure)
218
209
  end
219
210
 
220
211
  attr_reader :path
@@ -0,0 +1,274 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../session"
4
+ require_relative "../answer_refs"
5
+ require_relative "../answer_tail"
6
+ require_relative "../context_note"
7
+ require_relative "../image_store"
8
+ require_relative "../message_parts"
9
+ require_relative "../message_time"
10
+ require_relative "../output_formatter"
11
+ require_relative "../recap_store"
12
+ require_relative "../ref"
13
+ require_relative "../steer"
14
+ require_relative "../turn_note"
15
+ require_relative "../bridge/card_store"
16
+
17
+ module Samagotchi
18
+ module Web
19
+ # How chi web shows saved messages and cards: a session's history as
20
+ # display items (messages_for_display, which ParticipantReview reads
21
+ # too), an answer as its parts and refs, a card's body as HTML, and POST
22
+ # /api/render, the markdown read of a text the page already shows.
23
+ # Included in App: its ROUTES name handle_render, and its helpers
24
+ # (json_response, error_response, parse_json, request_body,
25
+ # default_state_dir) and ivars (@manager, @session_class, @state_dir,
26
+ # @markdown_renderer) answer. See docs/architecture.md (chi web's routes).
27
+ module MessageDisplay
28
+ # A POST /api/render body may be this big: a decision message is a few
29
+ # KB, and the cap bounds the work a page can ask for.
30
+ MAX_RENDER_BYTES = 64 * 1024
31
+
32
+ private
33
+
34
+ # The recap saved with the session and how many user turns came since:
35
+ # a live worker's (it counts against its Engine's messages), else
36
+ # recap.json against the session file. A stopped session gets its
37
+ # recap without a worker being woken.
38
+ # @return [Hash, nil] {text:, turns_since:, created_at:} (created_at:
39
+ # when it was written, UTC ISO; absent from an older recap)
40
+ def saved_recap_for(id, session, turn_snapshot)
41
+ if turn_snapshot
42
+ saved = turn_snapshot["saved_recap"]
43
+ return saved && { text: saved["text"], turns_since: saved["turns_since"].to_i, created_at: saved["created_at"] }.compact
44
+ end
45
+
46
+ saved = RecapStore.read(@session_class.session_dir(id, state_dir: default_state_dir))
47
+ return nil unless saved
48
+
49
+ messages = Array(session.messages)
50
+ since = messages.drop(saved[:covered].to_i).count { |m| Samagotchi::Steer.turn_prompt?(m) }
51
+ { text: saved[:text], turns_since: since, created_at: saved[:created_at] }.compact
52
+ rescue StandardError
53
+ nil
54
+ end
55
+
56
+ # The step (iteration) that answered the steer or merged input at
57
+ # +index+: one past the model messages between it and the turn's
58
+ # prompt.
59
+ def steer_step(list, index)
60
+ list[0...index].reverse_each.take_while { |m| !Samagotchi::Steer.turn_prompt?(m) }
61
+ .count { |m| %w[model assistant].include?((m[:role] || m["role"]).to_s) } + 1
62
+ end
63
+
64
+ def read_history(id)
65
+ raw = @manager.read_responses(id, since_time: nil, state_dir: @state_dir)
66
+ raw.map { |chunk| OutputFormatter.strip(chunk) }.reject(&:empty?)
67
+ rescue StandardError
68
+ []
69
+ end
70
+
71
+ # @param msgs [Array<Hash>] a session's messages; symbol keys from disk,
72
+ # string keys from a Bridge snapshot
73
+ # @param parts [Boolean] the turn view's reload (?parts=1): each
74
+ # assistant message also carries what it did (MessageParts: thinking,
75
+ # tool calls with params and output), and a message with nothing to
76
+ # show but that (a text-less step) is kept with empty content
77
+ # @param cwd [String, nil] the session's working directory (the parts'
78
+ # tool titles are relative to it)
79
+ def messages_for_display(msgs, parts: false, cwd: nil)
80
+ filtered = []
81
+ list = Array(msgs)
82
+ # The outputs' ✂ marks (LLM context edits), by id, for the parts.
83
+ marks = parts ? MessageParts.edit_marks(list) : {}
84
+ list.each_with_index do |m, index|
85
+ role = (m[:role] || m["role"]).to_s
86
+ content = (m[:content] || m["content"]).to_s
87
+ if Samagotchi::ContextNote.note?(m)
88
+ note = { role: "note", content: Samagotchi::ContextNote.text_of(m), label: Samagotchi::ContextNote.label_of(m), **time_of(m) }
89
+ # A context wake turn's note starts that turn (Steer.turn_prompt?):
90
+ # the page opens the turn with it and pairs it with its record.
91
+ if Samagotchi::Steer.turn_prompt?(m)
92
+ note[:turn_start] = true
93
+ note[:turn_id] = (m[:turn_id] || m["turn_id"]).to_s
94
+ end
95
+ filtered << note
96
+ next
97
+ end
98
+ # A plugin's steer: a row of the step that answered it, which the
99
+ # page finds by `step`.
100
+ if Samagotchi::Steer.steer?(m)
101
+ filtered << { role: "steer", content: content, source: (m[:source] || m["source"]).to_s, step: steer_step(list, index), **time_of(m) }
102
+ next
103
+ end
104
+ # A turn that ended with no answer (TurnNote.empty's marker): its
105
+ # empty steps (with ?parts=1) and the item the page draws its
106
+ # notice from; the note itself stays hidden.
107
+ if (marker = Samagotchi::TurnNote.empty_answer(m))
108
+ filtered.concat(empty_answer_items(marker, parts: parts, cwd: cwd))
109
+ next
110
+ end
111
+ next if role == "system"
112
+ next if role == "tool_response"
113
+
114
+ stripped = Samagotchi::OutputFormatter.strip_markup(content)
115
+ did = parts && %w[model assistant].include?(role) ? message_parts(list, index, cwd, marks) : nil
116
+ next if stripped.empty? && did.nil?
117
+
118
+ norm_role = role == "model" ? "assistant" : role
119
+ # normalize assistant vs model, keep user as is
120
+ norm_role = "assistant" if %w[assistant model].include?(norm_role)
121
+ norm_role = "user" if norm_role == "user"
122
+ next unless %w[user assistant].include?(norm_role)
123
+
124
+ message = { role: norm_role, content: stripped, **time_of(m) }
125
+ # Merged into the running turn (Steer::INPUT_KIND): part of it,
126
+ # read by step +step+; a wake turn's report starts its turn
127
+ # (Steer.turn_prompt?). Its sender (a delegate report, chi send):
128
+ # the page's label.
129
+ if norm_role == "user" && Samagotchi::Steer.input?(m)
130
+ message.merge!(merged: true, step: steer_step(list, index)) unless Samagotchi::Steer.turn_prompt?(m)
131
+ source = (m[:source] || m["source"]).to_s
132
+ message[:source] = source unless source.empty?
133
+ end
134
+ # The turn it started (Engine): the page pairs it with that turn's
135
+ # record by this, not by its place.
136
+ turn_id = m[:turn_id] || m["turn_id"]
137
+ message[:turn_id] = turn_id.to_s if norm_role == "user" && turn_id
138
+ # A participant's message names them (the page's author badge).
139
+ message.merge!(Samagotchi::Steer.author_fields(m[:author] || m["author"], m[:author_name] || m["author_name"])) if norm_role == "user"
140
+ images = m[:images] || m["images"]
141
+ message[:images] = Array(images).map { |ref| ImageStore.symbolize(ref).slice(:file, :name, :width, :height) } if norm_role == "user" && images.is_a?(Array) && !images.empty?
142
+ if norm_role == "assistant"
143
+ # What an after_turn hook presented (AnswerDisplay): rendered and
144
+ # copied in place of the answer, sanitised the same way; content
145
+ # stays the model's text, which the page finds the bubble by.
146
+ display = m[:display] || m["display"]
147
+ shown = display.is_a?(String) ? Samagotchi::OutputFormatter.strip_markup(display) : ""
148
+ message[:display] = shown unless shown.strip.empty?
149
+ rendered = message[:display] || stripped
150
+ message[:html] = @markdown_renderer.render(rendered) if !rendered.empty? && @markdown_renderer.available?
151
+ # The refs after_turn hooks attached (AnswerRefs), checked again:
152
+ # a stored file is never trusted. Never raises.
153
+ refs = Samagotchi::AnswerRefs.for_display(m[:display_refs] || m["display_refs"], cwd: cwd)
154
+ message[:display_refs] = refs unless refs.empty?
155
+ end
156
+ message[:parts] = did if did
157
+ filtered << message
158
+ end
159
+ filtered
160
+ rescue StandardError
161
+ []
162
+ end
163
+
164
+ # The entry's wall time (MessageTime: the page's hover and gap
165
+ # dividers) as {at:}, or nothing for an entry saved without one.
166
+ def time_of(message)
167
+ at = Samagotchi::MessageTime.of(message)
168
+ at.is_a?(String) && !at.empty? ? { at: at } : {}
169
+ end
170
+
171
+ # A no-answer turn's marker as display items: each empty step that
172
+ # has something to show (its thinking) as a text-less assistant step
173
+ # (only with +parts+, as other text-less steps), then
174
+ # { role: "empty_answer", retries: }.
175
+ def empty_answer_items(marker, parts:, cwd:)
176
+ steps = parts ? Array(marker[:steps] || marker["steps"]) : []
177
+ items = steps.filter_map do |step|
178
+ did = step.is_a?(Hash) ? MessageParts.for_message(step, [], cwd: cwd) : nil
179
+ { role: "assistant", content: "", parts: did } if did
180
+ end
181
+ refusal = marker[:refusal] || marker["refusal"]
182
+ items << { role: "empty_answer", content: "", retries: (marker[:retries] || marker["retries"]).to_i, refusal: refusal }.compact
183
+ end
184
+
185
+ # The last turn failed and its prompt went back to the user: the
186
+ # prompt (session.last_prompt) and why, for the page to show after the
187
+ # conversation as it did live. nil otherwise.
188
+ def failed_turn_for(session, msgs)
189
+ summary = Samagotchi::TurnNote.restored_failure(msgs)
190
+ prompt = session.last_prompt.to_s
191
+ return nil if summary.nil? || prompt.strip.empty?
192
+
193
+ { prompt: prompt, summary: summary }
194
+ end
195
+
196
+ # The parts of the assistant message at +index+, with the tool_response
197
+ # messages right after it; +marks+: the session's ✂ marks
198
+ # (MessageParts.edit_marks).
199
+ def message_parts(list, index, cwd = nil, marks = {})
200
+ responses = list.drop(index + 1).take_while { |r| (r[:role] || r["role"]).to_s == "tool_response" }
201
+ MessageParts.for_message(list[index], responses, cwd: cwd, marks: marks)
202
+ end
203
+
204
+ # The last message messages_for_display shows as an answer
205
+ # (AnswerTail), as a list of at most one: only that one is rendered.
206
+ # +cwd+: the session's folder, for its file refs (nil from a live
207
+ # worker's tail: its refs were held to the folder when they were added).
208
+ def last_assistant_for_display(msgs, turn_id: nil, cwd: nil)
209
+ messages_for_display([AnswerTail.find(msgs, turn_id: turn_id)].compact, cwd: cwd)
210
+ end
211
+
212
+ # +answer+ without its file refs (the rest kept), for a read that
213
+ # knows no folder to check them against.
214
+ def without_file_refs(answer)
215
+ return answer unless answer.is_a?(Hash)
216
+
217
+ key = [AnswerRefs::KEY.to_s, AnswerRefs::KEY].find { |name| answer[name].is_a?(Array) } or return answer
218
+ answer.merge(key => answer[key].reject { |ref| ref.is_a?(Hash) && (ref["kind"] || ref[:kind]).to_s == Ref::FILE })
219
+ end
220
+
221
+ # The worker's last cards and between-turns notices (Bridge
222
+ # cards: CardStore#list), else (no live worker) the ones the last
223
+ # worker saved in the session's folder (#saved_cards), a card's body as
224
+ # body_html: rendered markdown when the renderer is on, else the
225
+ # escaped text in a <pre>, and a question card's refs checked again
226
+ # (#with_question_refs; +cwd+ the session's folder, nil where the
227
+ # session file isn't read: a file ref then stays only absolute).
228
+ # @return [Array<Hash>]
229
+ def cards_for_display(cards, cwd: nil)
230
+ Array(cards).filter_map do |card|
231
+ next unless card.is_a?(Hash)
232
+ if card["type"].to_s == "question" && card["pending_question"].is_a?(Hash)
233
+ next card.merge("pending_question" => with_question_refs(card["pending_question"], cwd: cwd))
234
+ end
235
+ next card unless card["type"].to_s == "card"
236
+
237
+ card.merge("body_html" => card_body_html(card["body"].to_s))
238
+ end
239
+ end
240
+
241
+ # As JSON has them (string keys and values), like a snapshot's.
242
+ def saved_cards(id)
243
+ JSON.parse(JSON.generate(Samagotchi::Bridge::CardStore.saved(Session.session_dir(id, state_dir: default_state_dir))))
244
+ rescue StandardError
245
+ []
246
+ end
247
+
248
+ def card_body_html(body)
249
+ return "" if body.strip.empty?
250
+
251
+ html = @markdown_renderer.render(body) if @markdown_renderer.available?
252
+ html || "<pre>#{CGI.escapeHTML(body)}</pre>"
253
+ end
254
+
255
+ # POST /api/render {text}: the page's markdown read for a text it
256
+ # already shows (the stage's asking text, a resolved question card's
257
+ # reply), session-free. {html: nil} when markdown is off or
258
+ # commonmarker is missing: the page keeps the raw text.
259
+ def handle_render(req)
260
+ too_large = error_response(413, "too_large", "a text to render may be up to #{MAX_RENDER_BYTES / 1024} KB")
261
+ return too_large if req.content_length.to_i > MAX_RENDER_BYTES
262
+
263
+ raw = request_body(req, MAX_RENDER_BYTES + 1).to_s
264
+ return too_large if raw.bytesize > MAX_RENDER_BYTES
265
+
266
+ body = parse_json(raw)
267
+ return error_response(400, "invalid_json", "give {text}") unless body.is_a?(Hash) && body["text"].is_a?(String)
268
+
269
+ html = @markdown_renderer.render(body["text"]) if @markdown_renderer.available?
270
+ json_response(200, { html: html })
271
+ end
272
+ end
273
+ end
274
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ module Web
5
+ # chi web's private state folders (web-grants/, web-push/): a folder
6
+ # only this user can enter (0700) with files only this user can read
7
+ # (0600). chi web refuses to start on one others can read (Server.launch);
8
+ # the reason words say what the folder holds.
9
+ module PrivateFiles
10
+ module_function
11
+
12
+ # Refuses +dir+ or any of +files+ when others can read it or it isn't
13
+ # this user's. Only these: the state dir above them is often 0755, and
14
+ # that is fine. A folder or file not made yet passes (it is made 0700 /
15
+ # 0600).
16
+ # @param holds [String] what is in them ("the participants' grants")
17
+ # @param error [Class] the exception to raise
18
+ def check!(dir, files, holds:, error:)
19
+ [dir, *files].each do |target|
20
+ stat = File.stat(target)
21
+ next if stat.mode.nobits?(0o077) && stat.uid == Process.uid
22
+
23
+ raise error, "#{target} can be read by other users: chmod 700 #{dir} and 600 #{files.join(" ")} " \
24
+ "(it holds #{holds})"
25
+ rescue Errno::ENOENT
26
+ next
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -83,7 +83,7 @@ import { answerRefsOf, applyFinalAnswer, pairAnswerRefs, setAnswerRefs } from ".
83
83
  import { installRefClicks } from "./refs.js";
84
84
  import { linkRefs, touchedFiles } from "./ref_scan.js";
85
85
  import { DONE_MS, applyInitEvent, dropDone, fadeDone, initRowTemplate, newInitTasks, seedInitTasks } from "./init_row.js";
86
- import { newClientId, snapshotEvents, workerGoneText } from "./turn_events.js";
86
+ import { newClientId, resyncRetry, snapshotEvents, streamDropEnd } from "./turn_events.js";
87
87
  import { applyConfirm, keptCommandBubbles, restoreCommandBubbles } from "./commands.js";
88
88
  import { createComposerCommands } from "./composer.js";
89
89
  import { createStreamHandlers } from "./stream_handlers.js";
@@ -850,6 +850,7 @@ async function select(id) {
850
850
  historyEl.replaceChildren(hintEl("Loading…"));
851
851
  emptyEl.classList.add("hidden");
852
852
  closeStream();
853
+ resyncAttempts = 0; // this session has its own re-read budget
853
854
  resetSessionState();
854
855
  resetTurnView();
855
856
  composerGrip.newVisit();
@@ -873,6 +874,24 @@ function resetTurnView() {
873
874
  setTurnRunning(false);
874
875
  }
875
876
 
877
+ // A re-read that failed (the phone still offline, chi web a moment): try it
878
+ // again, backing off, and stop when the tries run out. The turn view is left
879
+ // alone meanwhile: a worker that still owns the turn keeps showing it running,
880
+ // and the hub's next event for the session is the fallback once the tries end.
881
+ const RESYNC_RETRIES = 3;
882
+ let resyncAttempts = 0;
883
+
884
+ function retryResync(id, { endText = null } = {}) {
885
+ if (id !== selected) return;
886
+ const { attempt, delayMs } = resyncRetry({ attempts: resyncAttempts, max: RESYNC_RETRIES, baseMs: STREAM_RETRY_MS });
887
+ if (delayMs === null) return;
888
+ resyncAttempts = attempt;
889
+ setTimeout(() => {
890
+ if (id !== selected || (streamControl && streamControl.live)) return;
891
+ resync({ endText });
892
+ }, delayMs);
893
+ }
894
+
876
895
  // (Re)render the selected session from the server and stream on from the
877
896
  // event_seq that render covers. Used on select, when the stream resets
878
897
  // (its cursor could not be replayed) and when it dropped (workerGone).
@@ -892,9 +911,13 @@ async function resync({ endText = null } = {}) {
892
911
  data = await getSession(id, { parts: true });
893
912
  } catch (e) {
894
913
  if (e.status === 404) return leaveGoneSession(id, "not_found");
895
- if (id === selected) historyEl.replaceChildren(hintEl(`Error: ${e.message}`));
914
+ if (id === selected) {
915
+ historyEl.replaceChildren(hintEl(`Error: ${e.message}`));
916
+ retryResync(id, { endText });
917
+ }
896
918
  return;
897
919
  }
920
+ resyncAttempts = 0;
898
921
  if (id !== selected) return;
899
922
  // An archived session opens folded: decided once per visit, from its
900
923
  // first answer (the list may not hold it yet, or ever under ?dir).
@@ -1348,10 +1371,11 @@ const STREAM_RETRY_MS = 3000;
1348
1371
  function workerGone(id, { opened = true } = {}) {
1349
1372
  streamControl = null;
1350
1373
  if (id !== selected) return;
1351
- // A turn that was running is over: its worker can't finish it.
1374
+ // A turn that was running is over only when the worker really left: a
1375
+ // stream that merely dropped (no stop, it was open) waits for the re-read.
1352
1376
  const stopped = sessionActions.stoppingId() === id;
1353
- const endText = workerGoneText({ turnRunning: liveTurn.running, stopped });
1354
- if (endText) {
1377
+ const { endNow, endText } = streamDropEnd({ turnRunning: liveTurn.running, stopped, opened });
1378
+ if (endNow) {
1355
1379
  endTurnView("gone", endText, { stopped });
1356
1380
  if (!stopped) setLiveStatus("idle");
1357
1381
  }
@@ -1843,7 +1867,7 @@ turnRoots().forEach((root) => installCopy(root));
1843
1867
 
1844
1868
  // Notifications (notifications.js): an OS notification and a title count
1845
1869
  // when a session needs the user while this tab is not in front.
1846
- const notifications = createNotifications({ button: $("#notifyBtn") });
1870
+ const notifications = createNotifications({ button: $("#notifyBtn"), scoped: !!scope.dir });
1847
1871
 
1848
1872
  // The latest-sessions strip can be hidden for more chat room; the choice
1849
1873
  // stays per browser (it replaced zen mode, so a zen user starts hidden).
@@ -4,6 +4,10 @@
4
4
  <meta charset="utf-8">
5
5
  <title>Chi</title>
6
6
  <link rel="icon" href="/assets/favicon.svg" type="image/svg+xml">
7
+ <!-- The Home Screen app (Web Push): bare paths, served without the token (App::GATE_FREE_PATHS); icons from script/render-icons. -->
8
+ <link rel="manifest" href="/manifest.webmanifest">
9
+ <link rel="apple-touch-icon" href="/apple-touch-icon.png">
10
+ <meta name="apple-mobile-web-app-title" content="Chi">
7
11
  <meta name="viewport" content="width=device-width,initial-scale=1">
8
12
  <link rel="stylesheet" href="/assets/tokens.css">
9
13
  <style>
@@ -0,0 +1,13 @@
1
+ {
2
+ "name": "Chi",
3
+ "short_name": "Chi",
4
+ "start_url": "/",
5
+ "scope": "/",
6
+ "display": "standalone",
7
+ "background_color": "#0b0d11",
8
+ "theme_color": "#7aa5ff",
9
+ "icons": [
10
+ { "src": "/icons/chi-192.png", "sizes": "192x192", "type": "image/png" },
11
+ { "src": "/icons/chi-512.png", "sizes": "512x512", "type": "image/png" }
12
+ ]
13
+ }