mouaif 0.3.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 (116) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +140 -0
  3. package/bin/mouaif.js +281 -0
  4. package/frontend/dist/assets/AgentFilePicker-CcKLJorU.js +1 -0
  5. package/frontend/dist/assets/CliModal-Hs5phmNZ.js +7 -0
  6. package/frontend/dist/assets/DictationPage-BI23lp42.js +2 -0
  7. package/frontend/dist/assets/FileEditor-DDl31c6d.js +2 -0
  8. package/frontend/dist/assets/GitModal-3EC_gpJ5.js +2 -0
  9. package/frontend/dist/assets/Inspector-Ba3R1w04.js +73 -0
  10. package/frontend/dist/assets/SettingsAbout-bvZGDEDw.js +1 -0
  11. package/frontend/dist/assets/SettingsActions-Dk6WX9jv.js +1 -0
  12. package/frontend/dist/assets/SettingsAgents-BNV0MgDB.js +1 -0
  13. package/frontend/dist/assets/SettingsDefaults-DbMmQbzc.js +1 -0
  14. package/frontend/dist/assets/SettingsHiddenContent-BZ2sloH1.js +1 -0
  15. package/frontend/dist/assets/SettingsMcp-DOrfbQd1.js +1 -0
  16. package/frontend/dist/assets/SettingsMcpEdit-BGMQ2CWC.js +3 -0
  17. package/frontend/dist/assets/SettingsMcpRegistry-BywXee_A.js +1 -0
  18. package/frontend/dist/assets/SettingsNotifications-B0LEs11a.js +1 -0
  19. package/frontend/dist/assets/SettingsPricing-BAg33iVF.js +1 -0
  20. package/frontend/dist/assets/SettingsProject-DNrKhCcZ.js +14 -0
  21. package/frontend/dist/assets/SettingsProjects-IqkBfDcm.js +1 -0
  22. package/frontend/dist/assets/SettingsPrompts-BgeiASuk.js +1 -0
  23. package/frontend/dist/assets/SettingsProviders-k0xJN0IK.js +1 -0
  24. package/frontend/dist/assets/SettingsTags-B5kjFdQi.js +1 -0
  25. package/frontend/dist/assets/agentNavigation-BiiCpFz5.js +1 -0
  26. package/frontend/dist/assets/codemirror-Bp6CUUFk.js +30 -0
  27. package/frontend/dist/assets/index-BGvI4n0T.js +61 -0
  28. package/frontend/dist/assets/index-Bgg1gnDf.css +1 -0
  29. package/frontend/dist/assets/index-C1sQFIC-.css +1 -0
  30. package/frontend/dist/assets/index-CANPYzQg.css +1 -0
  31. package/frontend/dist/assets/index-Crn1LdzK.css +1 -0
  32. package/frontend/dist/assets/index-FbCWDPiB.css +1 -0
  33. package/frontend/dist/assets/projectQS-D1cSZ7Gr.js +1 -0
  34. package/frontend/dist/assets/virtual-list-6H9b4K51.js +1 -0
  35. package/frontend/dist/icons/favicon-32.png +0 -0
  36. package/frontend/dist/icons/icon-180-apple.png +0 -0
  37. package/frontend/dist/icons/icon-192.png +0 -0
  38. package/frontend/dist/icons/icon-512.png +0 -0
  39. package/frontend/dist/icons/icon-maskable-512.png +0 -0
  40. package/frontend/dist/index.html +83 -0
  41. package/frontend/dist/manifest.webmanifest +33 -0
  42. package/frontend/dist/sw.js +482 -0
  43. package/package.json +98 -0
  44. package/scripts/patch-zimmerframe.js +58 -0
  45. package/src/access-auth.js +515 -0
  46. package/src/agentFeatures.js +294 -0
  47. package/src/agentFiles.js +164 -0
  48. package/src/agentSkills.js +147 -0
  49. package/src/agents.js +230 -0
  50. package/src/ai-chat.js +21 -0
  51. package/src/ai-endpoints.js +1880 -0
  52. package/src/ai-stream.js +2048 -0
  53. package/src/ai.js +68 -0
  54. package/src/auth.js +391 -0
  55. package/src/chatdb.js +816 -0
  56. package/src/chats.js +275 -0
  57. package/src/custom-actions.js +65 -0
  58. package/src/files.js +431 -0
  59. package/src/hideFileContent.js +327 -0
  60. package/src/http-server.js +535 -0
  61. package/src/index.js +15 -0
  62. package/src/inspector.js +731 -0
  63. package/src/inspectorProfiles.js +503 -0
  64. package/src/live-chat.js +107 -0
  65. package/src/mcp.js +1517 -0
  66. package/src/messages.js +238 -0
  67. package/src/modelList.js +137 -0
  68. package/src/notifications.js +52 -0
  69. package/src/oauth-anthropic.js +280 -0
  70. package/src/oauth-github-copilot.js +417 -0
  71. package/src/oauth-mcp.js +216 -0
  72. package/src/oauth-openrouter.js +285 -0
  73. package/src/package-version.js +20 -0
  74. package/src/projects.js +285 -0
  75. package/src/promptProfiles.js +256 -0
  76. package/src/prompts.js +384 -0
  77. package/src/providerShapes.js +44 -0
  78. package/src/providers/base.js +41 -0
  79. package/src/providers/index.js +25 -0
  80. package/src/push.js +315 -0
  81. package/src/qr.js +192 -0
  82. package/src/restart.js +47 -0
  83. package/src/server-handlers-access.js +306 -0
  84. package/src/server-handlers-actions.js +100 -0
  85. package/src/server-handlers-ai.js +248 -0
  86. package/src/server-handlers-auth.js +273 -0
  87. package/src/server-handlers-chats.js +1436 -0
  88. package/src/server-handlers-git.js +467 -0
  89. package/src/server-handlers-mcp-oauth.js +56 -0
  90. package/src/server-handlers-misc.js +783 -0
  91. package/src/server-handlers-projects.js +289 -0
  92. package/src/server-handlers-prompts.js +259 -0
  93. package/src/server-handlers-push.js +102 -0
  94. package/src/server-handlers-settings.js +406 -0
  95. package/src/server-handlers-tools.js +654 -0
  96. package/src/server-handlers-transcribe.js +399 -0
  97. package/src/server-shared.js +780 -0
  98. package/src/server-web-static.js +191 -0
  99. package/src/settings.js +898 -0
  100. package/src/statusBar.js +541 -0
  101. package/src/tags.js +414 -0
  102. package/src/toolFeedback.js +225 -0
  103. package/src/tools/ask.js +154 -0
  104. package/src/tools/authorization.js +932 -0
  105. package/src/tools/files.js +1150 -0
  106. package/src/tools/progress.js +71 -0
  107. package/src/tools/restart.js +32 -0
  108. package/src/tools/searchEngine.js +957 -0
  109. package/src/tools/shell.js +341 -0
  110. package/src/tools/subagent.js +47 -0
  111. package/src/tools/task.js +234 -0
  112. package/src/tools/webpreview.js +448 -0
  113. package/src/trace.js +103 -0
  114. package/src/transcribe.js +683 -0
  115. package/src/usage.js +389 -0
  116. package/src/util.js +151 -0
@@ -0,0 +1 @@
1
+ .hidden-content__head .view-back{min-width:2.75rem;min-height:2.75rem}.hidden-content__sr-only{position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%);white-space:nowrap}.hidden-content{display:flex;flex-direction:column;gap:.875rem;min-width:0;width:100%;max-width:52rem;margin:0 auto;padding:.75rem max(.75rem,env(safe-area-inset-right)) max(1rem,env(safe-area-inset-bottom)) max(.75rem,env(safe-area-inset-left));box-sizing:border-box}.hidden-content p{margin:0}.hidden-content__intro{font-size:.9rem;line-height:1.5}.hidden-content__scope{padding:.75rem;border-left:.2rem solid var(--accent);border-radius:var(--r-sm);background:var(--surface-2);font-size:.8rem;line-height:1.5;color:var(--fg-soft)}.hidden-content__path,.hidden-content__scope code{overflow-wrap:anywhere}.hidden-content__path{font-family:var(--font-mono);font-size:.9rem}.hidden-content__muted{display:block;color:var(--muted);font-size:.8rem;line-height:1.5;overflow-wrap:anywhere}.hidden-content>.btn{align-self:flex-start}.hidden-content__files{margin:0;padding:0;list-style:none;border:1px solid var(--border);border-radius:var(--r-md);overflow:hidden}.hidden-content__files li+li{border-top:1px solid var(--border)}.hidden-content__row{display:flex;align-items:stretch;background:var(--surface)}.hidden-content__file{flex:1 1 auto;min-width:0;display:flex;align-items:center;justify-content:space-between;gap:.75rem;min-height:3.5rem;padding:.875rem .5rem .875rem .875rem;background:transparent;color:var(--fg);border:0;text-align:left;cursor:pointer}.hidden-content__file-main{min-width:0;display:grid;gap:.25rem}.hidden-content__remove{flex:0 0 auto;min-width:2.75rem;min-height:2.75rem;margin:.5rem .5rem .5rem 0;display:inline-flex;align-items:center;justify-content:center;padding:0 .75rem;background:transparent;color:var(--danger);border:1px solid var(--border);border-radius:var(--r-sm);cursor:pointer;font:inherit;font-size:.8rem;font-weight:600}.hidden-content__empty{padding:1rem;background:var(--surface);border:1px dashed var(--border);border-radius:var(--r-md);color:var(--muted)}.hidden-content__notice:empty{display:none}.hidden-content__fields{border:0;margin:0;padding:0;min-width:0;display:flex;flex-direction:column;gap:.75rem}.hidden-content .btn,.hidden-content .input,.hidden-content summary{min-height:2.75rem;min-width:2.75rem}.hidden-content .input{font-size:1rem;box-sizing:border-box}.hidden-content__editor{max-height:48dvh;min-height:8rem;overflow:hidden;border:1px solid var(--border);border-radius:var(--r-md);background:var(--surface)}.hidden-content__editor .cm-editor{height:100%}.hidden-content__editor .cm-scroller{font-family:ui-monospace,Cascadia Mono,JetBrains Mono,Menlo,Consolas,monospace;font-size:13px;line-height:1.5}.hidden-content__editor .cm-content{padding:8px 0}.hidden-content__editor .cm-gutters{background:#282c34;border-right:1px solid #21252b;color:var(--muted)}.hidden-content__editor .hc__toggle{cursor:pointer}.hidden-content__editor .hc__toggle-marker{display:inline-flex;align-items:center;justify-content:center;width:2rem;height:100%;text-align:center;color:var(--accent);font-weight:700}.hidden-content__editor .hc__line-hidden{background:var(--accent-soft)}.hidden-content__editor .hc__char-hidden{background:var(--accent-soft);border-bottom:2px dotted var(--accent)}.hidden-content__toolbar{display:flex;flex-wrap:wrap;align-items:center;gap:.5rem;padding-top:.5rem}.hidden-content__toolbar>.hidden-content__muted{flex:1 1 auto;min-width:0}.hidden-content button:focus-visible,.hidden-content summary:focus-visible{outline:2px solid var(--accent);outline-offset:-2px}.hidden-content button:disabled{opacity:.5;cursor:default}.hidden-content__manual{border:1px solid var(--border);border-radius:var(--r-md);padding:0 .75rem .75rem}.hidden-content__manual summary{display:list-item;align-content:center;cursor:pointer;font-size:.9rem;padding:.5rem 0;box-sizing:border-box}.hidden-content__manual:not([open]){padding-bottom:0}.hidden-content__range{display:flex;align-items:flex-end;gap:.5rem;margin:.75rem 0}.hidden-content__range label{flex:1;min-width:0;display:grid;gap:.25rem;font-size:.8rem}.hidden-content__range input{width:100%}.hidden-content__preview-error{display:grid;gap:.5rem}.hidden-content__preview-error .btn{justify-self:start}.hidden-content__footer{position:sticky;bottom:0;z-index:2;padding:.75rem 0 max(.75rem,env(safe-area-inset-bottom));border-top:1px solid var(--border);background:var(--bg);display:grid;gap:.5rem}.hidden-content__selection{min-width:0;font-size:.9rem}.hidden-content__selection>.hidden-content__muted{max-height:3rem;overflow:auto}.hidden-content__actions{display:flex;flex-wrap:wrap;align-items:center;gap:.5rem}.hidden-content__actions>.hidden-content__hide-action{margin-right:auto}.hidden-content__error{color:var(--danger);font-size:.85rem;line-height:1.5}.hidden-content .afp__iconbtn{min-height:2.75rem;min-width:2.75rem}.hidden-content .afp__row{min-height:2.75rem}
@@ -0,0 +1 @@
1
+ function n(e){return e?"?projectDir="+encodeURIComponent(e):""}export{n as p};
@@ -0,0 +1 @@
1
+ function k(w){const{itemHeight:e,overscan:u,scrollTop:g,viewportHeight:l,count:a}=w;if(!e||e<=0)throw new Error("itemHeight must be > 0");if(a===0)return{start:0,end:0,padTop:0,padBottom:0};const y=Math.floor(g/e),p=Math.ceil(l/e),h=Math.min(Math.max(0,y-u),a),s=Math.min(a,Math.max(h,y+p+u));return{start:h,end:s,padTop:h*e,padBottom:Math.max(0,(a-s)*e)}}const z=Object.freeze({itemHeight:44,overscan:4,render:function(w,e){e.textContent=String(w)}});function F(w){if(typeof window>"u"||typeof document>"u")throw new Error("createVirtualList requires a browser environment");const e=Object.assign({},z,w||{});if(!e.scroller||!e.scroller.nodeType)throw new Error("scroller element is required");let u=Array.isArray(e.data)?e.data.slice():[],g=!1,l=null,a=null,y=!1;const p=typeof e.key=="function"?e.key:null,h=p?new Map:null,s=document.createElement("div");s.style.position="relative",s.style.width="100%",s.style.willChange="transform";const n=[];function M(o){for(;n.length<o;){const t=document.createElement("div");t.style.position="absolute",t.style.left="0",t.style.right="0",t.style.height=e.itemHeight+"px",t.style.willChange="transform",s.appendChild(t),n.push(t)}for(;n.length>o;){const t=n.pop();t.parentNode&&t.parentNode.removeChild(t)}}function x(){if(y)return;g=!1;const o=e.scroller.scrollTop,t=e.scroller.clientHeight,i=k({itemHeight:e.itemHeight,overscan:e.overscan,scrollTop:o,viewportHeight:t,count:u.length});if(!(l&&l.start===i.start&&l.end===i.end&&l.padTop===i.padTop&&l.padBottom===i.padBottom)){if(l=i,s.style.height=i.padTop+(i.end-i.start)*e.itemHeight+i.padBottom+"px",M(i.end-i.start),p){const c=new Array(n.length),f=new Set;for(let r=0;r<n.length;r++){const m=i.start+r,d=u[m];if(d===void 0)continue;const N=p(d),T=h.get(N);T&&n.indexOf(T)>=0&&!f.has(T)&&(c[r]=T,f.add(T))}for(let r=0;r<n.length;r++)if(!c[r])for(let m=0;m<n.length;m++){const d=n[m];if(!f.has(d)){c[r]=d,f.add(d);break}}n.length=0;for(const r of c)r&&n.push(r);h.clear()}for(let c=0;c<n.length;c++){const f=n[c],r=i.start+c,m=i.padTop+c*e.itemHeight;f.style.transform="translateY("+m+"px)";const d=u[r];d!==void 0&&(p&&h.set(p(d),f),e.render(d,f,r))}}}function v(){if(!g){g=!0;var o=window.requestAnimationFrame||function(t){return setTimeout(t,16)};a=o(x)}}function H(){v()}function E(){v()}e.scroller.addEventListener("scroll",H,{passive:!0}),window.addEventListener("resize",E,{passive:!0}),e.scroller.appendChild(s),v();function A(o){u=Array.isArray(o)?o.slice():[],l=null,v()}function C(){return u.slice()}function b(){l=null,v()}function L(o){const t=Math.max(0,Math.min(u.length-1,o|0));g&&x(),e.scroller.scrollTop=t*e.itemHeight,l=null,x()}function B(){for(a!==null&&(window.cancelAnimationFrame?window.cancelAnimationFrame(a):clearTimeout(a),a=null),y=!0,e.scroller.removeEventListener("scroll",H),window.removeEventListener("resize",E,{passive:!0});n.length;){const o=n.pop();o.parentNode&&o.parentNode.removeChild(o)}s.parentNode&&s.parentNode.removeChild(s),l=null}return{setData:A,getData:C,refresh:b,scrollToIndex:L,destroy:B,_range:function(){return l}}}export{F as c};
Binary file
Binary file
@@ -0,0 +1,83 @@
1
+ <!doctype html>
2
+ <!--
3
+ mouaif web entry. Served by `mouaif serve` at / (the root).
4
+
5
+ Build:
6
+ npm run build:web # writes frontend/dist/
7
+
8
+ Dev (with HMR):
9
+ npm run dev:web # vite dev server on :5173 (optional)
10
+
11
+ The script and style references are relative so Vite can resolve and
12
+ hash them at build time. The base URL in vite.config.js keeps the
13
+ bundle absolute at the root when it is served.
14
+
15
+ This page is the mobile UI for mouaif. It is a Preact + Vite app
16
+ served from frontend/dist/ by the Node server. See docs/decisions.md
17
+ sections 7 and 8.
18
+
19
+ PWA plumbing:
20
+ - <link rel="manifest"> declares the app metadata. Served from
21
+ public/manifest.webmanifest (Vite copies public/ verbatim into
22
+ dist/, so the absolute URL /manifest.webmanifest matches
23
+ what the browser expects).
24
+ - apple-mobile-web-app-* meta tags make the page installable on
25
+ iOS Safari (Apple doesn't honour the manifest scope field, so
26
+ the iOS-specific tags are required for an Add-to-Home-Screen
27
+ experience there).
28
+ - <link rel="apple-touch-icon"> points at the same 180x180 PNG
29
+ the manifest exposes. iOS picks this up when the user taps
30
+ Share → Add to Home Screen.
31
+ - The service worker is registered from src/main.jsx (after the
32
+ first paint, in production builds only — dev mode skips it so
33
+ HMR stays fast and stale code doesn't get cached).
34
+ -->
35
+ <html lang="en">
36
+ <head>
37
+ <meta charset="utf-8" />
38
+ <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover, interactive-widget=resizes-content" />
39
+ <meta name="theme-color" content="#111418" />
40
+ <meta name="color-scheme" content="dark" />
41
+ <meta name="description" content="mouaif — chat with AI providers, manage prompts, run tools against your projects." />
42
+ <meta name="apple-mobile-web-app-capable" content="yes" />
43
+ <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
44
+ <meta name="apple-mobile-web-app-title" content="mouaif" />
45
+ <meta name="mobile-web-app-capable" content="yes" />
46
+ <link rel="manifest" href="/manifest.webmanifest" />
47
+ <link rel="icon" type="image/png" sizes="32x32" href="/icons/favicon-32.png" />
48
+ <link rel="apple-touch-icon" href="/icons/icon-180-apple.png" />
49
+ <title>mouaif</title>
50
+ <!--
51
+ Paint the page background before the bundle's stylesheet arrives.
52
+
53
+ Until this rule existed the app's first frame was the browser's default
54
+ canvas: #fff, a full-screen WHITE flash, because the document's only
55
+ background came from the `@import`-ed index CSS and the external
56
+ stylesheet is requested before the module script runs. On a cold load,
57
+ a hard reload, or a profile where the SW has not yet taken over, that
58
+ is one or more frames of pure white between the commit and the
59
+ stylesheet — reported as "grey flashes with page reload".
60
+
61
+ Two details make this the right fix rather than a workaround:
62
+ - the inline <style> is in the document, so it applies at the first
63
+ paint — the white canvas is never composited;
64
+ - the color is the SAME --bg token the app settles on (#0a0d12, the
65
+ literal value of `--bg` in frontend/src/base.css). The bundle's CSS
66
+ re-declares it with the accent glow a tick later, so the swap is an
67
+ invisible repaint of the same base colour instead of a step.
68
+
69
+ Keep the value in sync with --bg in frontend/src/base.css, and keep this
70
+ rule BEFORE the bundler-injected `<link rel="stylesheet">` (Vite appends
71
+ it at the end of <head>): the bundle's `html` rule sets the same
72
+ `background` property at equal specificity, so coming later on equal
73
+ footing is exactly what lets the glow take over once it has loaded.
74
+ scripts/test-index-first-paint.mjs asserts both facts.
75
+ -->
76
+ <style>html{background:#0a0d12}</style>
77
+ <script type="module" crossorigin src="/assets/index-BGvI4n0T.js"></script>
78
+ <link rel="stylesheet" crossorigin href="/assets/index-CANPYzQg.css">
79
+ </head>
80
+ <body>
81
+ <main id="app"></main>
82
+ </body>
83
+ </html>
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "mouaif",
3
+ "short_name": "mouaif",
4
+ "description": "Mobile UI for mouaif — chat with AI providers, manage prompts, and run tools against your projects.",
5
+ "id": "/",
6
+ "start_url": "/",
7
+ "scope": "/",
8
+ "display": "standalone",
9
+ "orientation": "portrait",
10
+ "theme_color": "#111418",
11
+ "background_color": "#0c0f14",
12
+ "categories": ["productivity", "developer", "utilities"],
13
+ "icons": [
14
+ {
15
+ "src": "/icons/icon-192.png",
16
+ "sizes": "192x192",
17
+ "type": "image/png",
18
+ "purpose": "any"
19
+ },
20
+ {
21
+ "src": "/icons/icon-512.png",
22
+ "sizes": "512x512",
23
+ "type": "image/png",
24
+ "purpose": "any"
25
+ },
26
+ {
27
+ "src": "/icons/icon-maskable-512.png",
28
+ "sizes": "512x512",
29
+ "type": "image/png",
30
+ "purpose": "maskable"
31
+ }
32
+ ]
33
+ }
@@ -0,0 +1,482 @@
1
+ // mouaif web — service worker (precache the app shell, network-first
2
+ // for everything else).
3
+ //
4
+ // Build:
5
+ // This file is the source. A small Vite plugin in vite.config.js
6
+ // reads it, sets CACHE_VERSION to a content hash, and emits the
7
+ // result as sw.js at the dist root. The Node server serves it at
8
+ // /sw.js with Cache-Control: no-cache and
9
+ // Service-Worker-Allowed: /.
10
+ //
11
+ // Strategy:
12
+ // - install: precache the shell (index.html, manifest, icons).
13
+ // - activate: delete any old mouaif-v* caches.
14
+ // - fetch:
15
+ // * same-origin GET under the app scope (/):
16
+ // - navigation requests (HTML): network-first, fall back
17
+ // to the cached shell so a cold offline launch still
18
+ // renders the UI.
19
+ // - static assets (CSS/JS/PNG/icons/webmanifest):
20
+ // cache-first (they're fingerprinted, so cache hits
21
+ // are always valid).
22
+ // * anything else (cross-origin, /api/*, /events, /data, POST,
23
+ // SSE, WebSocket): bypass the SW entirely. The chat UI is
24
+ // fundamentally a live API surface; we cannot meaningfully
25
+ // cache SSE streams or live HTML pages, and serving a stale
26
+ // index.html from a previous version while the JS bundle
27
+ // URL is new (or vice versa) was the original half-broken
28
+ // UI bug.
29
+ //
30
+ // Scope:
31
+ // The registration is `scope: '/'` (see main.jsx). The SW intercepts
32
+ // same-origin GET requests under the root, so it must explicitly
33
+ // bypass every non-PWA surface: /api/*, /events, /data, /oauth/*.
34
+ // Those are checked in isShellAssetPath below.
35
+
36
+ /* eslint-disable no-restricted-globals */
37
+
38
+ const CACHE_VERSION = 'f3b647b9';
39
+ const CACHE_NAME = 'mouaif-v' + CACHE_VERSION;
40
+ const SHELL_CACHE = 'mouaif-shell-v' + CACHE_VERSION;
41
+
42
+ // Files to precache on install. The routes are absolute-from-root
43
+ // because the SW runs over the root scope. The hashed JS/CSS
44
+ // names aren't known at build time, so the precache list contains
45
+ // only the stable, unhashed shell entries (HTML, manifest, icons).
46
+ // Hashed assets are cached on first fetch via the cache-first
47
+ // branch of the fetch handler.
48
+ const SHELL_URLS = [
49
+ '/',
50
+ '/manifest.webmanifest',
51
+ '/icons/icon-192.png',
52
+ '/icons/icon-512.png',
53
+ '/icons/icon-maskable-512.png'
54
+ ];
55
+
56
+ self.addEventListener('install', (event) => {
57
+ // Precache the shell — best-effort. A failure on a single icon
58
+ // (e.g. the user opened / once before icons were built) does
59
+ // not block activation: we still want to skip waiting so a new
60
+ // version can take over without a force-reload.
61
+ event.waitUntil((async () => {
62
+ const cache = await caches.open(SHELL_CACHE);
63
+ const results = await Promise.allSettled(SHELL_URLS.map((u) => cache.add(u)));
64
+ const failed = results.filter((r) => r.status === 'rejected');
65
+ if (failed.length) {
66
+ // eslint-disable-next-line no-console
67
+ console.warn('[mouaif-sw] precache partial:', failed.length, 'of', SHELL_URLS.length);
68
+ }
69
+ // Stay in the waiting state until the user accepts the page's update
70
+ // banner. This avoids activating a new worker under an old JS bundle.
71
+ })());
72
+ });
73
+
74
+ self.addEventListener('activate', (event) => {
75
+ event.waitUntil((async () => {
76
+ // Drop caches that don't belong to the current build. The
77
+ // include() check skips any SW-managed cache for a different
78
+ // origin if a future feature grows one; today there's only one
79
+ // mouaif prefix.
80
+ const keys = await caches.keys();
81
+ await Promise.all(keys.map((k) => {
82
+ if (k.startsWith('mouaif-') && k !== CACHE_NAME && k !== SHELL_CACHE) {
83
+ return caches.delete(k);
84
+ }
85
+ return null;
86
+ }));
87
+ // Take control of every open client so the new version is live
88
+ // without waiting for the user to navigate.
89
+ await self.clients.claim();
90
+ })());
91
+ });
92
+
93
+ function isShellAssetPath(pathname) {
94
+ // The SW scope is '/' (the whole origin), so be strict: intercept
95
+ // only the mobile UI's static shell. Every live API surface is
96
+ // signaled to fall through to the network untouched — /api/* (live
97
+ // data, SSE), /events (SSE), /data (REST), /oauth/* (redirect
98
+ // callbacks), and the favicon. Without this the root-scoped SW
99
+ // would cache API responses as static assets and serve stale data.
100
+ if (pathname === '/api' || pathname.startsWith('/api/')) return false;
101
+ if (pathname === '/events' || pathname.startsWith('/events')) return false;
102
+ if (pathname === '/data' || pathname.startsWith('/data')) return false;
103
+ if (pathname === '/oauth' || pathname.startsWith('/oauth/')) return false;
104
+ if (pathname === '/favicon.ico') return false;
105
+ return true;
106
+ }
107
+
108
+ function isNavigationRequest(request) {
109
+ return request.mode === 'navigate' || (request.method === 'GET' && request.headers.get('accept') && request.headers.get('accept').includes('text/html'));
110
+ }
111
+
112
+ async function networkFirstNavigation(request) {
113
+ // Try the network. If it responds (any 2xx/3xx), update the
114
+ // shell cache and return the network response. If it fails
115
+ // (offline, DNS error), fall back to the cached shell. The
116
+ // fallback uses a fresh Request keyed at the root because the
117
+ // original request may have included a query string we don't
118
+ // care about for the offline shell.
119
+ const cache = await caches.open(SHELL_CACHE);
120
+ try {
121
+ const fresh = await fetch(request);
122
+ if (fresh && fresh.ok) {
123
+ cache.put('/', fresh.clone()).catch(() => {});
124
+ }
125
+ return fresh;
126
+ } catch (err) {
127
+ const cached = await cache.match('/');
128
+ if (cached) return cached;
129
+ throw err;
130
+ }
131
+ }
132
+
133
+ async function cacheFirstAsset(request) {
134
+ const cache = await caches.open(CACHE_NAME);
135
+ const cached = await cache.match(request);
136
+ if (cached) return cached;
137
+ // No cache hit. Fetch, then store on success so the next reload
138
+ // is instant. Non-2xx responses are not cached; a 404 for a
139
+ // missing icon shouldn't pollute the cache forever.
140
+ const fresh = await fetch(request);
141
+ if (fresh && fresh.ok) {
142
+ cache.put(request, fresh.clone()).catch(() => {});
143
+ }
144
+ return fresh;
145
+ }
146
+
147
+ self.addEventListener('fetch', (event) => {
148
+ const request = event.request;
149
+ if (request.method !== 'GET') return; // POST/PUT/DELETE/PATCH always hit the network
150
+ const url = new URL(request.url);
151
+ if (url.origin !== self.location.origin) return; // cross-origin: bypass
152
+
153
+ // The SW scope is /, so be explicit — anything under
154
+ // /api/, /events, /data, /oauth/ must reach the server
155
+ // untouched. The same-origin check above already excludes
156
+ // cross-origin traffic; this rule excludes any same-origin
157
+ // endpoint that isn't a PWA asset.
158
+ if (!isShellAssetPath(url.pathname)) return;
159
+
160
+ if (isNavigationRequest(request)) {
161
+ event.respondWith(networkFirstNavigation(request));
162
+ } else {
163
+ event.respondWith(cacheFirstAsset(request));
164
+ }
165
+ });
166
+
167
+ // ---- Fresh page visibility (notification suppression) -----------------
168
+ //
169
+ // WindowClient.focused / visibilityState are stale on Safari and iOS PWA.
170
+ // Query every live page when a push arrives and suppress only when the page
171
+ // answers with the target chat currently visible. No cached view state is
172
+ // trusted: a suspended page cannot answer and must still receive the alert.
173
+
174
+ function chatIdFromHash(hash) {
175
+ const match = /^#\/chat\/([^/?#]+)/.exec(typeof hash === 'string' ? hash : '');
176
+ if (!match) return '';
177
+ try { return decodeURIComponent(match[1]); } catch { return match[1]; }
178
+ }
179
+ function reportedViewMatchesChat(view, targetUrl) {
180
+ if (!view || !view.visible) return false;
181
+ const reportedChatId = chatIdFromHash(view.hash);
182
+ const targetChatId = chatIdFromHash(targetUrl.hash);
183
+ return !!reportedChatId && reportedChatId === targetChatId;
184
+ }
185
+
186
+
187
+ const pendingViewQueries = new Map(); // queryId -> { clientId, finish(view) }
188
+ let viewQuerySequence = 0;
189
+
190
+ function queryClientView(client) {
191
+ // Ask each live page for fresh state when a push arrives. A page that
192
+ // cannot answer within the deadline is treated as hidden/suspended.
193
+ return new Promise((resolve) => {
194
+ if (!client || typeof client.postMessage !== 'function') {
195
+ resolve(null);
196
+ return;
197
+ }
198
+ const queryId = 'view-' + Date.now() + '-' + (++viewQuerySequence);
199
+ const channel = typeof MessageChannel === 'function' ? new MessageChannel() : null;
200
+ let settled = false;
201
+ const finish = (view) => {
202
+ if (settled) return;
203
+ settled = true;
204
+ clearTimeout(timer);
205
+ pendingViewQueries.delete(queryId);
206
+ try { if (channel) channel.port1.close(); } catch { /* best-effort */ }
207
+ resolve(view && typeof view === 'object' ? { ...view, at: Date.now() } : null);
208
+ };
209
+ pendingViewQueries.set(queryId, { clientId: client.id || '', finish });
210
+ const timer = setTimeout(() => finish(null), 1000);
211
+ if (channel) {
212
+ channel.port1.onmessage = (event) => finish(event.data);
213
+ if (typeof channel.port1.start === 'function') channel.port1.start();
214
+ }
215
+ try {
216
+ const message = { type: 'GET_VISIBILITY_STATE', queryId };
217
+ if (channel) client.postMessage(message, [channel.port2]);
218
+ else client.postMessage(message);
219
+ } catch {
220
+ // Some WebKit versions reject a transferred MessagePort even though
221
+ // ordinary Client.postMessage works. Retry without transfer and let
222
+ // VISIBILITY_STATE_RESPONSE resolve this query.
223
+ try { client.postMessage({ type: 'GET_VISIBILITY_STATE', queryId }); }
224
+ catch { finish(null); }
225
+ }
226
+ });
227
+ }
228
+
229
+ self.addEventListener('message', (event) => {
230
+ // The page may post `{ type: 'SKIP_WAITING' }` after the user
231
+ // accepts an "Update available — reload" prompt.
232
+ if (event.data && event.data.type === 'SKIP_WAITING') {
233
+ self.skipWaiting();
234
+ }
235
+ // iOS WebKit may drop the MessagePort transferred with a visibility
236
+ // query. The page mirrors its answer as a regular service-worker message;
237
+ // resolve the matching fresh query only when it came from that client.
238
+ if (event.data && event.data.type === 'VISIBILITY_STATE_RESPONSE' && event.data.queryId) {
239
+ const pending = pendingViewQueries.get(event.data.queryId);
240
+ const sourceId = event.source && event.source.id ? event.source.id : '';
241
+ if (pending && (!pending.clientId || pending.clientId === sourceId)) pending.finish(event.data.state);
242
+ }
243
+ // Navigate to a specific URL (e.g. deep link from notification click).
244
+ // Clients that want to move an EXISTING app window (iOS PWA: no
245
+ // WindowClient.navigate()) post the full target URL here; the page
246
+ // swaps its own hash. This handler only opens a window when the
247
+ // caller knows no client exists (notification click with no
248
+ // matching window falls back to clients.openWindow itself).
249
+ if (event.data && event.data.type === 'NAVIGATE' && event.data.url) {
250
+ clients.openWindow(event.data.url);
251
+ }
252
+ });
253
+
254
+ // ---- Push notifications ------------------------------------------------
255
+
256
+ self.addEventListener('push', (event) => {
257
+ let data;
258
+ try { data = event.data ? event.data.json() : {}; } catch { data = {}; }
259
+ const { title, body, tag, renotify, icon, badge, data: payload, actions, requireInteraction } = data;
260
+ if (!title && !body) return;
261
+
262
+ event.waitUntil((async () => {
263
+ const windows = await clients.matchAll({ type: 'window', includeUncontrolled: true });
264
+ const targetUrl = payload && payload.url ? new URL(payload.url, self.location.origin) : null;
265
+ const freshViews = targetUrl ? await Promise.all(windows.map(queryClientView)) : [];
266
+ // Suppress only when a page answers this push-time query and confirms
267
+ // that the target chat is visible. A suspended or recently closed PWA
268
+ // can remain in clients.matchAll() with stale `focused: true` and a
269
+ // stale cached visibility report while its JavaScript cannot answer.
270
+ // Treating that stale state as visible intermittently discarded the
271
+ // authorization push exactly when the app was no longer usable. If no
272
+ // fresh answer arrives, showing the alert is the safe outcome.
273
+ const chatVisible = !!targetUrl && freshViews.some((view) =>
274
+ reportedViewMatchesChat(view, targetUrl));
275
+ const queued = await self.registration.getNotifications();
276
+ if (chatVisible) {
277
+ // Entering or staying in the target chat makes its queued alerts stale.
278
+ // Clear them as well as suppressing the incoming push so the tray does
279
+ // not keep showing notifications for content already on screen.
280
+ if (payload && payload.chatId) {
281
+ queued.forEach((notification) => {
282
+ if (notification.data && notification.data.chatId === payload.chatId) notification.close();
283
+ });
284
+ }
285
+ return;
286
+ }
287
+ const notifTag = tag || 'default';
288
+ // iOS can retain replaced notifications. Prune both the exact tag and
289
+ // every legacy status-slot tag for this chat before showing a progress,
290
+ // completion, or error update.
291
+ const statusKinds = new Set(['progress', 'completion', 'error']);
292
+ const authorizationKinds = new Set(['ask_user', 'tool_authorization']);
293
+ const incomingIsStatus = payload && statusKinds.has(payload.kind);
294
+ const incomingIsAuthorization = payload && authorizationKinds.has(payload.kind);
295
+ queued.forEach((notification) => {
296
+ const sameTag = notifTag !== 'default' && notification.tag === notifTag;
297
+ const sameChat = notification.data && payload
298
+ && notification.data.chatId === payload.chatId;
299
+ const sameStatusSlot = incomingIsStatus && sameChat
300
+ && statusKinds.has(notification.data.kind);
301
+ const sameAuthorizationSlot = incomingIsAuthorization && sameChat
302
+ && authorizationKinds.has(notification.data.kind);
303
+ if (sameTag || sameStatusSlot || sameAuthorizationSlot) notification.close();
304
+ });
305
+ await self.registration.showNotification(title || 'mouaif', {
306
+ body: body || '',
307
+ tag: notifTag,
308
+ renotify: renotify !== false,
309
+ icon: icon || '/icons/icon-192.png',
310
+ badge: badge || '/icons/favicon-32.png',
311
+ data: payload || {},
312
+ actions: actions || [{ action: 'open', title: 'Open chat' }],
313
+ requireInteraction: requireInteraction === true,
314
+ vibrate: requireInteraction === true ? [150, 80, 150] : [100]
315
+ });
316
+ })());
317
+ });
318
+
319
+ async function openNotificationTarget(data) {
320
+ const urlToOpen = data.url
321
+ || (data.chatId && data.projectDir
322
+ ? '/#/chat/' + data.chatId + '?projectDir=' + encodeURIComponent(data.projectDir)
323
+ : '/');
324
+ const target = new URL(urlToOpen, self.location.origin);
325
+ const clientList = await clients.matchAll({ type: 'window', includeUncontrolled: true });
326
+ // The client's URL always carries its *current* hash (never the one we
327
+ // want to move to), so the match below must look at the pathname only —
328
+ // comparing hashes here would miss every existing window and reopen the
329
+ // app in a new tab on every click.
330
+ const isAppWindow = (client) => {
331
+ let url;
332
+ try { url = new URL(client.url); } catch { return false; }
333
+ return url.origin === self.location.origin && url.pathname === '/';
334
+ };
335
+ const appWindow = clientList.find(isAppWindow);
336
+ if (appWindow && 'focus' in appWindow) {
337
+ // iOS Safari does not support WindowClient.navigate(): posting the
338
+ // target URL to the page is the only way to move an existing PWA
339
+ // window there. The page swaps the hash (or reloads when it is
340
+ // already there) and then focuses itself.
341
+ //
342
+ // Persist the click target as a fallback BEFORE posting the message.
343
+ // A suspended iOS PWA can be woken by focus without its JS running to
344
+ // receive the NAVIGATE postMessage (WebKit drops it), and the app then
345
+ // reopens at start_url instead of the clicked chat. Writing the same
346
+ // target the cold-launch path uses lets the woken page consume it on
347
+ // load / focus / visible and recover the navigation. The page clears
348
+ // the store once a NAVIGATE is actually handled, so a successful move
349
+ // never re-triggers.
350
+ await writeClickTarget(target.href);
351
+ appWindow.postMessage({ type: 'NAVIGATE', url: target.href });
352
+ return appWindow.focus();
353
+ }
354
+ // No matchable window: persist the click target BEFORE attempting to
355
+ // open one. iOS PWA: when the app is closed or suspended, tapping the
356
+ // notification relaunches the installed app at its start_url (/),
357
+ // and clients.openWindow() is not supported there — the click would
358
+ // land on the home screen. Writing the URL to IndexedDB lets the
359
+ // freshly launched page consume it and navigate to the chat (see
360
+ // frontend/src/sw-registration.js — consumePendingNotificationClick).
361
+ await writeClickTarget(target.href);
362
+ try {
363
+ const opened = await clients.openWindow(target.href);
364
+ // openWindow succeeded (desktop / non-iOS). Only clear the stored
365
+ // target when the new window actually loaded the clicked chat URL —
366
+ // compare the hash, since the window's URL always carries its current
367
+ // hash and that is the only thing we need to match. iOS can resolve
368
+ // openWindow() WITHOUT navigating to the clicked URL (it may reopen
369
+ // start_url / instead), so clearing here on a non-match would discard
370
+ // the only navigation hint and drop the user on the chats list. A
371
+ // non-matching window keeps the target; the freshly loaded page's
372
+ // consumePendingNotificationClick() then recovers it. Keeping it on
373
+ // desktop when the URLs do match is impossible, so this is safe.
374
+ const openedUrl = (opened && typeof opened.url === 'string')
375
+ ? new URL(opened.url, self.location.origin) : null;
376
+ if (openedUrl && openedUrl.hash === target.hash) await clearClickTarget();
377
+ return opened;
378
+ } catch {
379
+ // Fall through; the launched page consumes the IndexedDB click target.
380
+ return undefined;
381
+ }
382
+ }
383
+
384
+ // ---- IndexedDB click-target handoff --------------------------------
385
+ //
386
+ // The service worker and the page share a tiny store of the most recent
387
+ // notification click URL. iOS PWA cannot rely on clients.openWindow()
388
+ // when the app is closed; the SW writes the URL here, the page reads
389
+ // and clears it on load (or on visibilitychange when the app comes
390
+ // back), and navigates if it points at a chat.
391
+ const CLICK_DB = 'mouaif-push-click';
392
+ const CLICK_STORE = 'clicks';
393
+ const CLICK_KEY = 'latest';
394
+
395
+ function clickDb() {
396
+ return new Promise((resolve, reject) => {
397
+ if (!self.indexedDB) return reject(new Error('no indexedDB'));
398
+ const req = self.indexedDB.open(CLICK_DB, 1);
399
+ req.onupgradeneeded = () => {
400
+ const db = req.result;
401
+ if (!db.objectStoreNames.contains(CLICK_STORE)) db.createObjectStore(CLICK_STORE);
402
+ };
403
+ req.onsuccess = () => resolve(req.result);
404
+ req.onerror = () => reject(req.error || new Error('indexedDB open failed'));
405
+ });
406
+ }
407
+
408
+ function writeClickTarget(url) {
409
+ return clickDb().then((db) => new Promise((resolve) => {
410
+ const tx = db.transaction(CLICK_STORE, 'readwrite');
411
+ tx.objectStore(CLICK_STORE).put({ url, at: Date.now() }, CLICK_KEY);
412
+ tx.oncomplete = () => resolve();
413
+ tx.onerror = () => resolve();
414
+ })).catch(() => {});
415
+ }
416
+
417
+ function clearClickTarget() {
418
+ return clickDb().then((db) => new Promise((resolve) => {
419
+ const tx = db.transaction(CLICK_STORE, 'readwrite');
420
+ tx.objectStore(CLICK_STORE).delete(CLICK_KEY);
421
+ tx.oncomplete = () => resolve();
422
+ tx.onerror = () => resolve();
423
+ })).catch(() => {});
424
+ }
425
+
426
+ function readAndClearClickTarget() {
427
+ return clickDb().then((db) => new Promise((resolve) => {
428
+ const tx = db.transaction(CLICK_STORE, 'readwrite');
429
+ const store = tx.objectStore(CLICK_STORE);
430
+ const get = store.get(CLICK_KEY);
431
+ get.onsuccess = () => {
432
+ const row = get.result;
433
+ if (row && row.url && Date.now() - (row.at || 0) < 60 * 1000) {
434
+ store.delete(CLICK_KEY);
435
+ resolve(row.url);
436
+ } else {
437
+ if (row) store.delete(CLICK_KEY);
438
+ resolve(null);
439
+ }
440
+ };
441
+ get.onerror = () => resolve(null);
442
+ })).catch(() => null);
443
+ }
444
+
445
+ async function submitNotificationDecision(data, action) {
446
+ if (!data.projectDir || !data.chatId || !data.callId) throw new Error('missing decision context');
447
+ let decision = action;
448
+ let payload;
449
+ if (action.startsWith('answer-')) {
450
+ const index = Number(action.slice('answer-'.length));
451
+ const option = Array.isArray(data.options) ? data.options[index] : null;
452
+ if (!option || !option.value) throw new Error('missing answer option');
453
+ decision = 'allow-once';
454
+ payload = { choice: option.value, extra: '' };
455
+ }
456
+ const response = await fetch('/api/tools/authorization/decision', {
457
+ method: 'POST',
458
+ credentials: 'include',
459
+ headers: { 'Content-Type': 'application/json' },
460
+ body: JSON.stringify({
461
+ projectDir: data.projectDir,
462
+ chatId: data.chatId,
463
+ callId: data.callId,
464
+ decision,
465
+ payload
466
+ })
467
+ });
468
+ if (!response.ok) throw new Error('decision failed');
469
+ }
470
+
471
+ self.addEventListener('notificationclick', (event) => {
472
+ const data = event.notification.data || {};
473
+ event.notification.close();
474
+ const action = event.action || 'open';
475
+ if (action === 'open') event.waitUntil(openNotificationTarget(data));
476
+ else event.waitUntil(submitNotificationDecision(data, action).catch(() => openNotificationTarget(data)));
477
+ });
478
+
479
+ self.addEventListener('notificationclose', (event) => {
480
+ // Notification dismissed by user — could send a beacon for analytics
481
+ // but is intentionally a no-op for now.
482
+ });