nio-cat 0.9.9 → 0.9.10

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nio-cat",
3
- "version": "0.9.9",
3
+ "version": "0.9.10",
4
4
  "description": "nio: fes servir Claude Code, Codex, Grok, Antigravity i Ollama del teu ordinador des del mòbil.",
5
5
  "license": "UNLICENSED",
6
6
  "author": "f.cat",
@@ -20,6 +20,8 @@ module.exports = {
20
20
  // Conversa del terminal: el primer missatge en fa una branca; després ja continua la pròpia.
21
21
  if (session.nativeId && session.forkNative) { args.push('--fork-session'); delete session.forkNative; }
22
22
  if (session.model) args.push('--model', session.model);
23
+ // Que l'agent posi un títol que resumeixi la feina (eina MCP de nio; el hub el desa).
24
+ args.push('--append-system-prompt', 'Treballes dins una sessió de nio. Quan entenguis la tasca, crida l’eina mcp__nio__sessio_titol amb un títol de 3-7 paraules que la resumeixi, en l’idioma de l’usuari; torna-la a cridar només si la feina canvia de tema.');
23
25
 
24
26
  if (session.mode === 'tot' && process.getuid?.() === 0) {
25
27
  ctx.fail('Claude Code no deixa el mode «Via lliure» quan s’executa com a root. Canvia els permisos a «Demana permís» o engega nio amb un usuari normal.');
@@ -6,6 +6,7 @@
6
6
  const READ_ONLY = new Set([
7
7
  'Read', 'Glob', 'Grep', 'LS', 'NotebookRead', 'WebSearch', 'WebFetch',
8
8
  'TodoWrite', 'TodoRead', 'Task', 'Agent', 'ToolSearch', 'Skill', 'BashOutput',
9
+ 'mcp__nio__sessio_titol',
9
10
  ]);
10
11
 
11
12
  function reply(decision, reason) {
package/src/hub.js CHANGED
@@ -5,6 +5,7 @@ const path = require('path');
5
5
  const crypto = require('crypto');
6
6
  const { EventEmitter } = require('events');
7
7
  const { byId, binPath } = require('./agents');
8
+ const TITLE_TOOL = /(^|[._])sessio_titol$/;
8
9
  const { HOME, save } = require('./config');
9
10
  const push = require('./push');
10
11
  const usage = require('./usage');
@@ -206,7 +207,7 @@ class Hub extends EventEmitter {
206
207
  const s = this.get(sid);
207
208
  if (!s) throw httpErr(404, 'Aquesta sessió no existeix');
208
209
  if (patch.mode && ['llegir', 'preguntar', 'tot'].includes(patch.mode)) s.mode = patch.mode;
209
- if (typeof patch.title === 'string') s.title = patch.title.slice(0, 120);
210
+ if (typeof patch.title === 'string') { s.title = patch.title.slice(0, 120); s.titleBy = 'user'; } // un nom posat a mà no el canvia l'agent
210
211
  if (typeof patch.model === 'string') s.model = patch.model.slice(0, 80);
211
212
  if (typeof patch.cwd === 'string' && path.resolve(patch.cwd) !== s.cwd) {
212
213
  const cwd = path.resolve(patch.cwd);
@@ -285,6 +286,12 @@ class Hub extends EventEmitter {
285
286
  stream: (t) => this.chunk(s, String(t)),
286
287
  tool: ({ id: tid, name, input }) => {
287
288
  toolNames.set(tid, name);
289
+ // L'agent posa títol a la sessió amb l'eina MCP sessio_titol (Claude: mcp__nio__sessio_titol, Codex: nio.sessio_titol).
290
+ if (TITLE_TOOL.test(name || '') && typeof input === 'string') try { input = JSON.parse(input); } catch { input = {}; }
291
+ if (TITLE_TOOL.test(name || '') && s.titleBy !== 'user') {
292
+ const t = String(input?.titol || '').replace(/\s+/g, ' ').trim().replace(/[.。]$/, '').slice(0, 80);
293
+ if (t && t !== s.title) { s.title = t; s.titleBy = 'agent'; this.touch(s); }
294
+ }
288
295
  const inp = summarizeInput(input);
289
296
  this.push(s, 'tool', { id: tid, name, input: inp });
290
297
  this.audit.add({ type: 'tool', session: s.id, agent: s.agent, title: s.title, cwd: s.cwd, tool: name, what: describe(name, inp), mode: s.mode });
package/src/mcp.js CHANGED
@@ -50,6 +50,14 @@ const TOOLS = [
50
50
  return `Desat a ${r.path} (${r.size} B).`;
51
51
  },
52
52
  },
53
+ {
54
+ name: 'sessio_titol',
55
+ description: 'Posa títol a la sessió de nio on treballes: 3-7 paraules que resumeixin la feina (p. ex. «Mode fosc a la botiga»), perquè l’usuari la reconegui a la llista. Crida-la quan entenguis la tasca i torna-la a cridar si la feina canvia de tema. Fora de nio no fa res.',
56
+ inputSchema: { type: 'object', properties: { titol: { type: 'string', description: 'Títol curt, sense punt final' } }, required: ['titol'], additionalProperties: false },
57
+ annotations: { readOnlyHint: true },
58
+ // El títol el posa nio quan veu passar aquesta crida (hub.js); aquí només es confirma.
59
+ run: ({ titol }) => (String(titol || '').trim() ? `Títol: ${String(titol).trim().slice(0, 80)}` : 'Falta el títol.'),
60
+ },
53
61
  ];
54
62
 
55
63
  function serve({ input = process.stdin, output = process.stdout } = {}) {
@@ -64,7 +72,7 @@ function serve({ input = process.stdin, output = process.stdout } = {}) {
64
72
  protocolVersion: params?.protocolVersion || '2025-06-18',
65
73
  capabilities: { tools: {} },
66
74
  serverInfo: { name: 'nio', version: require('../package.json').version },
67
- instructions: 'Memòria compartida de nio (fitxers markdown en aquest ordinador). Consulta-la a l’inici d’una tasca (memoria_cerca o memoria_llegeix preferencies.md) i desa-hi el que valgui la pena recordar.',
75
+ instructions: 'Memòria compartida de nio (fitxers markdown en aquest ordinador). Consulta-la a l’inici d’una tasca (memoria_cerca o memoria_llegeix preferencies.md) i desa-hi el que valgui la pena recordar. Si treballes dins una sessió de nio, posa-li un títol curt amb sessio_titol.',
68
76
  });
69
77
  case 'ping':
70
78
  return reply({});
package/web/app/app.js CHANGED
@@ -213,14 +213,14 @@ async function api(path, opts = {}) {
213
213
  return data;
214
214
  }
215
215
 
216
- let streamCtrl = null, retry = 0, gotHello = false;
216
+ let streamCtrl = null, retry = 0, gotHello = false, alive = 0; // alive: última vegada que ha arribat alguna cosa de l'ordinador
217
217
  async function pollLoop(ctrl) {
218
218
  let first = true;
219
219
  while (!ctrl.signal.aborted) {
220
220
  try {
221
221
  const [list, aps] = await Promise.all([api('/api/sessions'), api('/api/approvals')]);
222
222
  if (ctrl.signal.aborted) return;
223
- state.connected = true; state.lastError = null;
223
+ state.connected = true; state.lastError = null; alive = Date.now();
224
224
  if (first) { first = false; state.synced = true; const r = state.route; state.sessions = new Map(list.map((x) => [x.id, x])); state.approvals = new Map(aps.map((a) => [a.id, a])); if (r.name === 'session') openSession(r.id); else render(); }
225
225
  else {
226
226
  const r = state.route, seen = new Set();
@@ -297,7 +297,7 @@ async function connect() {
297
297
  const res = await fetch(`${m.url}/api/stream?d=${encodeURIComponent(deviceId())}&k=${isPhone() ? 'phone' : 'desktop'}`, { headers: { Authorization: `Bearer ${m.token}` }, signal: ctrl.signal, cache: 'no-store' });
298
298
  if (res.status === 401) throw Object.assign(new Error('La clau no és vàlida. Torna a connectar amb el codi QR.'), { fatal: true });
299
299
  if (!res.ok || !res.body) throw new Error(`Error ${res.status}`);
300
- state.connected = true; state.lastError = null; retry = 0;
300
+ state.connected = true; state.lastError = null; retry = 0; alive = Date.now();
301
301
  rememberIdentity(m.url, state.info.identity);
302
302
  renameMachine(state.info.host, state.info.id); syncServiceWorker(); render(); brainsForBoard(); loadVoice().then(() => state.route.name === 'session' && renderComposer?.()); flushQueue();
303
303
  const reader = res.body.getReader(), dec = new TextDecoder(); let buf = '';
@@ -306,6 +306,7 @@ async function connect() {
306
306
  const hw = setTimeout(() => { if (!gotHello) { poll = true; reader.cancel().catch(() => {}); } }, 5000);
307
307
  for (;;) {
308
308
  const { value, done } = await reader.read().catch(() => ({ done: true })); if (done) break;
309
+ alive = Date.now();
309
310
  buf += dec.decode(value, { stream: true }); let i;
310
311
  while ((i = buf.indexOf('\n\n')) >= 0) {
311
312
  const block = buf.slice(0, i); buf = buf.slice(i + 2);
@@ -922,7 +923,7 @@ async function pantallaTab() {
922
923
  });
923
924
  }
924
925
  function moreSheet() {
925
- const item = (k) => `<a class="more-link" href="${HASH[k]}" ${state.route.name === k ? 'aria-current="page"' : ''}><span>${VIEWS[k].label}</span>${k === 'approvals' && state.approvals.size ? `<span class="badge">${state.approvals.size}</span>` : ''}</a>`;
926
+ const item = (k) => `<a class="more-link" href="${HASH[k]}" ${state.route.name === k ? 'aria-current="page"' : ''}>${svg(VIEWS[k].icon)}<span>${VIEWS[k].label}</span>${k === 'approvals' && state.approvals.size ? `<span class="badge">${state.approvals.size}</span>` : ''}</a>`;
926
927
  const groups = [...new Set(Object.values(VIEWS).map((v) => v.group))].map((group) => [group, Object.keys(VIEWS).filter((k) => VIEWS[k].group === group)]);
927
928
  openSheet(`<h2>Més</h2><nav class="more-sections" aria-label="Totes les seccions">${groups.map(([title, keys]) => `<section><h3>${title}</h3><div>${keys.map(item).join('')}</div></section>`).join('')}</nav>`, (sh) => { sh.onclick = (e) => { if (e.target.closest('a, [data-act]')) closeSheet(); }; });
928
929
  }
@@ -1310,7 +1311,10 @@ const hhmm = (t) => new Date(t * 1000).toLocaleString('ca', { weekday: t * 1000
1310
1311
  function usageChip(agent) {
1311
1312
  const u = state.usage?.[agent]; if (!u?.windows?.length) return '';
1312
1313
  const tip = u.windows.map((w) => `${w.label}: ${Math.round(w.used * 100)} %${w.resetsAt ? ` · es reinicia ${hhmm(w.resetsAt)}` : ''}`).join('\n');
1313
- return `<span class="chip mono usage" title="${esc(`Ús de la subscripció\n${tip}`)}">${svg(CHIP_ICON.usage)}${u.windows.map((w) => `<span class="uw" data-hi="${w.used >= 0.8 ? 1 : 0}">${esc(w.label)} ${Math.round(w.used * 100)}%</span>`).join('')}</span>`;
1314
+ // Etiquetes curtes: «5h», «7d». La icona és un anell que s'omple amb la finestra més plena.
1315
+ const short = (l) => (l === 'setmana' ? '7d' : l.replace(/\s+/g, '')), top = Math.max(...u.windows.map((w) => w.used)), pct = Math.round(top * 100);
1316
+ const ring = `<svg class="ico ring" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="8.5" class="rt"/><circle cx="12" cy="12" r="8.5" pathLength="100" stroke-dasharray="${Math.max(pct, 2)} 100" transform="rotate(-90 12 12)" class="rv"/></svg>`;
1317
+ return `<span class="chip mono usage" data-hi="${top >= 0.8 ? 1 : 0}" title="${esc(`Ús de la subscripció\n${tip}`)}">${ring}${u.windows.map((w) => `<span class="uw" data-hi="${w.used >= 0.8 ? 1 : 0}">${esc(short(w.label))} (${Math.round(w.used * 100)}%)</span>`).join('')}</span>`;
1314
1318
  }
1315
1319
  function renderSessionHead() {
1316
1320
  renderChrome();
@@ -1423,6 +1427,7 @@ function putEvent(e, scroll = true) {
1423
1427
  const feed = $('#feed'); if (!feed) return;
1424
1428
  if (e.kind === 'text' && scroll && (e.open || typers.has(e.seq))) return streamText(state.route.id, e);
1425
1429
  const stick = nearBottom(feed);
1430
+ if (e.kind === 'tool_result' && TITLE_TOOL.test(e.name || '')) return; // el títol ja es veu a dalt i a la línia «Títol»
1426
1431
  if (e.kind === 'tool_result') {
1427
1432
  const t = toolEls.get(e.id);
1428
1433
  if (t) {
@@ -1448,12 +1453,15 @@ function putEvent(e, scroll = true) {
1448
1453
  if (scroll && e.kind === 'user' && pinNext && !old) { pinNext = false; return pinMessage(feed, node); }
1449
1454
  if (scroll) follow(feed, stick);
1450
1455
  }
1456
+ // L'agent posa títol a la sessió amb l'eina sessio_titol de nio (Claude: mcp__nio__sessio_titol, Codex: nio.sessio_titol).
1457
+ const TITLE_TOOL = /(^|[._])sessio_titol$/;
1451
1458
  function eventNode(e) {
1452
1459
  const s = state.sessions.get(state.route.id);
1453
1460
  switch (e.kind) {
1454
1461
  case 'user': return el(`<div class="msg-user">${esc(e.text)}</div>`);
1455
1462
  case 'text': return el(`<div class="msg-ai ${e.open && s?.state === 'running' ? 'cursor' : ''}" data-seq="${e.seq}">${md(e.text)}${!e.open && ttsOn() ? `<button class="speak" type="button" data-speak aria-label="Escolta la resposta">${svg(ICON.speaker)}<span>Escolta</span></button>` : ''}</div>`);
1456
- case 'tool': return el(`<details class="tool"><summary><span class="name">${esc(e.name)}</span><span class="arg">${esc(toolArg(e.input))}</span><span class="st">…</span></summary><pre>${esc(prettyInput(e.input))}</pre></details>`);
1463
+ case 'tool': if (TITLE_TOOL.test(e.name || '')) return el(`<div class="ev-info">Títol: «${esc(e.input?.titol || '')}»</div>`);
1464
+ return el(`<details class="tool"><summary><span class="name">${esc(e.name)}</span><span class="arg">${esc(toolArg(e.input))}</span><span class="st">…</span></summary><pre>${esc(prettyInput(e.input))}</pre></details>`);
1457
1465
  case 'tool_result': return el(`<details class="tool"><summary><span class="name">${esc(e.name || 'Eina')}</span><span class="arg"></span><span class="st ${e.error ? 'err' : 'ok'}">${e.error ? '✕' : '✓'}</span></summary><pre class="out">${esc(e.output)}</pre></details>`);
1458
1466
  case 'approval': { const a = state.approvals.get(e.approval) || {}; return el(approvalCard({ ...a, approval: e.approval, tool: e.tool, input: e.input, state: e.state && e.state !== 'pending' ? e.state : a.state || e.state }, false)); }
1459
1467
  case 'error': return el(`<div class="ev-error" role="alert"><b>Error</b><span>${esc(e.text)}</span></div>`);
@@ -3014,7 +3022,19 @@ if ('serviceWorker' in navigator) {
3014
3022
  navigator.serviceWorker.addEventListener('message', (e) => { if (e.data?.type === 'open-session' && e.data.id) go(`#/s/${e.data.id}`); });
3015
3023
  }
3016
3024
  addEventListener('hashchange', route);
3017
- document.addEventListener('visibilitychange', () => { if (!document.hidden && machine() && !state.connected) connect(); });
3025
+ // El mòbil congela la pàgina en segon pla i el flux queda mort sense cap error: l'ordinador envia un ping cada 15 s,
3026
+ // així que si fa més de 40 s que no arriba res (o en tornar a l'app, més de 20 s), es torna a connectar sol.
3027
+ function revive(maxIdle) {
3028
+ if (!machine() || isDemo(machine()) || document.hidden) return;
3029
+ // Desconnectat, el vigilant no talla els reintents en curs; en tornar a l'app sí que es torna a provar de seguida.
3030
+ if (state.connected ? Date.now() - alive < maxIdle : maxIdle >= 40000) return;
3031
+ retry = 0; reconTry.clear(); connect();
3032
+ }
3033
+ document.addEventListener('visibilitychange', () => revive(20000));
3034
+ addEventListener('pageshow', (e) => { if (e.persisted) revive(0); });
3035
+ addEventListener('online', () => revive(0));
3036
+ addEventListener('focus', () => revive(20000));
3037
+ setInterval(() => revive(40000), 10000);
3018
3038
  setInterval(() => { if (!document.hidden && state.route.name === 'sessions') render(); }, 60000);
3019
3039
  const qs = new URLSearchParams(location.search).get('s');
3020
3040
  if (qs) history.replaceState(null, '', location.pathname + `#/s/${qs}`);
package/web/app/style.css CHANGED
@@ -85,8 +85,8 @@ kbd { font: 600 11px var(--font); padding: 1px 6px; border: 1px solid var(--bord
85
85
  body.side-collapsed .brand .word { visibility: hidden; }
86
86
  body.side-collapsed .side nav a .l, body.side-collapsed .kbd .l, body.side-collapsed .kbd kbd, body.side-collapsed .conn span, body.side-collapsed .conn svg { display: none; }
87
87
  body.side-collapsed .conn { padding-left: 0; justify-content: center; }
88
- body.side-collapsed .side nav .grp { font-size: 0; padding: 9px 13px 5px; }
89
- body.side-collapsed .side nav .grp::before { content: ''; display: block; height: 1px; background: var(--border); }
88
+ body.side-collapsed .side nav .grp { font-size: 0; margin-top: 8px; padding: 8px 0 0; }
89
+ body.side-collapsed .side nav .grp::before { left: 10px; right: 10px; }
90
90
  body.side-collapsed .side nav .grp:first-child { display: none; }
91
91
  body.side-collapsed .side nav a .badge { position: absolute; top: 2px; right: 2px; min-width: 16px; height: 16px; font-size: 9px; padding: 0 4px; }
92
92
  }
@@ -135,7 +135,11 @@ button.chip:hover { border-color: var(--fg); }
135
135
  .chip.waiting { background: var(--lemon); border-color: var(--lemon); }
136
136
  .chip.error { color: var(--red); border-color: color-mix(in srgb, var(--red) 40%, transparent); }
137
137
  .chip.usage { gap: 6px; }
138
- .chip.usage .uw + .uw::before { content: '·'; margin-right: 6px; color: var(--fg-muted); font-weight: 400; }
138
+ .chip.usage .uw + .uw::before { content: '//'; margin-right: 6px; color: var(--fg-muted); font-weight: 400; opacity: .7; }
139
+ .chip.usage .ring { width: 15px; height: 15px; stroke-width: 3; }
140
+ .chip.usage .ring .rt { stroke: color-mix(in srgb, currentColor 18%, transparent); }
141
+ .chip.usage .ring .rv { stroke: var(--moss); }
142
+ .chip.usage[data-hi="1"] .ring .rv { stroke: var(--red); }
139
143
  .chip.usage .uw[data-hi="1"] { color: var(--red); font-weight: 600; }
140
144
  .chip.beta { height: 20px; padding: 0 7px; font-size: 10px; color: var(--fg-muted); }
141
145
  .pulse { width: 6px; height: 6px; border-radius: 50%; background: currentColor; animation: pulse 1.2s ease-in-out infinite; }
@@ -185,6 +189,9 @@ button.chip:hover { border-color: var(--fg); }
185
189
  .try h2 { font-size: 18px; font-weight: 600; letter-spacing: -.03em; }
186
190
  .side nav .grp { display: block; padding: 14px 13px 4px; font-size: 11px; font-weight: 600; color: var(--fg-muted); letter-spacing: .02em; }
187
191
  .side nav .grp:first-child { padding-top: 0; }
192
+ /* Cada apartat comença amb una línia fina, alineada amb el text dels enllaços. */
193
+ .side nav .grp:not(:first-child) { position: relative; margin-top: 10px; padding-top: 18px; }
194
+ .side nav .grp:not(:first-child)::before { content: ''; position: absolute; top: 0; left: 13px; right: 13px; height: 1px; background: color-mix(in srgb, var(--border) 85%, transparent); }
188
195
 
189
196
  /* ---------- Sessions ---------- */
190
197
  .session-card .title { font-size: 16px; font-weight: 500; letter-spacing: -.02em; margin-top: 8px; }
@@ -360,6 +367,7 @@ body.board-mode .view-enter { animation: none; }
360
367
  body.nav-open .side { transform: none; box-shadow: 24px 0 60px -30px color-mix(in srgb, var(--fg) 45%, transparent); }
361
368
  .side.dragging { transition: none; }
362
369
  .side nav a { padding: 12px; font-size: 16px; }
370
+ .side nav, .side .foot { flex-shrink: 0; } /* el calaix fa scroll; la llista no s'encongeix sota el peu */
363
371
  .side .foot .kbd kbd { display: none; }
364
372
  .scrim { display: block; position: fixed; inset: 0; z-index: 45; background: color-mix(in srgb, var(--fg) 30%, transparent); opacity: 0; pointer-events: none; transition: opacity .3s ease-out; }
365
373
  body.nav-open .scrim { opacity: 1; pointer-events: auto; }
@@ -372,6 +380,8 @@ body.board-mode .view-enter { animation: none; }
372
380
  .cmd.big .btn { height: 38px; }
373
381
  .paste .pair-form { flex-direction: column; }
374
382
  .side nav .grp { padding: 16px 12px 4px; }
383
+ .side nav .grp:not(:first-child) { margin-top: 12px; padding-top: 20px; }
384
+ .side nav .grp:not(:first-child)::before { left: 12px; right: 12px; }
375
385
  .top h1 { font-size: 26px; }
376
386
  /* Al mòbil només fa scroll el document: res de contenidors amb scroll propi. */
377
387
  .chat { height: auto; min-height: calc(100dvh - 56px - var(--safe-t)); }
@@ -751,11 +761,14 @@ body.chat-mode .demo-bar, body.board-mode .demo-bar { display: none; }
751
761
  .feed { position: relative; }
752
762
 
753
763
  /* Totes les seccions, amb controls còmodes de tocar. */
754
- .more-sections { display: grid; gap: 22px; margin-top: 20px; }
755
- .more-sections h3 { margin: 0 0 6px; font-size: 12px; font-weight: 600; color: var(--fg-muted); }
764
+ .more-sections { display: grid; gap: 0; margin-top: 16px; }
765
+ .more-sections section + section { margin-top: 14px; padding-top: 16px; border-top: 1px solid color-mix(in srgb, var(--border) 85%, transparent); }
766
+ .more-sections h3 { margin: 0 0 4px; padding-left: 12px; font-size: 12px; font-weight: 600; color: var(--fg-muted); }
756
767
  .more-sections section > div { display: grid; }
757
768
  .more-link { display: flex; align-items: center; gap: 12px; min-height: 48px; padding: 10px 12px; border-radius: 10px; text-decoration: none; }
758
769
  .more-link > span:first-of-type { flex: 1; }
770
+ .more-link > .ico:first-child { width: 20px; height: 20px; color: var(--fg-muted); }
771
+ .more-link[aria-current="page"] > .ico:first-child { color: var(--fg); }
759
772
  .more-link > .ico:last-child { width: 14px; color: var(--fg-muted); }
760
773
  .more-link:hover, .more-link[aria-current="page"] { background: var(--bg-2); }
761
774
  .voice-extras { border: 1px solid var(--border); border-radius: 12px; padding: 0 14px; }
package/web/sw.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // nio · service worker: obre l'app encara que no hi hagi connexió i mostra els avisos.
2
- const SHELL = 'nio-shell-v50';
2
+ const SHELL = 'nio-shell-v51';
3
3
  const FILES = ['/app', '/app/style.css', '/app/notes.css', '/app/call.css', '/app/app.js', '/app/notes.js', '/app/call.js', '/app/demo.js', '/bot.css', '/bot.js', '/board.css', '/board.js', '/favicon.svg', '/manifest.webmanifest'];
4
4
 
5
5
  self.addEventListener('install', (e) => {