@amd-gaia/agent-ui 0.24.1 → 0.25.0-rc.8

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 (33) hide show
  1. package/README.md +4 -3
  2. package/bin/gaia-ui.cjs +42 -18
  3. package/dist/assets/AdvancedSettings-Ce6EvcAe.css +1 -0
  4. package/dist/assets/AdvancedSettings-DNQod9tb.js +1 -0
  5. package/dist/assets/ConnectorsPane-CG7atLb2.css +1 -0
  6. package/dist/assets/ConnectorsPane-DXvmZT6E.js +1 -0
  7. package/dist/assets/DocumentLibrary-DrIP_032.css +1 -0
  8. package/dist/assets/DocumentLibrary-H6_dL1FQ.js +1 -0
  9. package/dist/assets/FileBrowser-Bj6aB6Ro.css +1 -0
  10. package/dist/assets/FileBrowser-JEScQZqn.js +4 -0
  11. package/dist/assets/MemoryDashboard-B-YH4b1Z.css +1 -0
  12. package/dist/assets/MemoryDashboard-CK5IZKJ8.js +1 -0
  13. package/dist/assets/MobileAccessModal-CV7e2cgI.js +2 -0
  14. package/dist/assets/MobileAccessModal-C_t8DG0n.css +1 -0
  15. package/dist/assets/ScheduleManager-A5eLWyGP.js +2 -0
  16. package/dist/assets/ScheduleManager-CIescbX6.css +1 -0
  17. package/dist/assets/{browser-BKCjVmVD.js → browser-CCrYwriZ.js} +2 -2
  18. package/dist/assets/index-B8E7IBmc.css +1 -0
  19. package/dist/assets/index-D00FJoXi.js +58 -0
  20. package/dist/assets/jsx-runtime-DVeaULmT.js +1 -0
  21. package/dist/assets/message-square-Ccmbk96j.js +1 -0
  22. package/dist/assets/rolldown-runtime-hePW80VL.js +1 -0
  23. package/dist/index.html +7 -2
  24. package/main.cjs +133 -42
  25. package/package.json +9 -9
  26. package/services/backend-installer-progress-dialog.cjs +34 -15
  27. package/services/backend-installer.cjs +61 -19
  28. package/services/deep-link-queue.cjs +77 -0
  29. package/services/link-policy.cjs +90 -0
  30. package/services/notification-service.cjs +36 -14
  31. package/services/tray-manager.cjs +163 -23
  32. package/dist/assets/index-0fRr8P_5.js +0 -69
  33. package/dist/assets/index-D1S2dWYe.css +0 -1
@@ -0,0 +1 @@
1
+ import{r as e,t}from"./rolldown-runtime-hePW80VL.js";var n=t((e=>{var t=Symbol.for(`react.transitional.element`),n=Symbol.for(`react.portal`),r=Symbol.for(`react.fragment`),i=Symbol.for(`react.strict_mode`),a=Symbol.for(`react.profiler`),o=Symbol.for(`react.consumer`),s=Symbol.for(`react.context`),c=Symbol.for(`react.forward_ref`),l=Symbol.for(`react.suspense`),u=Symbol.for(`react.memo`),d=Symbol.for(`react.lazy`),f=Symbol.for(`react.activity`),p=Symbol.for(`react.view_transition`),m=Symbol.iterator;function h(e){return typeof e!=`object`||!e?null:(e=m&&e[m]||e[`@@iterator`],typeof e==`function`?e:null)}var g={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},_=Object.assign,v={};function y(e,t,n){this.props=e,this.context=t,this.refs=v,this.updater=n||g}y.prototype.isReactComponent={},y.prototype.setState=function(e,t){if(typeof e!=`object`&&typeof e!=`function`&&e!=null)throw Error(`takes an object of state variables to update or a function which returns an object of state variables.`);this.updater.enqueueSetState(this,e,t,`setState`)},y.prototype.forceUpdate=function(e){this.updater.enqueueForceUpdate(this,e,`forceUpdate`)};function b(){}b.prototype=y.prototype;function x(e,t,n){this.props=e,this.context=t,this.refs=v,this.updater=n||g}var S=x.prototype=new b;S.constructor=x,_(S,y.prototype),S.isPureReactComponent=!0;var C=Array.isArray;function w(){}var T={H:null,A:null,T:null,S:null},E=Object.prototype.hasOwnProperty;function D(e,n,r){var i=r.ref;return{$$typeof:t,type:e,key:n,ref:i===void 0?null:i,props:r}}function O(e,t){return D(e.type,t,e.props)}function k(e){return typeof e==`object`&&!!e&&e.$$typeof===t}function A(e){var t={"=":`=0`,":":`=2`};return`$`+e.replace(/[=:]/g,function(e){return t[e]})}var j=/\/+/g;function M(e,t){return typeof e==`object`&&e&&e.key!=null?A(``+e.key):t.toString(36)}function N(e){switch(e.status){case`fulfilled`:return e.value;case`rejected`:throw e.reason;default:switch(typeof e.status==`string`?e.then(w,w):(e.status=`pending`,e.then(function(t){e.status===`pending`&&(e.status=`fulfilled`,e.value=t)},function(t){e.status===`pending`&&(e.status=`rejected`,e.reason=t)})),e.status){case`fulfilled`:return e.value;case`rejected`:throw e.reason}}throw e}function P(e,r,i,a,o){var s=typeof e;(s===`undefined`||s===`boolean`)&&(e=null);var c=!1;if(e===null)c=!0;else switch(s){case`bigint`:case`string`:case`number`:c=!0;break;case`object`:switch(e.$$typeof){case t:case n:c=!0;break;case d:return c=e._init,P(c(e._payload),r,i,a,o)}}if(c)return o=o(e),c=a===``?`.`+M(e,0):a,C(o)?(i=``,c!=null&&(i=c.replace(j,`$&/`)+`/`),P(o,r,i,``,function(e){return e})):o!=null&&(k(o)&&(o=O(o,i+(o.key==null||e&&e.key===o.key?``:(``+o.key).replace(j,`$&/`)+`/`)+c)),r.push(o)),1;c=0;var l=a===``?`.`:a+`:`;if(C(e))for(var u=0;u<e.length;u++)a=e[u],s=l+M(a,u),c+=P(a,r,i,s,o);else if(u=h(e),typeof u==`function`)for(e=u.call(e),u=0;!(a=e.next()).done;)a=a.value,s=l+M(a,u++),c+=P(a,r,i,s,o);else if(s===`object`){if(typeof e.then==`function`)return P(N(e),r,i,a,o);throw r=String(e),Error(`Objects are not valid as a React child (found: `+(r===`[object Object]`?`object with keys {`+Object.keys(e).join(`, `)+`}`:r)+`). If you meant to render a collection of children, use an array instead.`)}return c}function F(e,t,n){if(e==null)return e;var r=[],i=0;return P(e,r,``,``,function(e){return t.call(n,e,i++)}),r}function I(e){if(e._status===-1){var t=e._result,n=t();n.then(function(t){(e._status===0||e._status===-1)&&(e._status=1,e._result=t,n.status===void 0&&(n.status=`fulfilled`,n.value=t))},function(t){(e._status===0||e._status===-1)&&(e._status=2,e._result=t,n.status===void 0&&(n.status=`rejected`,n.reason=t))}),e._status===-1&&(e._status=0,e._result=n)}if(e._status===1)return e._result.default;throw e._result}var L=typeof reportError==`function`?reportError:function(e){if(typeof window==`object`&&typeof window.ErrorEvent==`function`){var t=new window.ErrorEvent(`error`,{bubbles:!0,cancelable:!0,message:typeof e==`object`&&e&&typeof e.message==`string`?String(e.message):String(e),error:e});if(!window.dispatchEvent(t))return}else if(typeof process==`object`&&typeof process.emit==`function`){process.emit(`uncaughtException`,e);return}console.error(e)};function R(e){var t=T.T,n={};n.types=t===null?null:t.types,T.T=n;try{var r=e(),i=T.S;i!==null&&i(n,r),typeof r==`object`&&r&&typeof r.then==`function`&&r.then(w,L)}catch(e){L(e)}finally{t!==null&&n.types!==null&&(t.types=n.types),T.T=t}}function z(e){var t=T.T;if(t!==null){var n=t.types;n===null?t.types=[e]:n.indexOf(e)===-1&&n.push(e)}else R(z.bind(null,e))}var B={map:F,forEach:function(e,t,n){F(e,function(){t.apply(this,arguments)},n)},count:function(e){var t=0;return F(e,function(){t++}),t},toArray:function(e){return F(e,function(e){return e})||[]},only:function(e){if(!k(e))throw Error(`React.Children.only expected to receive a single React element child.`);return e}};e.Activity=f,e.Children=B,e.Component=y,e.Fragment=r,e.Profiler=a,e.PureComponent=x,e.StrictMode=i,e.Suspense=l,e.ViewTransition=p,e.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE=T,e.__COMPILER_RUNTIME={__proto__:null,c:function(e){return T.H.useMemoCache(e)}},e.addTransitionType=z,e.cache=function(e){return function(){return e.apply(null,arguments)}},e.cacheSignal=function(){return null},e.cloneElement=function(e,t,n){if(e==null)throw Error(`The argument must be a React element, but you passed `+e+`.`);var r=_({},e.props),i=e.key;if(t!=null)for(a in t.key!==void 0&&(i=``+t.key),t)!E.call(t,a)||a===`key`||a===`__self`||a===`__source`||a===`ref`&&t.ref===void 0||(r[a]=t[a]);var a=arguments.length-2;if(a===1)r.children=n;else if(1<a){for(var o=Array(a),s=0;s<a;s++)o[s]=arguments[s+2];r.children=o}return D(e.type,i,r)},e.createContext=function(e){return e={$$typeof:s,_currentValue:e,_currentValue2:e,_threadCount:0,Provider:null,Consumer:null},e.Provider=e,e.Consumer={$$typeof:o,_context:e},e},e.createElement=function(e,t,n){var r,i={},a=null;if(t!=null)for(r in t.key!==void 0&&(a=``+t.key),t)E.call(t,r)&&r!==`key`&&r!==`__self`&&r!==`__source`&&(i[r]=t[r]);var o=arguments.length-2;if(o===1)i.children=n;else if(1<o){for(var s=Array(o),c=0;c<o;c++)s[c]=arguments[c+2];i.children=s}if(e&&e.defaultProps)for(r in o=e.defaultProps,o)i[r]===void 0&&(i[r]=o[r]);return D(e,a,i)},e.createRef=function(){return{current:null}},e.forwardRef=function(e){return{$$typeof:c,render:e}},e.isValidElement=k,e.lazy=function(e){return{$$typeof:d,_payload:{_status:-1,_result:e},_init:I}},e.memo=function(e,t){return{$$typeof:u,type:e,compare:t===void 0?null:t}},e.startTransition=R,e.unstable_useCacheRefresh=function(){return T.H.useCacheRefresh()},e.use=function(e){return T.H.use(e)},e.useActionState=function(e,t,n){return T.H.useActionState(e,t,n)},e.useCallback=function(e,t){return T.H.useCallback(e,t)},e.useContext=function(e){return T.H.useContext(e)},e.useDebugValue=function(){},e.useDeferredValue=function(e,t){return T.H.useDeferredValue(e,t)},e.useEffect=function(e,t){return T.H.useEffect(e,t)},e.useEffectEvent=function(e){return T.H.useEffectEvent(e)},e.useId=function(){return T.H.useId()},e.useImperativeHandle=function(e,t,n){return T.H.useImperativeHandle(e,t,n)},e.useInsertionEffect=function(e,t){return T.H.useInsertionEffect(e,t)},e.useLayoutEffect=function(e,t){return T.H.useLayoutEffect(e,t)},e.useMemo=function(e,t){return T.H.useMemo(e,t)},e.useOptimistic=function(e,t){return T.H.useOptimistic(e,t)},e.useReducer=function(e,t,n){return T.H.useReducer(e,t,n)},e.useRef=function(e){return T.H.useRef(e)},e.useState=function(e){return T.H.useState(e)},e.useSyncExternalStore=function(e,t,n){return T.H.useSyncExternalStore(e,t,n)},e.useTransition=function(){return T.H.useTransition()},e.version=`19.3.0`})),r=t(((e,t)=>{t.exports=n()})),i=e(r(),1),a=e=>e?.replace(/([a-z0-9])([A-Z])/g,`$1-$2`).toLowerCase();function o(e,t,n=[]){if(t==null)throw Error(`[lucide]: iconNode is required when icon name is used`);return{name:a(e),size:24,node:t,...n.length>0?{aliases:n}:{}}}var s=e=>{let t=``,n=!1;for(let r of e){if(r===`-`||r===`_`||r<=` `){n=t.length>0;continue}t.length===0?t+=r.toLowerCase():t+=n?r.toUpperCase():r,n=!1}return t},c=e=>{let t=s(e);return t.charAt(0).toUpperCase()+t.slice(1)},l=(...e)=>e.filter((e,t,n)=>!!e&&e.trim()!==``&&n.indexOf(e)===t).join(` `).trim(),u={xmlns:`http://www.w3.org/2000/svg`,width:24,height:24,viewBox:`0 0 24 24`,fill:`none`,stroke:`currentColor`,"stroke-width":2,"stroke-linecap":`round`,"stroke-linejoin":`round`};function d(e){return e!=null}function f(e,t={}){let n=t.attributeNames??{},r=e=>n[e]??e,i=e.size??e.width??u.width,a=e.size??e.height??u.height,o=e.aliases?.filter(e=>typeof e==`string`&&e.trim()!==``).map(e=>`lucide-${e}`)??[],s=[...e.name?[`lucide-${e.name}`]:[],...o],c=t.className?.split(` `).filter(Boolean)??[],f=t.includeDefaultClasses===!1?l(...c):l(`lucide`,...s,...c),p=t.absoluteStrokeWidth?Number(t.strokeWidth??u[`stroke-width`])*Number(e.size??e.width??u.width)/Number(t.size??t.width??u.width):t.strokeWidth??u[`stroke-width`];return[`svg`,{...Object.entries(u).reduce((e,[t,n])=>(e[r(t)]=n,e),{}),...`color`in t&&t.color&&{[r(`stroke`)]:t.color},...`size`in t&&d(t.size)&&{[r(`width`)]:t.size,[r(`height`)]:t.size},...`width`in t&&d(t.width)&&{[r(`width`)]:t.width},...`height`in t&&d(t.height)&&{[r(`height`)]:t.height},[r(`stroke-width`)]:p,...f&&{[r(`class`)]:f},[r(`viewBox`)]:`0 0 ${i} ${a}`,...t.hasA11yProp===!1?{[r(`aria-hidden`)]:`true`}:{},...`attributes`in t&&t.attributes},e.node.map(e=>{let[n,i,a]=e,o=t.nonScalingStroke?{[r(`vector-effect`)]:`non-scaling-stroke`,...i}:i;return a?[n,o,a]:[n,o]})]}function p(e,t={}){return f(e,{...t,attributeNames:{...t.attributeNames,class:`className`,"stroke-width":`strokeWidth`,"stroke-linecap":`strokeLinecap`,"stroke-linejoin":`strokeLinejoin`,"vector-effect":`vectorEffect`}})}var m=e=>{for(let t in e)if(t.startsWith(`aria-`)||t===`role`||t===`title`)return!0;return!1},h=(0,i.createContext)({}),g=()=>(0,i.useContext)(h),_=(0,i.forwardRef)(({color:e,size:t,width:n,height:r,strokeWidth:a,absoluteStrokeWidth:o,nonScalingStroke:s,className:c=``,children:u,iconNode:d=[],icon:f={node:d,aliases:[],size:24},...h},_)=>{let{size:v=24,strokeWidth:y=2,absoluteStrokeWidth:b=!1,nonScalingStroke:x=!1,color:S=`currentColor`,className:C=``}=g()??{},w=!!u||m(h),[T,E,D=[]]=p(f,{color:e??S,width:n??t??v,height:r??t??v,strokeWidth:a??y,absoluteStrokeWidth:o??b,nonScalingStroke:s??x,className:l(C,c),hasA11yProp:w,attributes:h});return(0,i.createElement)(T,{ref:_,...E},[...D.map(([e,t])=>(0,i.createElement)(e,t)),...Array.isArray(u)?u:[u]])});function v(e,t=[],n=[]){let r=typeof e==`string`?o(e,t,n):e,a=(0,i.forwardRef)(({className:e,...t},n)=>(0,i.createElement)(_,{ref:n,icon:r,className:e,...t}));return r.name&&(a.displayName=c(r.name)),a}var y={api:`#4fc3f7`,store:`#ce93d8`,chat:`#81c784`,stream:`#ffb74d`,ui:`#90a4ae`,doc:`#a1887f`,system:`#e57373`,nav:`#fff176`},b={api:`fetch`,store:`state`,chat:`chat`,stream:`stream`,ui:`ui`,doc:`docs`,system:`sys`,nav:`nav`},x=(()=>{try{if(localStorage.getItem(`gaia-debug`)===`true`)return!0;if(typeof location<`u`){let e=new URLSearchParams(location.search);if(e.has(`debug`)||e.has(`verbose`))return!0}}catch{}return!1})();function S(){let e=new Date;return e.toLocaleTimeString(`en-US`,{hour12:!1})+`.`+String(e.getMilliseconds()).padStart(3,`0`)}function C(e){let t=y[e]||`#ccc`,n=`%c[${b[e]||e}]`,r=`color: ${t}; font-weight: bold;`,i=`color: #888; font-weight: normal;`;return{info:(e,...t)=>{if(!x)return;let a=S();t.length>0?console.log(`${n} %c${a} %c${e}`,r,i,`color: inherit;`,...t):console.log(`${n} %c${a} %c${e}`,r,i,`color: inherit;`)},warn:(e,...t)=>{let a=S();console.warn(`${n} %c${a} %c${e}`,r,i,`color: inherit;`,...t)},error:(e,...t)=>{let a=S();console.error(`${n} %c${a} %c${e}`,r,i,`color: inherit;`,...t)},debug:(e,...t)=>{if(!x)return;let a=S();console.debug(`${n} %c${a} %c${e}`,r,i,`color: #999;`,...t)},timed:(e,t,...a)=>{if(!x)return;let o=Math.round(performance.now()-t),s=S(),c=o>2e3?`color: #e57373;`:o>500?`color: #ffb74d;`:`color: #81c784;`;a.length>0?console.log(`${n} %c${s} %c${e} %c(${o}ms)`,r,i,`color: inherit;`,c,...a):console.log(`${n} %c${s} %c${e} %c(${o}ms)`,r,i,`color: inherit;`,c)},time:()=>performance.now()}}var w={api:C(`api`),store:C(`store`),chat:C(`chat`),stream:C(`stream`),ui:C(`ui`),doc:C(`doc`),system:C(`system`),nav:C(`nav`)};function T(e){console.log(`%c GAIA %c v`+e+` %c Local AI Desktop`,`background: #9A4930; color: white; font-weight: bold; padding: 4px 8px; border-radius: 4px 0 0 4px;`,`background: #333; color: #fff; padding: 4px 8px;`,`background: #1a1a1a; color: #888; padding: 4px 8px; border-radius: 0 4px 4px 0;`),console.log(x?`%cVerbose logging enabled. Categories: [fetch] [state] [chat] [stream] [ui] [docs] [sys] [nav]`:`%cVerbose logging: add ?debug to URL or run localStorage.setItem("gaia-debug","true")`,`color: #666; font-style: italic;`)}var E=/^https?:\/\/(127\.0\.0\.1|localhost):\d+\/api$/,D=`http://localhost:4200/api`;function O(){if(typeof window>`u`)return`/api`;if(window.location.protocol===`file:`){let e=new URLSearchParams(window.location.search).get(`api`);return e&&E.test(e)?e:D}return`/api`}var k=t((e=>{var t=Symbol.for(`react.transitional.element`),n=Symbol.for(`react.fragment`);function r(e,n,r){var i=null;if(r!==void 0&&(i=``+r),n.key!==void 0&&(i=``+n.key),`key`in n)for(var a in r={},n)a!==`key`&&(r[a]=n[a]);else r=n;return n=r.ref,{$$typeof:t,type:e,key:i,ref:n===void 0?null:n,props:r}}e.Fragment=n,e.jsx=r,e.jsxs=r})),A=t(((e,t)=>{t.exports=k()}));export{v as a,T as i,O as n,r as o,w as r,A as t};
@@ -0,0 +1 @@
1
+ import{a as e}from"./jsx-runtime-DVeaULmT.js";var t={name:`message-square`,size:24,node:[[`path`,{d:`M22 17a2 2 0 0 1-2 2H6.828a2 2 0 0 0-1.414.586l-2.202 2.202A.71.71 0 0 1 2 21.286V5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2z`,key:`18887p`}]]};t.node;var n=e(t);export{n as t};
@@ -0,0 +1 @@
1
+ var e=Object.create,t=Object.defineProperty,n=Object.getOwnPropertyDescriptor,r=Object.getOwnPropertyNames,i=Object.getPrototypeOf,a=Object.prototype.hasOwnProperty,o=(e,t)=>()=>(t||(e((t={exports:{}}).exports,t),e=null),t.exports),s=(e,n)=>{let r={};for(var i in e)t(r,i,{get:e[i],enumerable:!0});return n||t(r,Symbol.toStringTag,{value:`Module`}),r},c=(e,i,o,s)=>{if(i&&typeof i==`object`||typeof i==`function`)for(var c=r(i),l=0,u=c.length,d;l<u;l++)d=c[l],!a.call(e,d)&&d!==o&&t(e,d,{get:(e=>i[e]).bind(null,d),enumerable:!(s=n(i,d))||s.enumerable});return e},l=(n,r,o)=>(o=n==null?{}:e(i(n)),c(r||!n||!n.__esModule||!a.call(n,`default`)?t(o,`default`,{value:n,enumerable:!0}):o,n));export{s as n,l as r,o as t};
package/dist/index.html CHANGED
@@ -3,10 +3,15 @@
3
3
  <head>
4
4
  <meta charset="UTF-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <!-- Graphite canvas; rewritten per theme by applyTheme(). Dark is the
7
+ designed default, so an unset preference stays dark. -->
8
+ <meta name="theme-color" content="#17161C" />
6
9
  <link rel="icon" type="image/png" href="./favicon.png" />
7
10
  <title>GAIA Agent UI</title>
8
- <script type="module" crossorigin src="./assets/index-0fRr8P_5.js"></script>
9
- <link rel="stylesheet" crossorigin href="./assets/index-D1S2dWYe.css">
11
+ <script type="module" crossorigin src="./assets/index-D00FJoXi.js"></script>
12
+ <link rel="modulepreload" crossorigin href="./assets/rolldown-runtime-hePW80VL.js">
13
+ <link rel="modulepreload" crossorigin href="./assets/jsx-runtime-DVeaULmT.js">
14
+ <link rel="stylesheet" crossorigin href="./assets/index-B8E7IBmc.css">
10
15
  </head>
11
16
  <body>
12
17
  <div id="root"></div>
package/main.cjs CHANGED
@@ -70,6 +70,12 @@ const {
70
70
  dispatchDeepLink,
71
71
  buildInstallPrompt,
72
72
  } = require("./services/deep-link.cjs");
73
+ const {
74
+ isAllowedExternalUrl,
75
+ isInAppNavigation,
76
+ describeBlockedUrl,
77
+ } = require("./services/link-policy.cjs");
78
+ const { createStartupDeepLinkQueue } = require("./services/deep-link-queue.cjs");
73
79
 
74
80
  // ── F7: Ozone hint (issue #782) ─────────────────────────────────────────────
75
81
  // Electron-recommended switch for distro-agnostic Linux behaviour: picks
@@ -306,10 +312,12 @@ async function startBackend() {
306
312
  try {
307
313
  backendPort = await portManager.findFreePort();
308
314
  } catch (err) {
309
- console.warn(
310
- `[main] findFreePort failed (${err.message}); falling back to ${DEFAULT_BACKEND_PORT}`
315
+ throw new Error(
316
+ `Could not reserve a local port for the GAIA backend: ${err.message}. ` +
317
+ "Check that security software isn't blocking 127.0.0.1, then relaunch GAIA. " +
318
+ `Logs: ${_MAIN_LOG_PATH}`,
319
+ { cause: err }
311
320
  );
312
- backendPort = DEFAULT_BACKEND_PORT;
313
321
  }
314
322
  healthCheckUrl = `http://localhost:${backendPort}/api/health`;
315
323
  // Defensively clean up any orphaned backend in case bootstrap was skipped
@@ -492,6 +500,27 @@ function findDistPath() {
492
500
  return null;
493
501
  }
494
502
 
503
+ /** Refuse a link loudly — the user clicked it, so they get told why nothing happened. */
504
+ function reportBlockedLink(url) {
505
+ const message = describeBlockedUrl(url);
506
+ console.error(`[main] ${message}`);
507
+ // Non-blocking and parented: showErrorBox would freeze the main process.
508
+ dialog
509
+ .showMessageBox(mainWindow && !mainWindow.isDestroyed() ? mainWindow : null, {
510
+ type: "warning",
511
+ title: "Link blocked",
512
+ message: "Link blocked",
513
+ detail: message,
514
+ buttons: ["OK"],
515
+ noLink: true,
516
+ })
517
+ .catch((err) =>
518
+ console.error(
519
+ `[main] Could not show the link-blocked dialog: ${err && err.message ? err.message : err}`
520
+ )
521
+ );
522
+ }
523
+
495
524
  function createWindow() {
496
525
  mainWindow = new BrowserWindow({
497
526
  width: windowConfig.width,
@@ -511,12 +540,31 @@ function createWindow() {
511
540
  // Remove default menu bar
512
541
  mainWindow.setMenuBarVisibility(false);
513
542
 
514
- // Open external links in the default browser
543
+ // Open external links in the default browser. The URL here is already
544
+ // RESOLVED against the file:// document, so a scheme-less markdown link
545
+ // arrives as file:///C:/… — check the scheme before handing it to the OS.
515
546
  mainWindow.webContents.setWindowOpenHandler(({ url }) => {
516
- shell.openExternal(url);
547
+ if (isAllowedExternalUrl(url)) {
548
+ shell.openExternal(url);
549
+ } else {
550
+ reportBlockedLink(url);
551
+ }
517
552
  return { action: "deny" };
518
553
  });
519
554
 
555
+ // Nothing may navigate the app window away from its own document. In-page
556
+ // anchors pass; web links open in the browser; everything else is refused.
557
+ mainWindow.webContents.on("will-navigate", (event, url) => {
558
+ const currentUrl = mainWindow.webContents.getURL();
559
+ if (isInAppNavigation(url, currentUrl)) return;
560
+ event.preventDefault();
561
+ if (isAllowedExternalUrl(url)) {
562
+ shell.openExternal(url);
563
+ return;
564
+ }
565
+ reportBlockedLink(url);
566
+ });
567
+
520
568
  // ── Minimize-to-tray on close (C4 fix) ──────────────────────────────
521
569
  // Intercept window close — hide instead of closing when tray mode is active
522
570
  mainWindow.on("close", (event) => {
@@ -570,19 +618,44 @@ async function loadApp() {
570
618
  fileUrl.search = new URLSearchParams(indexQuery).toString();
571
619
  await mainWindow.loadURL(fileUrl.href);
572
620
  } else {
573
- // Show a simple loading/error page
574
- mainWindow.loadURL(
575
- `data:text/html,
576
- <html>
577
- <head><title>${APP_NAME}</title></head>
578
- <body style="font-family: -apple-system, BlinkMacSystemFont, sans-serif; display:flex; align-items:center; justify-content:center; height:100vh; margin:0; background:#1a1a2e; color:#eee;">
621
+ // Show a simple loading/error page. Percent-encoded: an unescaped `#`
622
+ // in a data: URL starts the fragment and truncates the document there.
623
+ const waitingHtml = `<!DOCTYPE html>
624
+ <html lang="en">
625
+ <head>
626
+ <title>${APP_NAME}</title>
627
+ <style>
628
+ /* Standalone page: it loads from a data: URL and never sees
629
+ * src/styles/index.css, so the dark theme's role values are
630
+ * mirrored here. Keep them in step with that file. */
631
+ :root {
632
+ --bg-primary: #17161C;
633
+ --text-primary: #F0EDE7;
634
+ --text-secondary: #AAA5B0;
635
+ }
636
+ body {
637
+ font-family: -apple-system, BlinkMacSystemFont, sans-serif;
638
+ display: flex;
639
+ align-items: center;
640
+ justify-content: center;
641
+ height: 100vh;
642
+ margin: 0;
643
+ background: var(--bg-primary);
644
+ color: var(--text-primary);
645
+ }
646
+ .hint { color: var(--text-secondary); font-size: 12px; }
647
+ </style>
648
+ </head>
649
+ <body>
579
650
  <div style="text-align:center;">
580
651
  <h1>${APP_NAME}</h1>
581
652
  <p>Waiting for backend to start...</p>
582
- <p style="color:#888; font-size:12px;">Backend: http://localhost:${backendPort}</p>
653
+ <p class="hint">Backend: http://localhost:${backendPort}</p>
583
654
  </div>
584
655
  </body>
585
- </html>`
656
+ </html>`;
657
+ mainWindow.loadURL(
658
+ "data:text/html;charset=utf-8," + encodeURIComponent(waitingHtml)
586
659
  );
587
660
  }
588
661
  }
@@ -751,8 +824,15 @@ async function bootstrapBackend() {
751
824
  // A malformed/unsupported link surfaces a loud error dialog — never a silent
752
825
  // no-op (deep-link.cjs throws with an actionable message).
753
826
 
754
- /** Holds a deep link that arrived before services were ready. */
755
- let pendingDeepLinkUrl = null;
827
+ // Holds gaia:// links until startup has waited for the backend (the install
828
+ // gate verifies the agent through its catalog API), then dispatches each once.
829
+ const startupDeepLinks = createStartupDeepLinkQueue({
830
+ dispatch: (rawUrl) => {
831
+ void runDeepLink(parseDeepLink(rawUrl));
832
+ },
833
+ reportNotReady: reportDeepLinksNotReady,
834
+ logger: console,
835
+ });
756
836
 
757
837
  /**
758
838
  * Register this app as the handler for the gaia:// scheme. In dev (`electron .`)
@@ -785,13 +865,13 @@ function registerProtocolHandler() {
785
865
  }
786
866
 
787
867
  /**
788
- * Entry point for any inbound deep link. Queues the URL if services aren't up
789
- * yet, otherwise dispatches immediately. Parse failures are surfaced loudly.
868
+ * Entry point for any inbound deep link. Validates it now (a bad link is
869
+ * refused loudly and immediately), then hands it to the startup queue, which
870
+ * holds it until the backend is ready and dispatches it immediately after.
790
871
  */
791
872
  function handleDeepLink(rawUrl) {
792
- let command;
793
873
  try {
794
- command = parseDeepLink(rawUrl);
874
+ parseDeepLink(rawUrl);
795
875
  } catch (err) {
796
876
  const message = err && err.message ? err.message : String(err);
797
877
  console.error(`[main] Rejected deep link: ${message}`);
@@ -803,14 +883,7 @@ function handleDeepLink(rawUrl) {
803
883
  return;
804
884
  }
805
885
 
806
- // If services aren't wired yet (cold start still bootstrapping), stash it.
807
- if (!agentProcessManager) {
808
- console.log("[main] Deep link received before services ready — queuing");
809
- pendingDeepLinkUrl = rawUrl;
810
- return;
811
- }
812
-
813
- void runDeepLink(command);
886
+ startupDeepLinks.accept(rawUrl);
814
887
  }
815
888
 
816
889
  /**
@@ -889,16 +962,32 @@ async function runDeepLink(command) {
889
962
  }
890
963
  }
891
964
 
892
- /** Drain a queued deep link, plus any gaia:// URL present in the cold-start argv. */
893
- function processStartupDeepLinks() {
894
- if (pendingDeepLinkUrl) {
895
- const url = pendingDeepLinkUrl;
896
- pendingDeepLinkUrl = null;
897
- handleDeepLink(url);
898
- return;
965
+ /**
966
+ * Release queued deep links, plus any gaia:// URL in the cold-start argv. Call
967
+ * once, after startup has waited for the backend.
968
+ *
969
+ * @param {boolean} backendReady Whether the backend API answered its health check.
970
+ */
971
+ function processStartupDeepLinks(backendReady) {
972
+ startupDeepLinks.drain({ backendReady, argvUrl: extractDeepLinkFromArgv(process.argv) });
973
+ }
974
+
975
+ /** The backend never came up: tell the user instead of dispatching blind. */
976
+ function reportDeepLinksNotReady(urls) {
977
+ const why = backendProcess
978
+ ? `did not answer within ${Math.round(STARTUP_TIMEOUT / 1000)}s`
979
+ : "could not be started";
980
+ const message =
981
+ `GAIA could not open ${urls.map((u) => `"${u}"`).join(", ")} because ` +
982
+ `its backend ${why}, so the agent cannot be verified before install. ` +
983
+ `Check ${_MAIN_LOG_PATH} for the startup error, then click the link ` +
984
+ "again once GAIA is running.";
985
+ console.error(`[main] ${message}`);
986
+ try {
987
+ dialog.showErrorBox("GAIA is not ready yet", message);
988
+ } catch {
989
+ /* best-effort */
899
990
  }
900
- const fromArgv = extractDeepLinkFromArgv(process.argv);
901
- if (fromArgv) handleDeepLink(fromArgv);
902
991
  }
903
992
 
904
993
  // macOS delivers deep links via this event; it can fire before whenReady, so
@@ -1029,10 +1118,6 @@ app.whenReady().then(async () => {
1029
1118
  // Setup Windows Jump List (T11)
1030
1119
  setupJumpList();
1031
1120
 
1032
- // Act on any gaia:// deep link that arrived during bootstrap or via the
1033
- // cold-start command line (issue #1725). Services are now wired.
1034
- processStartupDeepLinks();
1035
-
1036
1121
  // Show loading state
1037
1122
  await loadApp();
1038
1123
 
@@ -1041,16 +1126,22 @@ app.whenReady().then(async () => {
1041
1126
  // API becomes available and dismisses its "Cannot connect" banner.
1042
1127
  // We do NOT reload the window with http://localhost:4200/ because
1043
1128
  // the pip-installed backend has no frontend files — only the API.
1129
+ let backendReady = false;
1044
1130
  if (backendProcess) {
1045
1131
  console.log("Waiting for backend to start...");
1046
- const ready = await waitForBackend(STARTUP_TIMEOUT);
1047
- if (ready) {
1132
+ backendReady = await waitForBackend(STARTUP_TIMEOUT);
1133
+ if (backendReady) {
1048
1134
  console.log("Backend API is ready on port", backendPort);
1049
1135
  } else {
1050
1136
  console.warn("Backend did not respond within timeout.");
1051
1137
  }
1052
1138
  }
1053
1139
 
1140
+ // Act on any gaia:// deep link that arrived during bootstrap or via the
1141
+ // cold-start command line (issue #1725) — after the backend is listening,
1142
+ // because the install gate queries its catalog API.
1143
+ processStartupDeepLinks(backendReady);
1144
+
1054
1145
  // Auto-start enabled agents (T2)
1055
1146
  if (agentProcessManager) {
1056
1147
  try {
package/package.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "@amd-gaia/agent-ui",
3
- "version": "0.24.1",
3
+ "version": "0.25.0-rc.8",
4
4
  "type": "module",
5
5
  "productName": "GAIA Agent UI",
6
- "description": "Privacy-first agentic AI interface with document Q&A - runs 100% locally on AMD Ryzen AI",
6
+ "description": "Chat with the GAIA agent - local and private by default on AMD Ryzen AI, cloud models when you choose them",
7
7
  "author": "AMD AI Group",
8
8
  "license": "MIT",
9
9
  "repository": {
@@ -58,7 +58,8 @@
58
58
  "package:linux": "npm run build && electron-builder --linux --config electron-builder.yml",
59
59
  "prepublishOnly": "npm run build",
60
60
  "test": "vitest run",
61
- "test:watch": "vitest"
61
+ "test:watch": "vitest",
62
+ "gen:api-types": "node scripts/gen-api-types.mjs"
62
63
  },
63
64
  "dependencies": {
64
65
  "electron-updater": "^6.8.3"
@@ -70,20 +71,19 @@
70
71
  "@types/react": "^19.2.17",
71
72
  "@types/react-dom": "^19.2.3",
72
73
  "@vitejs/plugin-react": "^6.0.2",
73
- "electron": "^44.0.0",
74
+ "electron": "^44.5.1",
74
75
  "electron-builder": "^26.8.1",
75
76
  "jsdom": "^30.0.1",
76
- "lucide-react": "^1.17.0",
77
- "msw": "^2.7.0",
77
+ "json-schema-to-typescript": "16.0.0",
78
+ "lucide-react": "^1.50.0",
78
79
  "qrcode": "^1.5.4",
79
80
  "react": "^19.2.7",
80
81
  "react-dom": "^19.2.7",
81
82
  "react-markdown": "^10.1.0",
82
- "rehype-sanitize": "^6.0.0",
83
83
  "remark-gfm": "^4.0.1",
84
84
  "typescript": "^7.0.2",
85
- "vite": "^8.0.16",
86
- "vitest": "^5.0.0",
85
+ "vite": "^8.3.2",
86
+ "vitest": "^5.0.3",
87
87
  "zustand": "^5.0.14"
88
88
  }
89
89
  }
@@ -86,12 +86,24 @@ function buildProgressHtml({ logPath }) {
86
86
  <meta charset="UTF-8" />
87
87
  <title>Installing GAIA</title>
88
88
  <style>
89
+ /* Standalone window: it loads from a data: URL and never sees
90
+ * src/styles/index.css, so the dark theme's role values are mirrored
91
+ * here. Keep them in step with that file; nothing below names a hex. */
92
+ :root {
93
+ --bg-primary: #17161C;
94
+ --bg-tertiary: #2A2831;
95
+ --border: #3A3842;
96
+ --text-primary: #F0EDE7;
97
+ --text-secondary: #AAA5B0;
98
+ --text-muted: #8C8794;
99
+ --accent-fill: #9A4930;
100
+ }
89
101
  html, body {
90
102
  margin: 0;
91
103
  padding: 0;
92
104
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
93
- background: #1a1a2e;
94
- color: #eee;
105
+ background: var(--bg-primary);
106
+ color: var(--text-primary);
95
107
  user-select: none;
96
108
  -webkit-user-select: none;
97
109
  overflow: hidden;
@@ -110,35 +122,38 @@ function buildProgressHtml({ logPath }) {
110
122
  font-size: 18px;
111
123
  font-weight: 600;
112
124
  margin: 0 0 8px 0;
113
- color: #fff;
125
+ color: var(--text-primary);
114
126
  }
115
127
  .subtitle {
116
128
  font-size: 13px;
117
- color: #9aa0c8;
129
+ color: var(--text-secondary);
118
130
  margin: 0 0 24px 0;
119
131
  }
120
132
  .stage {
121
133
  font-size: 12px;
122
- color: #7a7f9f;
134
+ color: var(--text-secondary);
123
135
  text-transform: uppercase;
124
136
  letter-spacing: 0.5px;
125
137
  margin-bottom: 6px;
126
138
  min-height: 15px;
127
139
  }
140
+ /* The rule carries the track's edge: no fill clears the 1.5:1 chrome
141
+ * floor against the canvas, but --border does at 1.56:1. */
128
142
  .bar {
129
143
  width: 360px;
130
144
  max-width: 100%;
131
145
  height: 10px;
132
- background: #2a2a47;
133
- border-radius: 5px;
146
+ box-sizing: border-box;
147
+ background: var(--bg-tertiary);
148
+ border: 1px solid var(--border);
149
+ border-radius: 9999px;
134
150
  overflow: hidden;
135
151
  margin-bottom: 12px;
136
- box-shadow: inset 0 1px 2px rgba(0,0,0,0.3);
137
152
  }
138
153
  .fill {
139
154
  height: 100%;
140
- background: linear-gradient(90deg, #4a7dff 0%, #6b9aff 100%);
141
- border-radius: 5px;
155
+ background: var(--accent-fill);
156
+ border-radius: 9999px;
142
157
  transition: width 300ms ease-out;
143
158
  width: 0%;
144
159
  }
@@ -151,15 +166,19 @@ function buildProgressHtml({ logPath }) {
151
166
  50% { margin-left: 70%; }
152
167
  100% { margin-left: 0%; }
153
168
  }
169
+ @media (prefers-reduced-motion: reduce) {
170
+ .fill { transition: none; }
171
+ .fill.indeterminate { animation: none; margin-left: 35%; }
172
+ }
154
173
  .percent {
155
174
  font-size: 12px;
156
- color: #9aa0c8;
175
+ color: var(--text-secondary);
157
176
  margin-bottom: 16px;
158
177
  min-height: 14px;
159
178
  }
160
179
  .message {
161
180
  font-size: 13px;
162
- color: #c6cae4;
181
+ color: var(--text-primary);
163
182
  max-width: 420px;
164
183
  min-height: 36px;
165
184
  line-height: 1.4;
@@ -171,12 +190,12 @@ function buildProgressHtml({ logPath }) {
171
190
  right: 0;
172
191
  text-align: center;
173
192
  font-size: 10px;
174
- color: #5a5f7a;
193
+ color: var(--text-muted);
175
194
  }
176
195
  code {
177
196
  font-family: "SF Mono", Monaco, Consolas, monospace;
178
197
  font-size: 10px;
179
- color: #7a7f9f;
198
+ color: var(--text-muted);
180
199
  }
181
200
  </style>
182
201
  </head>
@@ -249,7 +268,7 @@ function createProgressWindow() {
249
268
  frame: false,
250
269
  transparent: false,
251
270
  show: false,
252
- backgroundColor: "#1a1a2e",
271
+ backgroundColor: "#17161C", // --bg-primary, dark theme (see the :root block above)
253
272
  title: "Installing GAIA",
254
273
  webPreferences: {
255
274
  nodeIntegration: false,
@@ -493,7 +493,7 @@ function isFileLockedError(output) {
493
493
  /**
494
494
  * Detect a transient network failure in `uv pip install` output so the
495
495
  * install stage can retry instead of failing the whole bootstrap. The
496
- * install stage downloads heavy transitive deps (scipy, numpy, torch) from
496
+ * install stage downloads transitive deps (numpy, faiss, pymupdf, …) from
497
497
  * PyPI; a single mid-stream hiccup ("stream closed because of a broken pipe")
498
498
  * otherwise fails the entire backend install — and, in the release pipeline,
499
499
  * the whole AppImage smoke test that gates publishing.
@@ -1366,6 +1366,15 @@ function resolveBackendVersion(opts = {}) {
1366
1366
  }
1367
1367
  }
1368
1368
 
1369
+ /**
1370
+ * The PEP 440 spelling of the app's npm version: `0.25.0-rc.1` -> `0.25.0rc1`.
1371
+ * pip pins and `gaia --version` use this form; a final version is unchanged.
1372
+ */
1373
+ function toPep440(version) {
1374
+ if (!version) return version;
1375
+ return version.replace(/^(\d+\.\d+\.\d+)-rc\.(\d+)$/, "$1rc$2");
1376
+ }
1377
+
1369
1378
  /**
1370
1379
  * Install the GAIA Python backend from scratch.
1371
1380
  *
@@ -1389,7 +1398,7 @@ async function installBackend(opts = {}) {
1389
1398
  const localWheel = process.env.GAIA_LOCAL_WHEEL || null;
1390
1399
  const pipPackage = localWheel
1391
1400
  ? `${localWheel}[ui]`
1392
- : `amd-gaia[ui]==${version}`;
1401
+ : `amd-gaia[ui]==${toPep440(version)}`;
1393
1402
  const skipGaiaInit =
1394
1403
  Boolean(opts.skipGaiaInit) || isTruthyEnv(process.env.GAIA_SKIP_GAIA_INIT);
1395
1404
 
@@ -1424,18 +1433,42 @@ async function installBackend(opts = {}) {
1424
1433
  }
1425
1434
  }
1426
1435
 
1427
- const venvResult = await runCommand(
1428
- "uv",
1429
- ["venv", GAIA_VENV, "--python", "3.12"],
1430
- { stageLabel: "venv" }
1431
- );
1436
+ const venvArgs = ["venv", GAIA_VENV, "--python", "3.12"];
1437
+ let venvResult = await runCommand("uv", venvArgs, { stageLabel: "venv" });
1438
+ // uv downloads Python 3.12 when none is installed; behind a corporate
1439
+ // proxy that fails on uv's bundled roots exactly like the pip step below.
1440
+ let venvNativeTls = false;
1441
+ if (
1442
+ venvResult.code !== 0 &&
1443
+ isTlsCertError(`${venvResult.stdout || ""}\n${venvResult.stderr || ""}`)
1444
+ ) {
1445
+ venvNativeTls = true;
1446
+ log(
1447
+ "TLS trust failure downloading Python — retrying with --native-tls " +
1448
+ "to use the OS certificate store"
1449
+ );
1450
+ report(
1451
+ STAGES.CREATE_VENV,
1452
+ 0,
1453
+ "Certificate trust issue — retrying with the system certificate store"
1454
+ );
1455
+ if (fs.existsSync(GAIA_VENV)) {
1456
+ fs.rmSync(GAIA_VENV, { recursive: true, force: true });
1457
+ }
1458
+ venvResult = await runCommand("uv", [...venvArgs, "--native-tls"], {
1459
+ stageLabel: "venv",
1460
+ });
1461
+ }
1432
1462
  if (venvResult.code !== 0) {
1463
+ const manual = `uv venv ${GAIA_VENV_DISPLAY} --python 3.12${
1464
+ venvNativeTls ? " --native-tls" : ""
1465
+ }`;
1433
1466
  throw new InstallError(
1434
1467
  `Failed to create Python environment (uv venv exit ${venvResult.code}).`,
1435
1468
  {
1436
1469
  stage: STAGES.CREATE_VENV,
1437
1470
  code: venvResult.code,
1438
- suggestion: `Try creating it manually:\n uv venv ${GAIA_VENV_DISPLAY} --python 3.12\nThen restart GAIA.`,
1471
+ suggestion: `Try creating it manually:\n ${manual}\nThen restart GAIA.`,
1439
1472
  }
1440
1473
  );
1441
1474
  }
@@ -1453,17 +1486,13 @@ async function installBackend(opts = {}) {
1453
1486
  "install",
1454
1487
  pipPackage,
1455
1488
  "--refresh",
1489
+ // uv skips this by default, leaving the first daemon start to compile GAIA.
1490
+ "--compile-bytecode",
1456
1491
  "--python",
1457
1492
  GAIA_PYTHON_BIN,
1458
1493
  ];
1459
- // Linux/macOS: use CPU-only PyTorch to avoid huge CUDA wheels.
1460
- // Skip when installing from a local wheel — PyPI index not needed.
1461
- if (!IS_WINDOWS && !localWheel) {
1462
- pipArgs.push("--extra-index-url", "https://download.pytorch.org/whl/cpu");
1463
- }
1464
-
1465
- // Retry the install on transient PyPI/network failures. The heavy
1466
- // transitive deps (scipy, numpy, torch) are fetched live from PyPI even
1494
+ // Retry the install on transient PyPI/network failures. The transitive
1495
+ // deps (numpy, faiss, pymupdf, …) are fetched live from PyPI even
1467
1496
  // when the gaia wheel itself is local, so a single broken-pipe mid-download
1468
1497
  // would otherwise fail the whole bootstrap (and block a release). File-lock
1469
1498
  // failures (Windows os-error-32) are NOT retried — they need user action.
@@ -1540,8 +1569,18 @@ async function installBackend(opts = {}) {
1540
1569
  IS_WINDOWS ? `${GAIA_VENV_DISPLAY}/Scripts/python.exe` : `${GAIA_VENV_DISPLAY}/bin/python`
1541
1570
  }\nThen restart GAIA. See https://amd-gaia.ai/docs/quickstart#cli-install`;
1542
1571
  }
1572
+ // Carry pip's own last words. Without them the only record of a failed
1573
+ // install is an exit code, which is not something a user or a CI log can
1574
+ // act on.
1575
+ const tail = output
1576
+ .split("\n")
1577
+ .map((line) => line.trimEnd())
1578
+ .filter(Boolean)
1579
+ .slice(-20)
1580
+ .join("\n");
1543
1581
  throw new InstallError(
1544
- `Failed to install ${pipPackage} (pip exit ${installResult.code}).`,
1582
+ `Failed to install ${pipPackage} (pip exit ${installResult.code}).` +
1583
+ (tail ? `\npip said:\n${tail}` : ""),
1545
1584
  {
1546
1585
  stage: STAGES.INSTALL_PACKAGE,
1547
1586
  code: installResult.code,
@@ -1664,7 +1703,9 @@ function getInstalledVersion(gaiaBin) {
1664
1703
  windowsHide: true,
1665
1704
  });
1666
1705
  if (result.status === 0 && result.stdout) {
1667
- const match = result.stdout.toString().trim().match(/(\d+\.\d+\.\d+)/);
1706
+ // Keep an rcN suffix: truncating it would let a release candidate's
1707
+ // backend pass for the final's and never be upgraded.
1708
+ const match = result.stdout.toString().trim().match(/(\d+\.\d+\.\d+(?:rc\d+)?)/);
1668
1709
  return match ? match[1] : null;
1669
1710
  }
1670
1711
  } catch {
@@ -1764,7 +1805,7 @@ async function ensureBackend(opts = {}) {
1764
1805
  const existingBin = findGaiaBin();
1765
1806
  if (existingBin) {
1766
1807
  const installedVersion = getInstalledVersion(existingBin);
1767
- if (expectedVersion !== null && installedVersion === expectedVersion) {
1808
+ if (expectedVersion !== null && installedVersion === toPep440(expectedVersion)) {
1768
1809
  log(
1769
1810
  `GAIA backend already installed at version ${installedVersion} — nothing to do`
1770
1811
  );
@@ -1831,6 +1872,7 @@ module.exports = {
1831
1872
  ensureBackend,
1832
1873
  getInstalledVersion,
1833
1874
  findGaiaBin,
1875
+ toPep440,
1834
1876
 
1835
1877
  // Pre-checks
1836
1878
  runPreChecks,