@hellcoder/companion 0.105.1-preview.20260624135426.10d7236 → 0.105.1
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/dist/assets/{AgentsPage-CWeUKtFI.js → AgentsPage-eLLQNxgP.js} +1 -1
- package/dist/assets/{CronManager-BKdouPOe.js → CronManager-DDwptxks.js} +1 -1
- package/dist/assets/{IntegrationsPage-Ccg_HgWi.js → IntegrationsPage-BExX5JOb.js} +1 -1
- package/dist/assets/{LinearOAuthSettingsPage-D9_JUWel.js → LinearOAuthSettingsPage--m6I9icx.js} +1 -1
- package/dist/assets/{LinearSettingsPage-DWkwlh2E.js → LinearSettingsPage-4_3ZJrKS.js} +1 -1
- package/dist/assets/{Playground-DSXKSds_.js → Playground-BBt78cYF.js} +1 -1
- package/dist/assets/{PromptsPage-DogzCF5T.js → PromptsPage-_B61JVQm.js} +1 -1
- package/dist/assets/{RunsPage-BICx5Ywy.js → RunsPage-CgUwAzRE.js} +1 -1
- package/dist/assets/{SandboxManager-Lp__FqMb.js → SandboxManager-CX_Zi6K3.js} +1 -1
- package/dist/assets/{SettingsPage-CrOyTtWR.js → SettingsPage-BLax9147.js} +1 -1
- package/dist/assets/{TailscalePage-DCdh_AMg.js → TailscalePage-Buy9PO3F.js} +1 -1
- package/dist/assets/{index-B9Oa9QD1.js → index-DeUv1epH.js} +14 -14
- package/dist/assets/{sw-register-Ctj8ZoBE.js → sw-register-BPMmALbK.js} +1 -1
- package/dist/index.html +1 -1
- package/dist/sw.js +1 -1
- package/package.json +1 -1
- package/server/codex-models.test.ts +247 -0
- package/server/codex-models.ts +196 -0
- package/server/routes.test.ts +39 -8
- package/server/routes.ts +42 -28
|
@@ -1 +1 @@
|
|
|
1
|
-
import{_ as u}from"./index-
|
|
1
|
+
import{_ as u}from"./index-DeUv1epH.js";function w(r={}){const{immediate:i=!1,onNeedReload:d,onNeedRefresh:_,onOfflineReady:s,onRegistered:n,onRegisteredSW:o,onRegisterError:t}=r;let a,c;const l=async(e=!0)=>{await c};async function f(){if("serviceWorker"in navigator){if(a=await u(async()=>{const{Workbox:e}=await import("./workbox-window.prod.es5-BBnX5xw4.js");return{Workbox:e}},[]).then(({Workbox:e})=>new e("/sw.js",{scope:"/",type:"classic"})).catch(e=>{t==null||t(e)}),!a)return;a.addEventListener("activated",e=>{(e.isUpdate||e.isExternal)&&(d?d():window.location.reload())}),a.addEventListener("installed",e=>{e.isUpdate||s==null||s()}),a.register({immediate:i}).then(e=>{o?o("/sw.js",e):n==null||n(e)}).catch(e=>{t==null||t(e)})}}return c=f(),l}w({onRegisteredSW(r,i){i&&setInterval(()=>{i.update()},3600*1e3)},onOfflineReady(){console.log("[SW] Offline-ready: all assets precached")}});
|
package/dist/index.html
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
<meta name="apple-mobile-web-app-title" content="Companion ME" />
|
|
12
12
|
<meta name="theme-color" content="#d97757" />
|
|
13
13
|
<title>The Companion — Moritz Edition</title>
|
|
14
|
-
<script type="module" crossorigin src="/assets/index-
|
|
14
|
+
<script type="module" crossorigin src="/assets/index-DeUv1epH.js"></script>
|
|
15
15
|
<link rel="stylesheet" crossorigin href="/assets/index-psWeuMrk.css">
|
|
16
16
|
</head>
|
|
17
17
|
<body class="bg-cc-bg text-cc-fg">
|
package/dist/sw.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
try{self["workbox:core:7.4.0"]&&_()}catch{}const x=(a,...e)=>{let t=a;return e.length>0&&(t+=` :: ${JSON.stringify(e)}`),t},N=x;class l extends Error{constructor(e,t){const s=N(e,t);super(s),this.name=e,this.details=t}}const E=new Set,f={googleAnalytics:"googleAnalytics",precache:"precache-v2",prefix:"workbox",runtime:"runtime",suffix:typeof registration<"u"?registration.scope:""},b=a=>[f.prefix,a,f.suffix].filter(e=>e&&e.length>0).join("-"),O=a=>{for(const e of Object.keys(f))a(e)},C={updateDetails:a=>{O(e=>{typeof a[e]=="string"&&(f[e]=a[e])})},getGoogleAnalyticsName:a=>a||b(f.googleAnalytics),getPrecacheName:a=>a||b(f.precache),getPrefix:()=>f.prefix,getRuntimeName:a=>a||b(f.runtime),getSuffix:()=>f.suffix};function P(a,e){const t=new URL(a);for(const s of e)t.searchParams.delete(s);return t.href}async function I(a,e,t,s){const n=P(e.url,t);if(e.url===n)return a.match(e,s);const r=Object.assign(Object.assign({},s),{ignoreSearch:!0}),c=await a.keys(e,r);for(const i of c){const o=P(i.url,t);if(n===o)return a.match(i,s)}}let y;function M(){if(y===void 0){const a=new Response("");if("body"in a)try{new Response(a.body),y=!0}catch{y=!1}y=!1}return y}class D{constructor(){this.promise=new Promise((e,t)=>{this.resolve=e,this.reject=t})}}async function S(){for(const a of E)await a()}const W=a=>new URL(String(a),location.href).href.replace(new RegExp(`^${location.origin}`),"");function A(a){return new Promise(e=>setTimeout(e,a))}function K(a,e){const t=e();return a.waitUntil(t),t}async function q(a,e){let t=null;if(a.url&&(t=new URL(a.url).origin),t!==self.location.origin)throw new l("cross-origin-copy-response",{origin:t});const s=a.clone(),r={headers:new Headers(s.headers),status:s.status,statusText:s.statusText},c=M()?s.body:await s.blob();return new Response(c,r)}function j(){self.addEventListener("activate",()=>self.clients.claim())}try{self["workbox:precaching:7.4.0"]&&_()}catch{}const H="__WB_REVISION__";function F(a){if(!a)throw new l("add-to-cache-list-unexpected-type",{entry:a});if(typeof a=="string"){const r=new URL(a,location.href);return{cacheKey:r.href,url:r.href}}const{revision:e,url:t}=a;if(!t)throw new l("add-to-cache-list-unexpected-type",{entry:a});if(!e){const r=new URL(t,location.href);return{cacheKey:r.href,url:r.href}}const s=new URL(t,location.href),n=new URL(t,location.href);return s.searchParams.set(H,e),{cacheKey:s.href,url:n.href}}class B{constructor(){this.updatedURLs=[],this.notUpdatedURLs=[],this.handlerWillStart=async({request:e,state:t})=>{t&&(t.originalRequest=e)},this.cachedResponseWillBeUsed=async({event:e,state:t,cachedResponse:s})=>{if(e.type==="install"&&t&&t.originalRequest&&t.originalRequest instanceof Request){const n=t.originalRequest.url;s?this.notUpdatedURLs.push(n):this.updatedURLs.push(n)}return s}}}class ${constructor({precacheController:e}){this.cacheKeyWillBeUsed=async({request:t,params:s})=>{const n=(s==null?void 0:s.cacheKey)||this._precacheController.getCacheKeyForURL(t.url);return n?new Request(n,{headers:t.headers}):t},this._precacheController=e}}try{self["workbox:strategies:7.4.0"]&&_()}catch{}function m(a){return typeof a=="string"?new Request(a):a}class G{constructor(e,t){this._cacheKeys={},Object.assign(this,t),this.event=t.event,this._strategy=e,this._handlerDeferred=new D,this._extendLifetimePromises=[],this._plugins=[...e.plugins],this._pluginStateMap=new Map;for(const s of this._plugins)this._pluginStateMap.set(s,{});this.event.waitUntil(this._handlerDeferred.promise)}async fetch(e){const{event:t}=this;let s=m(e);if(s.mode==="navigate"&&t instanceof FetchEvent&&t.preloadResponse){const c=await t.preloadResponse;if(c)return c}const n=this.hasCallback("fetchDidFail")?s.clone():null;try{for(const c of this.iterateCallbacks("requestWillFetch"))s=await c({request:s.clone(),event:t})}catch(c){if(c instanceof Error)throw new l("plugin-error-request-will-fetch",{thrownErrorMessage:c.message})}const r=s.clone();try{let c;c=await fetch(s,s.mode==="navigate"?void 0:this._strategy.fetchOptions);for(const i of this.iterateCallbacks("fetchDidSucceed"))c=await i({event:t,request:r,response:c});return c}catch(c){throw n&&await this.runCallbacks("fetchDidFail",{error:c,event:t,originalRequest:n.clone(),request:r.clone()}),c}}async fetchAndCachePut(e){const t=await this.fetch(e),s=t.clone();return this.waitUntil(this.cachePut(e,s)),t}async cacheMatch(e){const t=m(e);let s;const{cacheName:n,matchOptions:r}=this._strategy,c=await this.getCacheKey(t,"read"),i=Object.assign(Object.assign({},r),{cacheName:n});s=await caches.match(c,i);for(const o of this.iterateCallbacks("cachedResponseWillBeUsed"))s=await o({cacheName:n,matchOptions:r,cachedResponse:s,request:c,event:this.event})||void 0;return s}async cachePut(e,t){const s=m(e);await A(0);const n=await this.getCacheKey(s,"write");if(!t)throw new l("cache-put-with-no-response",{url:W(n.url)});const r=await this._ensureResponseSafeToCache(t);if(!r)return!1;const{cacheName:c,matchOptions:i}=this._strategy,o=await self.caches.open(c),h=this.hasCallback("cacheDidUpdate"),p=h?await I(o,n.clone(),["__WB_REVISION__"],i):null;try{await o.put(n,h?r.clone():r)}catch(u){if(u instanceof Error)throw u.name==="QuotaExceededError"&&await S(),u}for(const u of this.iterateCallbacks("cacheDidUpdate"))await u({cacheName:c,oldResponse:p,newResponse:r.clone(),request:n,event:this.event});return!0}async getCacheKey(e,t){const s=`${e.url} | ${t}`;if(!this._cacheKeys[s]){let n=e;for(const r of this.iterateCallbacks("cacheKeyWillBeUsed"))n=m(await r({mode:t,request:n,event:this.event,params:this.params}));this._cacheKeys[s]=n}return this._cacheKeys[s]}hasCallback(e){for(const t of this._strategy.plugins)if(e in t)return!0;return!1}async runCallbacks(e,t){for(const s of this.iterateCallbacks(e))await s(t)}*iterateCallbacks(e){for(const t of this._strategy.plugins)if(typeof t[e]=="function"){const s=this._pluginStateMap.get(t);yield r=>{const c=Object.assign(Object.assign({},r),{state:s});return t[e](c)}}}waitUntil(e){return this._extendLifetimePromises.push(e),e}async doneWaiting(){for(;this._extendLifetimePromises.length;){const e=this._extendLifetimePromises.splice(0),s=(await Promise.allSettled(e)).find(n=>n.status==="rejected");if(s)throw s.reason}}destroy(){this._handlerDeferred.resolve(null)}async _ensureResponseSafeToCache(e){let t=e,s=!1;for(const n of this.iterateCallbacks("cacheWillUpdate"))if(t=await n({request:this.request,response:t,event:this.event})||void 0,s=!0,!t)break;return s||t&&t.status!==200&&(t=void 0),t}}class V{constructor(e={}){this.cacheName=C.getRuntimeName(e.cacheName),this.plugins=e.plugins||[],this.fetchOptions=e.fetchOptions,this.matchOptions=e.matchOptions}handle(e){const[t]=this.handleAll(e);return t}handleAll(e){e instanceof FetchEvent&&(e={event:e,request:e.request});const t=e.event,s=typeof e.request=="string"?new Request(e.request):e.request,n="params"in e?e.params:void 0,r=new G(this,{event:t,request:s,params:n}),c=this._getResponse(r,s,t),i=this._awaitComplete(c,r,s,t);return[c,i]}async _getResponse(e,t,s){await e.runCallbacks("handlerWillStart",{event:s,request:t});let n;try{if(n=await this._handle(t,e),!n||n.type==="error")throw new l("no-response",{url:t.url})}catch(r){if(r instanceof Error){for(const c of e.iterateCallbacks("handlerDidError"))if(n=await c({error:r,event:s,request:t}),n)break}if(!n)throw r}for(const r of e.iterateCallbacks("handlerWillRespond"))n=await r({event:s,request:t,response:n});return n}async _awaitComplete(e,t,s,n){let r,c;try{r=await e}catch{}try{await t.runCallbacks("handlerDidRespond",{event:n,request:s,response:r}),await t.doneWaiting()}catch(i){i instanceof Error&&(c=i)}if(await t.runCallbacks("handlerDidComplete",{event:n,request:s,response:r,error:c}),t.destroy(),c)throw c}}class d extends V{constructor(e={}){e.cacheName=C.getPrecacheName(e.cacheName),super(e),this._fallbackToNetwork=e.fallbackToNetwork!==!1,this.plugins.push(d.copyRedirectedCacheableResponsesPlugin)}async _handle(e,t){const s=await t.cacheMatch(e);return s||(t.event&&t.event.type==="install"?await this._handleInstall(e,t):await this._handleFetch(e,t))}async _handleFetch(e,t){let s;const n=t.params||{};if(this._fallbackToNetwork){const r=n.integrity,c=e.integrity,i=!c||c===r;s=await t.fetch(new Request(e,{integrity:e.mode!=="no-cors"?c||r:void 0})),r&&i&&e.mode!=="no-cors"&&(this._useDefaultCacheabilityPluginIfNeeded(),await t.cachePut(e,s.clone()))}else throw new l("missing-precache-entry",{cacheName:this.cacheName,url:e.url});return s}async _handleInstall(e,t){this._useDefaultCacheabilityPluginIfNeeded();const s=await t.fetch(e);if(!await t.cachePut(e,s.clone()))throw new l("bad-precaching-response",{url:e.url,status:s.status});return s}_useDefaultCacheabilityPluginIfNeeded(){let e=null,t=0;for(const[s,n]of this.plugins.entries())n!==d.copyRedirectedCacheableResponsesPlugin&&(n===d.defaultPrecacheCacheabilityPlugin&&(e=s),n.cacheWillUpdate&&t++);t===0?this.plugins.push(d.defaultPrecacheCacheabilityPlugin):t>1&&e!==null&&this.plugins.splice(e,1)}}d.defaultPrecacheCacheabilityPlugin={async cacheWillUpdate({response:a}){return!a||a.status>=400?null:a}};d.copyRedirectedCacheableResponsesPlugin={async cacheWillUpdate({response:a}){return a.redirected?await q(a):a}};class Q{constructor({cacheName:e,plugins:t=[],fallbackToNetwork:s=!0}={}){this._urlsToCacheKeys=new Map,this._urlsToCacheModes=new Map,this._cacheKeysToIntegrities=new Map,this._strategy=new d({cacheName:C.getPrecacheName(e),plugins:[...t,new $({precacheController:this})],fallbackToNetwork:s}),this.install=this.install.bind(this),this.activate=this.activate.bind(this)}get strategy(){return this._strategy}precache(e){this.addToCacheList(e),this._installAndActiveListenersAdded||(self.addEventListener("install",this.install),self.addEventListener("activate",this.activate),this._installAndActiveListenersAdded=!0)}addToCacheList(e){const t=[];for(const s of e){typeof s=="string"?t.push(s):s&&s.revision===void 0&&t.push(s.url);const{cacheKey:n,url:r}=F(s),c=typeof s!="string"&&s.revision?"reload":"default";if(this._urlsToCacheKeys.has(r)&&this._urlsToCacheKeys.get(r)!==n)throw new l("add-to-cache-list-conflicting-entries",{firstEntry:this._urlsToCacheKeys.get(r),secondEntry:n});if(typeof s!="string"&&s.integrity){if(this._cacheKeysToIntegrities.has(n)&&this._cacheKeysToIntegrities.get(n)!==s.integrity)throw new l("add-to-cache-list-conflicting-integrities",{url:r});this._cacheKeysToIntegrities.set(n,s.integrity)}if(this._urlsToCacheKeys.set(r,n),this._urlsToCacheModes.set(r,c),t.length>0){const i=`Workbox is precaching URLs without revision info: ${t.join(", ")}
|
|
2
|
-
This is generally NOT safe. Learn more at https://bit.ly/wb-precache`;console.warn(i)}}}install(e){return K(e,async()=>{const t=new B;this.strategy.plugins.push(t);for(const[r,c]of this._urlsToCacheKeys){const i=this._cacheKeysToIntegrities.get(c),o=this._urlsToCacheModes.get(r),h=new Request(r,{integrity:i,cache:o,credentials:"same-origin"});await Promise.all(this.strategy.handleAll({params:{cacheKey:c},request:h,event:e}))}const{updatedURLs:s,notUpdatedURLs:n}=t;return{updatedURLs:s,notUpdatedURLs:n}})}activate(e){return K(e,async()=>{const t=await self.caches.open(this.strategy.cacheName),s=await t.keys(),n=new Set(this._urlsToCacheKeys.values()),r=[];for(const c of s)n.has(c.url)||(await t.delete(c),r.push(c.url));return{deletedURLs:r}})}getURLsToCacheKeys(){return this._urlsToCacheKeys}getCachedURLs(){return[...this._urlsToCacheKeys.keys()]}getCacheKeyForURL(e){const t=new URL(e,location.href);return this._urlsToCacheKeys.get(t.href)}getIntegrityForCacheKey(e){return this._cacheKeysToIntegrities.get(e)}async matchPrecache(e){const t=e instanceof Request?e.url:e,s=this.getCacheKeyForURL(t);if(s)return(await self.caches.open(this.strategy.cacheName)).match(s)}createHandlerBoundToURL(e){const t=this.getCacheKeyForURL(e);if(!t)throw new l("non-precached-url",{url:e});return s=>(s.request=new Request(e),s.params=Object.assign({cacheKey:t},s.params),this.strategy.handle(s))}}let U;const L=()=>(U||(U=new Q),U);try{self["workbox:routing:7.4.0"]&&_()}catch{}const v="GET",R=a=>a&&typeof a=="object"?a:{handle:a};class g{constructor(e,t,s=v){this.handler=R(t),this.match=e,this.method=s}setCatchHandler(e){this.catchHandler=R(e)}}class z extends g{constructor(e,t,s){const n=({url:r})=>{const c=e.exec(r.href);if(c&&!(r.origin!==location.origin&&c.index!==0))return c.slice(1)};super(n,t,s)}}class J{constructor(){this._routes=new Map,this._defaultHandlerMap=new Map}get routes(){return this._routes}addFetchListener(){self.addEventListener("fetch",(e=>{const{request:t}=e,s=this.handleRequest({request:t,event:e});s&&e.respondWith(s)}))}addCacheListener(){self.addEventListener("message",(e=>{if(e.data&&e.data.type==="CACHE_URLS"){const{payload:t}=e.data,s=Promise.all(t.urlsToCache.map(n=>{typeof n=="string"&&(n=[n]);const r=new Request(...n);return this.handleRequest({request:r,event:e})}));e.waitUntil(s),e.ports&&e.ports[0]&&s.then(()=>e.ports[0].postMessage(!0))}}))}handleRequest({request:e,event:t}){const s=new URL(e.url,location.href);if(!s.protocol.startsWith("http"))return;const n=s.origin===location.origin,{params:r,route:c}=this.findMatchingRoute({event:t,request:e,sameOrigin:n,url:s});let i=c&&c.handler;const o=e.method;if(!i&&this._defaultHandlerMap.has(o)&&(i=this._defaultHandlerMap.get(o)),!i)return;let h;try{h=i.handle({url:s,request:e,event:t,params:r})}catch(u){h=Promise.reject(u)}const p=c&&c.catchHandler;return h instanceof Promise&&(this._catchHandler||p)&&(h=h.catch(async u=>{if(p)try{return await p.handle({url:s,request:e,event:t,params:r})}catch(k){k instanceof Error&&(u=k)}if(this._catchHandler)return this._catchHandler.handle({url:s,request:e,event:t});throw u})),h}findMatchingRoute({url:e,sameOrigin:t,request:s,event:n}){const r=this._routes.get(s.method)||[];for(const c of r){let i;const o=c.match({url:e,sameOrigin:t,request:s,event:n});if(o)return i=o,(Array.isArray(i)&&i.length===0||o.constructor===Object&&Object.keys(o).length===0||typeof o=="boolean")&&(i=void 0),{route:c,params:i}}return{}}setDefaultHandler(e,t=v){this._defaultHandlerMap.set(t,R(e))}setCatchHandler(e){this._catchHandler=R(e)}registerRoute(e){this._routes.has(e.method)||this._routes.set(e.method,[]),this._routes.get(e.method).push(e)}unregisterRoute(e){if(!this._routes.has(e.method))throw new l("unregister-route-but-not-found-with-method",{method:e.method});const t=this._routes.get(e.method).indexOf(e);if(t>-1)this._routes.get(e.method).splice(t,1);else throw new l("unregister-route-route-not-registered")}}let w;const X=()=>(w||(w=new J,w.addFetchListener(),w.addCacheListener()),w);function T(a,e,t){let s;if(typeof a=="string"){const r=new URL(a,location.href),c=({url:i})=>i.href===r.href;s=new g(c,e,t)}else if(a instanceof RegExp)s=new z(a,e,t);else if(typeof a=="function")s=new g(a,e,t);else if(a instanceof g)s=a;else throw new l("unsupported-route-type",{moduleName:"workbox-routing",funcName:"registerRoute",paramName:"capture"});return X().registerRoute(s),s}function Y(a,e=[]){for(const t of[...a.searchParams.keys()])e.some(s=>s.test(t))&&a.searchParams.delete(t);return a}function*Z(a,{ignoreURLParametersMatching:e=[/^utm_/,/^fbclid$/],directoryIndex:t="index.html",cleanURLs:s=!0,urlManipulation:n}={}){const r=new URL(a,location.href);r.hash="",yield r.href;const c=Y(r,e);if(yield c.href,t&&c.pathname.endsWith("/")){const i=new URL(c.href);i.pathname+=t,yield i.href}if(s){const i=new URL(c.href);i.pathname+=".html",yield i.href}if(n){const i=n({url:r});for(const o of i)yield o.href}}class ee extends g{constructor(e,t){const s=({request:n})=>{const r=e.getURLsToCacheKeys();for(const c of Z(n.url,t)){const i=r.get(c);if(i){const o=e.getIntegrityForCacheKey(i);return{cacheKey:i,integrity:o}}}};super(s,e.strategy)}}function te(a){const e=L(),t=new ee(e,a);T(t)}const se="-precache-",ae=async(a,e=se)=>{const s=(await self.caches.keys()).filter(n=>n.includes(e)&&n.includes(self.registration.scope)&&n!==a);return await Promise.all(s.map(n=>self.caches.delete(n))),s};function ne(){self.addEventListener("activate",(a=>{const e=C.getPrecacheName();a.waitUntil(ae(e).then(t=>{}))}))}function re(a){return L().createHandlerBoundToURL(a)}function ce(a){L().precache(a)}function ie(a,e){ce(a),te(e)}class oe extends g{constructor(e,{allowlist:t=[/./],denylist:s=[]}={}){super(n=>this._match(n),e),this._allowlist=t,this._denylist=s}_match({url:e,request:t}){if(t&&t.mode!=="navigate")return!1;const s=e.pathname+e.search;for(const n of this._denylist)if(n.test(s))return!1;return!!this._allowlist.some(n=>n.test(s))}}self.skipWaiting();j();ie([{"revision":"fab346dacb0b9b9af12554e9a59f004d","url":"logo.svg"},{"revision":"623840baec64a3438663496bb1050d30","url":"logo-docker.svg"},{"revision":"c5c8611b432190ec28522f1a2dfcbb7f","url":"logo-codex.svg"},{"revision":"
|
|
2
|
+
This is generally NOT safe. Learn more at https://bit.ly/wb-precache`;console.warn(i)}}}install(e){return K(e,async()=>{const t=new B;this.strategy.plugins.push(t);for(const[r,c]of this._urlsToCacheKeys){const i=this._cacheKeysToIntegrities.get(c),o=this._urlsToCacheModes.get(r),h=new Request(r,{integrity:i,cache:o,credentials:"same-origin"});await Promise.all(this.strategy.handleAll({params:{cacheKey:c},request:h,event:e}))}const{updatedURLs:s,notUpdatedURLs:n}=t;return{updatedURLs:s,notUpdatedURLs:n}})}activate(e){return K(e,async()=>{const t=await self.caches.open(this.strategy.cacheName),s=await t.keys(),n=new Set(this._urlsToCacheKeys.values()),r=[];for(const c of s)n.has(c.url)||(await t.delete(c),r.push(c.url));return{deletedURLs:r}})}getURLsToCacheKeys(){return this._urlsToCacheKeys}getCachedURLs(){return[...this._urlsToCacheKeys.keys()]}getCacheKeyForURL(e){const t=new URL(e,location.href);return this._urlsToCacheKeys.get(t.href)}getIntegrityForCacheKey(e){return this._cacheKeysToIntegrities.get(e)}async matchPrecache(e){const t=e instanceof Request?e.url:e,s=this.getCacheKeyForURL(t);if(s)return(await self.caches.open(this.strategy.cacheName)).match(s)}createHandlerBoundToURL(e){const t=this.getCacheKeyForURL(e);if(!t)throw new l("non-precached-url",{url:e});return s=>(s.request=new Request(e),s.params=Object.assign({cacheKey:t},s.params),this.strategy.handle(s))}}let U;const L=()=>(U||(U=new Q),U);try{self["workbox:routing:7.4.0"]&&_()}catch{}const v="GET",R=a=>a&&typeof a=="object"?a:{handle:a};class g{constructor(e,t,s=v){this.handler=R(t),this.match=e,this.method=s}setCatchHandler(e){this.catchHandler=R(e)}}class z extends g{constructor(e,t,s){const n=({url:r})=>{const c=e.exec(r.href);if(c&&!(r.origin!==location.origin&&c.index!==0))return c.slice(1)};super(n,t,s)}}class J{constructor(){this._routes=new Map,this._defaultHandlerMap=new Map}get routes(){return this._routes}addFetchListener(){self.addEventListener("fetch",(e=>{const{request:t}=e,s=this.handleRequest({request:t,event:e});s&&e.respondWith(s)}))}addCacheListener(){self.addEventListener("message",(e=>{if(e.data&&e.data.type==="CACHE_URLS"){const{payload:t}=e.data,s=Promise.all(t.urlsToCache.map(n=>{typeof n=="string"&&(n=[n]);const r=new Request(...n);return this.handleRequest({request:r,event:e})}));e.waitUntil(s),e.ports&&e.ports[0]&&s.then(()=>e.ports[0].postMessage(!0))}}))}handleRequest({request:e,event:t}){const s=new URL(e.url,location.href);if(!s.protocol.startsWith("http"))return;const n=s.origin===location.origin,{params:r,route:c}=this.findMatchingRoute({event:t,request:e,sameOrigin:n,url:s});let i=c&&c.handler;const o=e.method;if(!i&&this._defaultHandlerMap.has(o)&&(i=this._defaultHandlerMap.get(o)),!i)return;let h;try{h=i.handle({url:s,request:e,event:t,params:r})}catch(u){h=Promise.reject(u)}const p=c&&c.catchHandler;return h instanceof Promise&&(this._catchHandler||p)&&(h=h.catch(async u=>{if(p)try{return await p.handle({url:s,request:e,event:t,params:r})}catch(k){k instanceof Error&&(u=k)}if(this._catchHandler)return this._catchHandler.handle({url:s,request:e,event:t});throw u})),h}findMatchingRoute({url:e,sameOrigin:t,request:s,event:n}){const r=this._routes.get(s.method)||[];for(const c of r){let i;const o=c.match({url:e,sameOrigin:t,request:s,event:n});if(o)return i=o,(Array.isArray(i)&&i.length===0||o.constructor===Object&&Object.keys(o).length===0||typeof o=="boolean")&&(i=void 0),{route:c,params:i}}return{}}setDefaultHandler(e,t=v){this._defaultHandlerMap.set(t,R(e))}setCatchHandler(e){this._catchHandler=R(e)}registerRoute(e){this._routes.has(e.method)||this._routes.set(e.method,[]),this._routes.get(e.method).push(e)}unregisterRoute(e){if(!this._routes.has(e.method))throw new l("unregister-route-but-not-found-with-method",{method:e.method});const t=this._routes.get(e.method).indexOf(e);if(t>-1)this._routes.get(e.method).splice(t,1);else throw new l("unregister-route-route-not-registered")}}let w;const X=()=>(w||(w=new J,w.addFetchListener(),w.addCacheListener()),w);function T(a,e,t){let s;if(typeof a=="string"){const r=new URL(a,location.href),c=({url:i})=>i.href===r.href;s=new g(c,e,t)}else if(a instanceof RegExp)s=new z(a,e,t);else if(typeof a=="function")s=new g(a,e,t);else if(a instanceof g)s=a;else throw new l("unsupported-route-type",{moduleName:"workbox-routing",funcName:"registerRoute",paramName:"capture"});return X().registerRoute(s),s}function Y(a,e=[]){for(const t of[...a.searchParams.keys()])e.some(s=>s.test(t))&&a.searchParams.delete(t);return a}function*Z(a,{ignoreURLParametersMatching:e=[/^utm_/,/^fbclid$/],directoryIndex:t="index.html",cleanURLs:s=!0,urlManipulation:n}={}){const r=new URL(a,location.href);r.hash="",yield r.href;const c=Y(r,e);if(yield c.href,t&&c.pathname.endsWith("/")){const i=new URL(c.href);i.pathname+=t,yield i.href}if(s){const i=new URL(c.href);i.pathname+=".html",yield i.href}if(n){const i=n({url:r});for(const o of i)yield o.href}}class ee extends g{constructor(e,t){const s=({request:n})=>{const r=e.getURLsToCacheKeys();for(const c of Z(n.url,t)){const i=r.get(c);if(i){const o=e.getIntegrityForCacheKey(i);return{cacheKey:i,integrity:o}}}};super(s,e.strategy)}}function te(a){const e=L(),t=new ee(e,a);T(t)}const se="-precache-",ae=async(a,e=se)=>{const s=(await self.caches.keys()).filter(n=>n.includes(e)&&n.includes(self.registration.scope)&&n!==a);return await Promise.all(s.map(n=>self.caches.delete(n))),s};function ne(){self.addEventListener("activate",(a=>{const e=C.getPrecacheName();a.waitUntil(ae(e).then(t=>{}))}))}function re(a){return L().createHandlerBoundToURL(a)}function ce(a){L().precache(a)}function ie(a,e){ce(a),te(e)}class oe extends g{constructor(e,{allowlist:t=[/./],denylist:s=[]}={}){super(n=>this._match(n),e),this._allowlist=t,this._denylist=s}_match({url:e,request:t}){if(t&&t.mode!=="navigate")return!1;const s=e.pathname+e.search;for(const n of this._denylist)if(n.test(s))return!1;return!!this._allowlist.some(n=>n.test(s))}}self.skipWaiting();j();ie([{"revision":"fab346dacb0b9b9af12554e9a59f004d","url":"logo.svg"},{"revision":"623840baec64a3438663496bb1050d30","url":"logo-docker.svg"},{"revision":"c5c8611b432190ec28522f1a2dfcbb7f","url":"logo-codex.svg"},{"revision":"47e0c2bc0ef20fa5f29c67b1bd618b46","url":"index.html"},{"revision":"1421e9c6cad8218ef5689b85034d3a95","url":"icon-512.png"},{"revision":"b96da5a0b6636012517f54b0e69c304d","url":"icon-192.png"},{"revision":"c64ca8ae8596d4ffe13d0f591beb2274","url":"favicon.svg"},{"revision":"6481face04bd08e6f0bfc2a633840b57","url":"apple-touch-icon.png"},{"revision":"83304194a3c8be2b5b61242eeb1c1046","url":"fonts/MesloLGSNerdFontMono-Regular.woff2"},{"revision":"60fafe18cbcb717c51cdabf87f9490f0","url":"fonts/MesloLGSNerdFontMono-Bold.woff2"},{"revision":null,"url":"assets/workbox-window.prod.es5-BBnX5xw4.js"},{"revision":null,"url":"assets/sw-register-BPMmALbK.js"},{"revision":null,"url":"assets/index-psWeuMrk.css"},{"revision":null,"url":"assets/index-DeUv1epH.js"},{"revision":null,"url":"assets/TailscalePage-Buy9PO3F.js"},{"revision":null,"url":"assets/SettingsPage-BLax9147.js"},{"revision":null,"url":"assets/SandboxManager-CX_Zi6K3.js"},{"revision":null,"url":"assets/RunsPage-CgUwAzRE.js"},{"revision":null,"url":"assets/PromptsPage-_B61JVQm.js"},{"revision":null,"url":"assets/Playground-BBt78cYF.js"},{"revision":null,"url":"assets/LinearSettingsPage-4_3ZJrKS.js"},{"revision":null,"url":"assets/LinearOAuthSettingsPage--m6I9icx.js"},{"revision":null,"url":"assets/IntegrationsPage-BExX5JOb.js"},{"revision":null,"url":"assets/CronManager-DDwptxks.js"},{"revision":null,"url":"assets/AgentsPage-eLLQNxgP.js"}]);ne();T(new oe(re("index.html"),{denylist:[/^\/api/,/^\/ws/]}));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hellcoder/companion",
|
|
3
|
-
"version": "0.105.1
|
|
3
|
+
"version": "0.105.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Web UI for launching and interacting with Claude Code agents — Moritz Edition (fork of the-companion)",
|
|
6
6
|
"license": "MIT",
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import { vi, describe, it, expect, beforeEach } from "vitest";
|
|
2
|
+
import { EventEmitter } from "node:events";
|
|
3
|
+
|
|
4
|
+
// ── Mocks ────────────────────────────────────────────────────────────────────
|
|
5
|
+
// fetchCodexModels spawns `codex app-server` and speaks JSON-RPC over stdio.
|
|
6
|
+
// We mock node:child_process spawn with a fake child that replays the Codex
|
|
7
|
+
// handshake, plus path-resolver / fs so no real binary or PATH is touched.
|
|
8
|
+
|
|
9
|
+
const mockSpawn = vi.hoisted(() => vi.fn());
|
|
10
|
+
vi.mock("node:child_process", () => ({ spawn: mockSpawn }));
|
|
11
|
+
|
|
12
|
+
const mockResolveBinary = vi.hoisted(() => vi.fn((_n: string) => "/usr/bin/codex" as string | null));
|
|
13
|
+
const mockGetEnrichedPath = vi.hoisted(() => vi.fn(() => "/usr/bin:/bin"));
|
|
14
|
+
vi.mock("./path-resolver.js", () => ({
|
|
15
|
+
resolveBinary: mockResolveBinary,
|
|
16
|
+
getEnrichedPath: mockGetEnrichedPath,
|
|
17
|
+
}));
|
|
18
|
+
|
|
19
|
+
const mockExistsSync = vi.hoisted(() => vi.fn(() => false));
|
|
20
|
+
const mockRealpathSync = vi.hoisted(() => vi.fn((p: string) => p));
|
|
21
|
+
vi.mock("node:fs", () => ({
|
|
22
|
+
existsSync: mockExistsSync,
|
|
23
|
+
realpathSync: mockRealpathSync,
|
|
24
|
+
}));
|
|
25
|
+
|
|
26
|
+
import { parseCodexModelList, fetchCodexModels, _resetCodexModelsCache } from "./codex-models.js";
|
|
27
|
+
|
|
28
|
+
// A fake ChildProcess that drives the Codex initialize → model/list handshake.
|
|
29
|
+
// When the code under test writes `initialize`/`model/list` to stdin, the fake
|
|
30
|
+
// replays the corresponding responses on stdout. Behavior is configurable to
|
|
31
|
+
// exercise error/timeout/empty branches.
|
|
32
|
+
function makeFakeChild(opts: {
|
|
33
|
+
modelListResult?: unknown;
|
|
34
|
+
onInit?: (child: FakeChild) => void; // override default init handling (e.g. emit error)
|
|
35
|
+
respondToModelList?: boolean; // default true
|
|
36
|
+
} = {}): FakeChild {
|
|
37
|
+
const child = new EventEmitter() as FakeChild;
|
|
38
|
+
child.stdout = new EventEmitter();
|
|
39
|
+
child.kill = vi.fn();
|
|
40
|
+
const respondToModelList = opts.respondToModelList !== false;
|
|
41
|
+
|
|
42
|
+
child.stdin = {
|
|
43
|
+
write: (line: string) => {
|
|
44
|
+
let msg: { method?: string; id?: number };
|
|
45
|
+
try { msg = JSON.parse(line); } catch { return; }
|
|
46
|
+
if (msg.method === "initialize") {
|
|
47
|
+
if (opts.onInit) { opts.onInit(child); return; }
|
|
48
|
+
// Ack initialize so the code sends initialized + model/list.
|
|
49
|
+
queueMicrotask(() => child.stdout.emit("data", Buffer.from(JSON.stringify({ id: 1, result: {} }) + "\n")));
|
|
50
|
+
} else if (msg.method === "model/list" && respondToModelList) {
|
|
51
|
+
const result = opts.modelListResult ?? {
|
|
52
|
+
data: [
|
|
53
|
+
{ id: "gpt-5.5", displayName: "GPT-5.5", description: "Frontier", hidden: false, isDefault: true },
|
|
54
|
+
{ id: "gpt-5.3-codex", displayName: "gpt-5.3-codex", description: "Codex", hidden: false },
|
|
55
|
+
],
|
|
56
|
+
};
|
|
57
|
+
queueMicrotask(() => child.stdout.emit("data", Buffer.from(JSON.stringify({ id: 2, result }) + "\n")));
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
return child;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
interface FakeChild extends EventEmitter {
|
|
65
|
+
stdout: EventEmitter;
|
|
66
|
+
stdin: { write: (line: string) => void };
|
|
67
|
+
kill: ReturnType<typeof vi.fn>;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// These tests validate the pure parsing of the Codex app-server `model/list`
|
|
71
|
+
// RPC payload into the UI's { value, label, description } model-option shape.
|
|
72
|
+
// The payload fixtures mirror the real shape emitted by `codex app-server`
|
|
73
|
+
// (id/displayName/description/hidden/isDefault), captured from codex 0.142.x.
|
|
74
|
+
describe("parseCodexModelList", () => {
|
|
75
|
+
it("maps id/displayName/description and floats the default model first", () => {
|
|
76
|
+
const result = {
|
|
77
|
+
data: [
|
|
78
|
+
{ id: "gpt-5.3-codex", displayName: "gpt-5.3-codex", description: "Codex model", hidden: false },
|
|
79
|
+
{ id: "gpt-5.5", displayName: "GPT-5.5", description: "Frontier model", hidden: false, isDefault: true },
|
|
80
|
+
],
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const models = parseCodexModelList(result);
|
|
84
|
+
|
|
85
|
+
// gpt-5.5 is the default and must be first so getDefaultModel() picks it.
|
|
86
|
+
expect(models).toEqual([
|
|
87
|
+
{ value: "gpt-5.5", label: "GPT-5.5", description: "Frontier model", isDefault: true },
|
|
88
|
+
{ value: "gpt-5.3-codex", label: "gpt-5.3-codex", description: "Codex model", isDefault: false },
|
|
89
|
+
]);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("filters out hidden models", () => {
|
|
93
|
+
const result = {
|
|
94
|
+
data: [
|
|
95
|
+
{ id: "gpt-5.5", displayName: "GPT-5.5", hidden: false },
|
|
96
|
+
{ id: "gpt-5-legacy", displayName: "Legacy", hidden: true },
|
|
97
|
+
],
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const models = parseCodexModelList(result);
|
|
101
|
+
|
|
102
|
+
expect(models.map((m) => m.value)).toEqual(["gpt-5.5"]);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("falls back to id for label and empty string for missing description", () => {
|
|
106
|
+
const result = { data: [{ id: "gpt-5.5" }] };
|
|
107
|
+
|
|
108
|
+
const models = parseCodexModelList(result);
|
|
109
|
+
|
|
110
|
+
expect(models[0]).toEqual({ value: "gpt-5.5", label: "gpt-5.5", description: "", isDefault: false });
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("uses `model` when `id` is absent", () => {
|
|
114
|
+
const result = { data: [{ model: "gpt-5.4-mini", displayName: "GPT-5.4 Mini" }] };
|
|
115
|
+
|
|
116
|
+
const models = parseCodexModelList(result);
|
|
117
|
+
|
|
118
|
+
expect(models[0].value).toBe("gpt-5.4-mini");
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("skips entries without an id or model slug", () => {
|
|
122
|
+
const result = { data: [{ displayName: "Nameless" }, { id: "gpt-5.5" }] };
|
|
123
|
+
|
|
124
|
+
const models = parseCodexModelList(result);
|
|
125
|
+
|
|
126
|
+
expect(models.map((m) => m.value)).toEqual(["gpt-5.5"]);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("returns an empty array for malformed or missing payloads", () => {
|
|
130
|
+
expect(parseCodexModelList(undefined)).toEqual([]);
|
|
131
|
+
expect(parseCodexModelList(null)).toEqual([]);
|
|
132
|
+
expect(parseCodexModelList({})).toEqual([]);
|
|
133
|
+
expect(parseCodexModelList({ data: "not-an-array" })).toEqual([]);
|
|
134
|
+
expect(parseCodexModelList({ data: [null, 42, "x"] })).toEqual([]);
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
describe("fetchCodexModels", () => {
|
|
139
|
+
beforeEach(() => {
|
|
140
|
+
_resetCodexModelsCache();
|
|
141
|
+
mockSpawn.mockReset();
|
|
142
|
+
mockResolveBinary.mockReset().mockReturnValue("/usr/bin/codex");
|
|
143
|
+
mockGetEnrichedPath.mockReset().mockReturnValue("/usr/bin:/bin");
|
|
144
|
+
mockExistsSync.mockReset().mockReturnValue(false);
|
|
145
|
+
mockRealpathSync.mockReset().mockImplementation((p: string) => p);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("performs the handshake and returns parsed models", async () => {
|
|
149
|
+
const child = makeFakeChild();
|
|
150
|
+
mockSpawn.mockReturnValue(child);
|
|
151
|
+
|
|
152
|
+
const models = await fetchCodexModels();
|
|
153
|
+
|
|
154
|
+
expect(models.map((m) => m.value)).toEqual(["gpt-5.5", "gpt-5.3-codex"]);
|
|
155
|
+
// Spawns `codex app-server` (bare, no --enable flags).
|
|
156
|
+
expect(mockSpawn).toHaveBeenCalledTimes(1);
|
|
157
|
+
const args = mockSpawn.mock.calls[0][1] as string[];
|
|
158
|
+
expect(args).toContain("app-server");
|
|
159
|
+
// The short-lived process is cleaned up.
|
|
160
|
+
expect(child.kill).toHaveBeenCalled();
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("returns [] when the codex binary cannot be resolved", async () => {
|
|
164
|
+
mockResolveBinary.mockReturnValue(null);
|
|
165
|
+
|
|
166
|
+
const models = await fetchCodexModels();
|
|
167
|
+
|
|
168
|
+
expect(models).toEqual([]);
|
|
169
|
+
expect(mockSpawn).not.toHaveBeenCalled();
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("uses the sibling-node shim when a bundled node exists next to the binary", async () => {
|
|
173
|
+
// existsSync(true) => the binary dir has a sibling `node`; spawn node + script.
|
|
174
|
+
mockExistsSync.mockReturnValue(true);
|
|
175
|
+
mockRealpathSync.mockReturnValue("/usr/lib/codex/codex.js");
|
|
176
|
+
const child = makeFakeChild();
|
|
177
|
+
mockSpawn.mockReturnValue(child);
|
|
178
|
+
|
|
179
|
+
await fetchCodexModels();
|
|
180
|
+
|
|
181
|
+
const cmd = mockSpawn.mock.calls[0][0] as string;
|
|
182
|
+
const args = mockSpawn.mock.calls[0][1] as string[];
|
|
183
|
+
expect(cmd).toBe("/usr/bin/node");
|
|
184
|
+
expect(args[0]).toBe("/usr/lib/codex/codex.js");
|
|
185
|
+
expect(args).toContain("app-server");
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("returns [] and stops when the process emits an error", async () => {
|
|
189
|
+
const child = makeFakeChild({ onInit: (c) => c.emit("error", new Error("spawn failed")) });
|
|
190
|
+
mockSpawn.mockReturnValue(child);
|
|
191
|
+
|
|
192
|
+
const models = await fetchCodexModels();
|
|
193
|
+
|
|
194
|
+
expect(models).toEqual([]);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("returns [] when the process exits before answering model/list", async () => {
|
|
198
|
+
const child = makeFakeChild({ onInit: (c) => c.emit("exit", 1) });
|
|
199
|
+
mockSpawn.mockReturnValue(child);
|
|
200
|
+
|
|
201
|
+
const models = await fetchCodexModels();
|
|
202
|
+
|
|
203
|
+
expect(models).toEqual([]);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("times out and returns [] when the process never responds", async () => {
|
|
207
|
+
// respondToModelList:false and a no-op init => nothing ever comes back.
|
|
208
|
+
const child = makeFakeChild({ onInit: () => { /* swallow init, never reply */ } });
|
|
209
|
+
mockSpawn.mockReturnValue(child);
|
|
210
|
+
|
|
211
|
+
const models = await fetchCodexModels({ timeoutMs: 20 });
|
|
212
|
+
|
|
213
|
+
expect(models).toEqual([]);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it("returns [] when spawn itself throws", async () => {
|
|
217
|
+
mockSpawn.mockImplementation(() => { throw new Error("ENOENT"); });
|
|
218
|
+
|
|
219
|
+
const models = await fetchCodexModels();
|
|
220
|
+
|
|
221
|
+
expect(models).toEqual([]);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it("caches successful results and does not respawn within the TTL", async () => {
|
|
225
|
+
const child = makeFakeChild();
|
|
226
|
+
mockSpawn.mockReturnValue(child);
|
|
227
|
+
|
|
228
|
+
const first = await fetchCodexModels();
|
|
229
|
+
const second = await fetchCodexModels();
|
|
230
|
+
|
|
231
|
+
expect(first).toEqual(second);
|
|
232
|
+
// Second call served from cache — spawn only happened once.
|
|
233
|
+
expect(mockSpawn).toHaveBeenCalledTimes(1);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it("does not cache empty results (retries on next call)", async () => {
|
|
237
|
+
mockSpawn.mockReturnValueOnce(makeFakeChild({ modelListResult: { data: [] } }));
|
|
238
|
+
const empty = await fetchCodexModels();
|
|
239
|
+
expect(empty).toEqual([]);
|
|
240
|
+
|
|
241
|
+
// A subsequent call should spawn again (empty results are not cached).
|
|
242
|
+
mockSpawn.mockReturnValueOnce(makeFakeChild());
|
|
243
|
+
const models = await fetchCodexModels();
|
|
244
|
+
expect(models.map((m) => m.value)).toEqual(["gpt-5.5", "gpt-5.3-codex"]);
|
|
245
|
+
expect(mockSpawn).toHaveBeenCalledTimes(2);
|
|
246
|
+
});
|
|
247
|
+
});
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { existsSync, realpathSync } from "node:fs";
|
|
3
|
+
import { join, resolve } from "node:path";
|
|
4
|
+
import { getEnrichedPath, resolveBinary } from "./path-resolver.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A Codex model as surfaced to the frontend model picker.
|
|
8
|
+
* Shape matches the `/backends/:id/models` response contract that the UI's
|
|
9
|
+
* `toModelOptions` helper consumes ({ value, label, description }).
|
|
10
|
+
*/
|
|
11
|
+
export interface CodexModelOption {
|
|
12
|
+
value: string;
|
|
13
|
+
label: string;
|
|
14
|
+
description: string;
|
|
15
|
+
/** True for the model Codex marks as its default (`isDefault`). */
|
|
16
|
+
isDefault?: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Raw entry shape from the Codex app-server `model/list` RPC. Only the fields
|
|
21
|
+
* we care about are typed; the payload carries many more (see the generated
|
|
22
|
+
* protocol bindings under web/server/protocol/codex-upstream).
|
|
23
|
+
*/
|
|
24
|
+
interface CodexModelListEntry {
|
|
25
|
+
id?: string;
|
|
26
|
+
model?: string;
|
|
27
|
+
displayName?: string;
|
|
28
|
+
description?: string;
|
|
29
|
+
hidden?: boolean;
|
|
30
|
+
isDefault?: boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Parse the `result` payload of a Codex `model/list` response into the UI
|
|
35
|
+
* model-option shape. Hidden models are filtered out; the default model is
|
|
36
|
+
* floated to the top so it becomes the picker's default selection.
|
|
37
|
+
*
|
|
38
|
+
* Pure (no I/O) so it can be unit-tested against recorded payloads.
|
|
39
|
+
*/
|
|
40
|
+
export function parseCodexModelList(result: unknown): CodexModelOption[] {
|
|
41
|
+
const data = (result as { data?: unknown })?.data;
|
|
42
|
+
if (!Array.isArray(data)) return [];
|
|
43
|
+
|
|
44
|
+
const options: CodexModelOption[] = [];
|
|
45
|
+
for (const raw of data as CodexModelListEntry[]) {
|
|
46
|
+
if (!raw || typeof raw !== "object") continue;
|
|
47
|
+
if (raw.hidden === true) continue;
|
|
48
|
+
const value = raw.id || raw.model;
|
|
49
|
+
if (!value) continue;
|
|
50
|
+
options.push({
|
|
51
|
+
value,
|
|
52
|
+
label: raw.displayName || value,
|
|
53
|
+
description: raw.description || "",
|
|
54
|
+
isDefault: raw.isDefault === true,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Float the default model to the top so getDefaultModel() picks it.
|
|
59
|
+
options.sort((a, b) => Number(b.isDefault) - Number(a.isDefault));
|
|
60
|
+
return options;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// In-memory cache so repeated picker loads don't respawn Codex every time.
|
|
64
|
+
// Codex's model catalog only changes on CLI upgrade, so a few minutes is safe.
|
|
65
|
+
const MODELS_CACHE_TTL_MS = 5 * 60 * 1000;
|
|
66
|
+
const modelsCache = new Map<string, { at: number; models: CodexModelOption[] }>();
|
|
67
|
+
|
|
68
|
+
/** Clear the in-memory model cache (test helper). */
|
|
69
|
+
export function _resetCodexModelsCache(): void {
|
|
70
|
+
modelsCache.clear();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Build the spawn command for `codex app-server`, resolving the binary and
|
|
75
|
+
* applying the same sibling-node shim that cli-launcher uses so a CLI shipped
|
|
76
|
+
* with a bundled node still launches correctly.
|
|
77
|
+
*/
|
|
78
|
+
function buildCodexSpawn(binaryName: string): { cmd: string[]; env: NodeJS.ProcessEnv } | null {
|
|
79
|
+
const resolved = resolveBinary(binaryName);
|
|
80
|
+
if (!resolved) return null;
|
|
81
|
+
|
|
82
|
+
const binaryDir = resolve(resolved, "..");
|
|
83
|
+
const siblingNode = join(binaryDir, "node");
|
|
84
|
+
const enrichedPath = getEnrichedPath();
|
|
85
|
+
const pathSep = process.platform === "win32" ? ";" : ":";
|
|
86
|
+
const spawnPath = [binaryDir, ...enrichedPath.split(pathSep)].filter(Boolean).join(pathSep);
|
|
87
|
+
|
|
88
|
+
// `model/list` does not require auth or a sandbox, so we skip --enable flags
|
|
89
|
+
// and run a bare app-server purely for the handshake + listing.
|
|
90
|
+
const args = ["app-server"];
|
|
91
|
+
|
|
92
|
+
let cmd: string[];
|
|
93
|
+
if (existsSync(siblingNode)) {
|
|
94
|
+
let codexScript: string;
|
|
95
|
+
try {
|
|
96
|
+
codexScript = realpathSync(resolved);
|
|
97
|
+
} catch {
|
|
98
|
+
codexScript = resolved;
|
|
99
|
+
}
|
|
100
|
+
cmd = [siblingNode, codexScript, ...args];
|
|
101
|
+
} else {
|
|
102
|
+
const isCmdScript = process.platform === "win32" && (resolved.endsWith(".cmd") || resolved.endsWith(".bat"));
|
|
103
|
+
cmd = isCmdScript ? ["cmd.exe", "/c", resolved, ...args] : [resolved, ...args];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
cmd,
|
|
108
|
+
env: { ...process.env, CLAUDECODE: undefined, PATH: spawnPath },
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Fetch the list of available Codex models by briefly launching
|
|
114
|
+
* `codex app-server`, performing the initialize handshake, and calling the
|
|
115
|
+
* `model/list` RPC. Replaces the legacy `~/.codex/models_cache.json` reader —
|
|
116
|
+
* recent Codex releases no longer write that file and serve models over the
|
|
117
|
+
* app-server protocol instead.
|
|
118
|
+
*
|
|
119
|
+
* Returns an empty array on any failure (binary missing, timeout, RPC error)
|
|
120
|
+
* so callers can fall back to a static list.
|
|
121
|
+
*/
|
|
122
|
+
export async function fetchCodexModels(opts: { binary?: string; timeoutMs?: number } = {}): Promise<CodexModelOption[]> {
|
|
123
|
+
const binaryName = opts.binary || "codex";
|
|
124
|
+
const timeoutMs = opts.timeoutMs ?? 10_000;
|
|
125
|
+
|
|
126
|
+
const cached = modelsCache.get(binaryName);
|
|
127
|
+
if (cached && Date.now() - cached.at < MODELS_CACHE_TTL_MS) {
|
|
128
|
+
return cached.models;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const spawnInfo = buildCodexSpawn(binaryName);
|
|
132
|
+
if (!spawnInfo) return [];
|
|
133
|
+
|
|
134
|
+
return new Promise<CodexModelOption[]>((resolvePromise) => {
|
|
135
|
+
let settled = false;
|
|
136
|
+
const finish = (models: CodexModelOption[]) => {
|
|
137
|
+
if (settled) return;
|
|
138
|
+
settled = true;
|
|
139
|
+
clearTimeout(timer);
|
|
140
|
+
try { child.kill(); } catch { /* already dead */ }
|
|
141
|
+
if (models.length > 0) modelsCache.set(binaryName, { at: Date.now(), models });
|
|
142
|
+
resolvePromise(models);
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
let child: ReturnType<typeof spawn>;
|
|
146
|
+
try {
|
|
147
|
+
child = spawn(spawnInfo.cmd[0], spawnInfo.cmd.slice(1), {
|
|
148
|
+
env: spawnInfo.env,
|
|
149
|
+
stdio: ["pipe", "pipe", "ignore"],
|
|
150
|
+
});
|
|
151
|
+
} catch {
|
|
152
|
+
resolvePromise([]);
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const timer = setTimeout(() => finish([]), timeoutMs);
|
|
157
|
+
|
|
158
|
+
const send = (obj: Record<string, unknown>) => {
|
|
159
|
+
try { child.stdin?.write(JSON.stringify(obj) + "\n"); } catch { /* pipe closed */ }
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
let buf = "";
|
|
163
|
+
child.stdout?.on("data", (chunk: Buffer) => {
|
|
164
|
+
buf += chunk.toString();
|
|
165
|
+
let idx: number;
|
|
166
|
+
while ((idx = buf.indexOf("\n")) >= 0) {
|
|
167
|
+
const line = buf.slice(0, idx);
|
|
168
|
+
buf = buf.slice(idx + 1);
|
|
169
|
+
if (!line.trim()) continue;
|
|
170
|
+
let msg: { id?: number; result?: unknown };
|
|
171
|
+
try { msg = JSON.parse(line); } catch { continue; }
|
|
172
|
+
if (msg.id === 1) {
|
|
173
|
+
// initialize acknowledged → complete handshake and request models
|
|
174
|
+
send({ method: "initialized", params: {} });
|
|
175
|
+
send({ method: "model/list", id: 2, params: {} });
|
|
176
|
+
} else if (msg.id === 2) {
|
|
177
|
+
finish(parseCodexModelList(msg.result));
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
child.on("error", () => finish([]));
|
|
184
|
+
child.on("exit", () => finish([]));
|
|
185
|
+
|
|
186
|
+
// Kick off the handshake.
|
|
187
|
+
send({
|
|
188
|
+
method: "initialize",
|
|
189
|
+
id: 1,
|
|
190
|
+
params: {
|
|
191
|
+
clientInfo: { name: "thecompanion", title: "The Companion", version: "1.0.0" },
|
|
192
|
+
capabilities: { experimentalApi: true },
|
|
193
|
+
},
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
}
|
package/server/routes.test.ts
CHANGED
|
@@ -44,6 +44,11 @@ vi.mock("./path-resolver.js", () => ({
|
|
|
44
44
|
resolveBinary: mockResolveBinary,
|
|
45
45
|
}));
|
|
46
46
|
|
|
47
|
+
const mockFetchCodexModels = vi.hoisted(() => vi.fn(async () => [] as Array<{ value: string; label: string; description: string; isDefault?: boolean }>));
|
|
48
|
+
vi.mock("./codex-models.js", () => ({
|
|
49
|
+
fetchCodexModels: mockFetchCodexModels,
|
|
50
|
+
}));
|
|
51
|
+
|
|
47
52
|
vi.mock("node:fs", async (importOriginal) => {
|
|
48
53
|
const actual = await importOriginal<typeof import("node:fs")>();
|
|
49
54
|
return {
|
|
@@ -3565,7 +3570,33 @@ describe("GET /api/backends", () => {
|
|
|
3565
3570
|
});
|
|
3566
3571
|
|
|
3567
3572
|
describe("GET /api/backends/:id/models", () => {
|
|
3568
|
-
|
|
3573
|
+
beforeEach(() => {
|
|
3574
|
+
mockFetchCodexModels.mockReset();
|
|
3575
|
+
});
|
|
3576
|
+
|
|
3577
|
+
// The primary path: models come live from the Codex app-server `model/list`
|
|
3578
|
+
// RPC (recent Codex releases no longer write models_cache.json).
|
|
3579
|
+
it("returns codex models from the live app-server RPC", async () => {
|
|
3580
|
+
mockFetchCodexModels.mockResolvedValue([
|
|
3581
|
+
{ value: "gpt-5.5", label: "GPT-5.5", description: "Frontier model", isDefault: true },
|
|
3582
|
+
{ value: "gpt-5.3-codex", label: "gpt-5.3-codex", description: "Codex model" },
|
|
3583
|
+
]);
|
|
3584
|
+
|
|
3585
|
+
const res = await app.request("/api/backends/codex/models", { method: "GET" });
|
|
3586
|
+
|
|
3587
|
+
expect(res.status).toBe(200);
|
|
3588
|
+
const json = await res.json();
|
|
3589
|
+
// isDefault is an internal hint and must not leak into the API contract.
|
|
3590
|
+
expect(json).toEqual([
|
|
3591
|
+
{ value: "gpt-5.5", label: "GPT-5.5", description: "Frontier model" },
|
|
3592
|
+
{ value: "gpt-5.3-codex", label: "gpt-5.3-codex", description: "Codex model" },
|
|
3593
|
+
]);
|
|
3594
|
+
});
|
|
3595
|
+
|
|
3596
|
+
// Fallback path: if the RPC yields nothing, the legacy cache file (if present)
|
|
3597
|
+
// is still honored so older Codex installs keep working.
|
|
3598
|
+
it("falls back to the legacy cache file when the RPC returns no models", async () => {
|
|
3599
|
+
mockFetchCodexModels.mockResolvedValue([]);
|
|
3569
3600
|
const cacheContent = JSON.stringify({
|
|
3570
3601
|
models: [
|
|
3571
3602
|
{ slug: "gpt-5.1-codex-mini", display_name: "gpt-5.1-codex-mini", description: "Fast model", visibility: "list", priority: 10 },
|
|
@@ -3580,32 +3611,32 @@ describe("GET /api/backends/:id/models", () => {
|
|
|
3580
3611
|
|
|
3581
3612
|
expect(res.status).toBe(200);
|
|
3582
3613
|
const json = await res.json();
|
|
3583
|
-
// Should only include visible models, sorted by priority
|
|
3584
3614
|
expect(json).toEqual([
|
|
3585
3615
|
{ value: "gpt-5.2-codex", label: "gpt-5.2-codex", description: "Frontier model" },
|
|
3586
3616
|
{ value: "gpt-5.1-codex-mini", label: "gpt-5.1-codex-mini", description: "Fast model" },
|
|
3587
3617
|
]);
|
|
3588
3618
|
});
|
|
3589
3619
|
|
|
3590
|
-
it("returns 404 when
|
|
3620
|
+
it("returns 404 when neither the RPC nor a cache file yields models", async () => {
|
|
3621
|
+
mockFetchCodexModels.mockResolvedValue([]);
|
|
3591
3622
|
vi.mocked(existsSync).mockReturnValue(false);
|
|
3592
3623
|
|
|
3593
3624
|
const res = await app.request("/api/backends/codex/models", { method: "GET" });
|
|
3594
3625
|
|
|
3595
3626
|
expect(res.status).toBe(404);
|
|
3596
3627
|
const json = await res.json();
|
|
3597
|
-
expect(json.error).toContain("Codex models
|
|
3628
|
+
expect(json.error).toContain("Codex models unavailable");
|
|
3598
3629
|
});
|
|
3599
3630
|
|
|
3600
|
-
|
|
3631
|
+
// A malformed legacy cache must not crash the endpoint — it falls through to 404.
|
|
3632
|
+
it("returns 404 (not 500) when the cache file is malformed and the RPC is empty", async () => {
|
|
3633
|
+
mockFetchCodexModels.mockResolvedValue([]);
|
|
3601
3634
|
vi.mocked(existsSync).mockReturnValue(true);
|
|
3602
3635
|
vi.mocked(readFileSync).mockReturnValue("not valid json{{{");
|
|
3603
3636
|
|
|
3604
3637
|
const res = await app.request("/api/backends/codex/models", { method: "GET" });
|
|
3605
3638
|
|
|
3606
|
-
expect(res.status).toBe(
|
|
3607
|
-
const json = await res.json();
|
|
3608
|
-
expect(json.error).toContain("Failed to parse");
|
|
3639
|
+
expect(res.status).toBe(404);
|
|
3609
3640
|
});
|
|
3610
3641
|
|
|
3611
3642
|
it("returns 404 for claude backend (uses frontend defaults)", async () => {
|