@hellcoder/companion 0.124.1 → 0.126.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/dist/assets/{AgentsPage-Byq8wvdn.js → AgentsPage-DuPncpk1.js} +1 -1
  2. package/dist/assets/{CronManager-Bq2lOMbZ.js → CronManager-DCmtUDzd.js} +1 -1
  3. package/dist/assets/{DashboardPage-V77HIt8S.js → DashboardPage-CYELiWmp.js} +1 -1
  4. package/dist/assets/{IntegrationsPage-C3aW9cDI.js → IntegrationsPage-BR2Z-pCf.js} +1 -1
  5. package/dist/assets/{LinearOAuthSettingsPage-_eAbciJN.js → LinearOAuthSettingsPage-InO6EwYe.js} +1 -1
  6. package/dist/assets/{LinearSettingsPage-DDHFHt7j.js → LinearSettingsPage-D8G4AAY3.js} +1 -1
  7. package/dist/assets/{MagicUIDashboard-Cl18AKjV.js → MagicUIDashboard-F0yTxNmY.js} +1 -1
  8. package/dist/assets/{MagicUIView-C0aiMTet.js → MagicUIView-1wNBb6FU.js} +1 -1
  9. package/dist/assets/{Playground-CvoHRMBd.js → Playground-CZoI80Za.js} +1 -1
  10. package/dist/assets/{PromptsPage-CzTs1_Se.js → PromptsPage-COKY3vTB.js} +1 -1
  11. package/dist/assets/{RunsPage-B7xru-Xy.js → RunsPage-uiW4-c8p.js} +1 -1
  12. package/dist/assets/{SandboxManager-C3tFkiWz.js → SandboxManager-Doc50lAB.js} +1 -1
  13. package/dist/assets/{SettingsPage-BZ2fBrHD.js → SettingsPage-B8tXQ_Xt.js} +1 -1
  14. package/dist/assets/{TailscalePage-DFRiFKcb.js → TailscalePage-TH3Seutc.js} +1 -1
  15. package/dist/assets/index-DduNX1Gc.js +142 -0
  16. package/dist/assets/{sw-register-Dc72vh_Y.js → sw-register-C-YDyat6.js} +1 -1
  17. package/dist/index.html +1 -1
  18. package/dist/sw.js +1 -1
  19. package/package.json +1 -1
  20. package/server/cli-launcher.ts +2 -0
  21. package/server/routes.test.ts +23 -0
  22. package/server/routes.ts +1 -0
  23. package/server/session-store.ts +2 -0
  24. package/server/settings-manager.ts +1 -0
  25. package/server/ws-bridge-persist.test.ts +41 -0
  26. package/server/ws-bridge-persist.ts +17 -0
  27. package/server/ws-bridge-types.ts +6 -0
  28. package/server/ws-bridge.test.ts +122 -0
  29. package/server/ws-bridge.ts +11 -0
  30. package/dist/assets/index-C7nK12tB.js +0 -142
@@ -1 +1 @@
1
- import{_ as u}from"./index-C7nK12tB.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")}});
1
+ import{_ as u}from"./index-DduNX1Gc.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-C7nK12tB.js"></script>
14
+ <script type="module" crossorigin src="/assets/index-DduNX1Gc.js"></script>
15
15
  <link rel="stylesheet" crossorigin href="/assets/index-OAVDFR0O.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":"54558a709a65caff714f71ea6125911d","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-Dc72vh_Y.js"},{"revision":null,"url":"assets/index-OAVDFR0O.css"},{"revision":null,"url":"assets/index-C7nK12tB.js"},{"revision":null,"url":"assets/TailscalePage-DFRiFKcb.js"},{"revision":null,"url":"assets/SettingsPage-BZ2fBrHD.js"},{"revision":null,"url":"assets/SandboxManager-C3tFkiWz.js"},{"revision":null,"url":"assets/RunsPage-B7xru-Xy.js"},{"revision":null,"url":"assets/PromptsPage-CzTs1_Se.js"},{"revision":null,"url":"assets/Playground-CvoHRMBd.js"},{"revision":null,"url":"assets/MagicUIView-C0aiMTet.js"},{"revision":null,"url":"assets/MagicUIDashboard-Cl18AKjV.js"},{"revision":null,"url":"assets/LinearSettingsPage-DDHFHt7j.js"},{"revision":null,"url":"assets/LinearOAuthSettingsPage-_eAbciJN.js"},{"revision":null,"url":"assets/IntegrationsPage-C3aW9cDI.js"},{"revision":null,"url":"assets/DashboardPage-V77HIt8S.js"},{"revision":null,"url":"assets/CronManager-Bq2lOMbZ.js"},{"revision":null,"url":"assets/AgentsPage-Byq8wvdn.js"}]);ne();T(new oe(re("index.html"),{denylist:[/^\/api/,/^\/ws/]}));
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":"9af5baa1120cc76be1900b52951a904e","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-C-YDyat6.js"},{"revision":null,"url":"assets/index-OAVDFR0O.css"},{"revision":null,"url":"assets/index-DduNX1Gc.js"},{"revision":null,"url":"assets/TailscalePage-TH3Seutc.js"},{"revision":null,"url":"assets/SettingsPage-B8tXQ_Xt.js"},{"revision":null,"url":"assets/SandboxManager-Doc50lAB.js"},{"revision":null,"url":"assets/RunsPage-uiW4-c8p.js"},{"revision":null,"url":"assets/PromptsPage-COKY3vTB.js"},{"revision":null,"url":"assets/Playground-CZoI80Za.js"},{"revision":null,"url":"assets/MagicUIView-1wNBb6FU.js"},{"revision":null,"url":"assets/MagicUIDashboard-F0yTxNmY.js"},{"revision":null,"url":"assets/LinearSettingsPage-D8G4AAY3.js"},{"revision":null,"url":"assets/LinearOAuthSettingsPage-InO6EwYe.js"},{"revision":null,"url":"assets/IntegrationsPage-BR2Z-pCf.js"},{"revision":null,"url":"assets/DashboardPage-CYELiWmp.js"},{"revision":null,"url":"assets/CronManager-DCmtUDzd.js"},{"revision":null,"url":"assets/AgentsPage-DuPncpk1.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.124.1",
3
+ "version": "0.126.0",
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",
@@ -111,6 +111,8 @@ export interface SdkSessionInfo {
111
111
  totalLinesAdded?: number;
112
112
  /** Total lines removed (enriched by REST API) */
113
113
  totalLinesRemoved?: number;
114
+ /** Epoch ms of the last user_message (enriched by REST API from the bridge) */
115
+ lastUserMessageAt?: number;
114
116
  /** Whether internet/web search is enabled for Codex sessions */
115
117
  codexInternetAccess?: boolean;
116
118
  /** Sandbox mode selected for Codex sessions */
@@ -320,6 +320,7 @@ function createMockBridge() {
320
320
  closeSession: vi.fn(),
321
321
  getSession: vi.fn(() => null),
322
322
  getAllSessions: vi.fn(() => []),
323
+ getLastUserMessageAt: vi.fn(() => undefined),
323
324
  getCodexRateLimits: vi.fn(() => null),
324
325
  markContainerized: vi.fn(),
325
326
  prePopulateCommands: vi.fn(),
@@ -615,6 +616,28 @@ describe("GET /api/sessions", () => {
615
616
  });
616
617
  });
617
618
 
619
+ it("includes each session's lastUserMessageAt from the bridge", async () => {
620
+ // The sidebar sorts by the time of the last user message, which only the
621
+ // bridge knows. Sessions that never received a message have no value (the
622
+ // key is dropped from the JSON), so the client falls back to createdAt.
623
+ const sessions = [
624
+ { sessionId: "s1", state: "running", cwd: "/a", createdAt: 1000 },
625
+ { sessionId: "s2", state: "running", cwd: "/b", createdAt: 2000 },
626
+ ];
627
+ launcher.listSessions.mockReturnValue(sessions);
628
+ vi.mocked(sessionNames.getAllNames).mockReturnValue({});
629
+ bridge.getLastUserMessageAt.mockImplementation((id: string) => (id === "s1" ? 5000 : undefined));
630
+
631
+ const res = await app.request("/api/sessions", { method: "GET" });
632
+
633
+ expect(res.status).toBe(200);
634
+ const json = await res.json();
635
+ expect(json[0].lastUserMessageAt).toBe(5000);
636
+ expect(json[1]).not.toHaveProperty("lastUserMessageAt");
637
+ expect(bridge.getLastUserMessageAt).toHaveBeenCalledWith("s1");
638
+ expect(bridge.getLastUserMessageAt).toHaveBeenCalledWith("s2");
639
+ });
640
+
618
641
  it("prefers bridge cwd over launcher cwd when available", async () => {
619
642
  const sessions = [
620
643
  { sessionId: "s1", state: "running", cwd: "/workspace" },
package/server/routes.ts CHANGED
@@ -252,6 +252,7 @@ export function createRoutes(
252
252
  gitBehind: bridge?.git_behind || 0,
253
253
  totalLinesAdded: bridge?.total_lines_added || 0,
254
254
  totalLinesRemoved: bridge?.total_lines_removed || 0,
255
+ lastUserMessageAt: wsBridge.getLastUserMessageAt(s.sessionId),
255
256
  };
256
257
  });
257
258
  return c.json(enriched);
@@ -21,6 +21,8 @@ export interface PersistedSession {
21
21
  lastAckSeq?: number;
22
22
  processedClientMessageIds?: string[];
23
23
  archived?: boolean;
24
+ /** Epoch ms of the last user_message (see Session.lastUserMessageAt). */
25
+ lastUserMessageAt?: number;
24
26
  }
25
27
 
26
28
  // ─── Store ──────────────────────────────────────────────────────────────────
@@ -16,6 +16,7 @@ export const DEFAULT_ANTHROPIC_MODEL = "claude-opus-5-5";
16
16
  export const DEFAULT_DASHBOARD_MODEL = "claude-haiku-4-5";
17
17
  export const DASHBOARD_MODEL_OPTIONS = [
18
18
  "claude-haiku-4-5",
19
+ "claude-haiku-5-5",
19
20
  "claude-sonnet-4-6",
20
21
  "claude-sonnet-5",
21
22
  ] as const;
@@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
2
  import {
3
3
  appendAndPersist,
4
4
  appendHistory,
5
+ findLastUserMessageAt,
5
6
  persistSession,
6
7
  serializeForStore,
7
8
  MESSAGE_HISTORY_LIMIT,
@@ -216,6 +217,15 @@ describe("appendAndPersist", () => {
216
217
  // ─── serializeForStore ────────────────────────────────────────────────────────
217
218
 
218
219
  describe("serializeForStore", () => {
220
+ it("includes lastUserMessageAt so sidebar ordering survives restarts", () => {
221
+ const session = makeSession({ lastUserMessageAt: 12345 });
222
+ expect(serializeForStore(session).lastUserMessageAt).toBe(12345);
223
+ });
224
+
225
+ it("leaves lastUserMessageAt undefined when no message was ever sent", () => {
226
+ expect(serializeForStore(makeSession()).lastUserMessageAt).toBeUndefined();
227
+ });
228
+
219
229
  it("converts pendingPermissions Map to array of entries", () => {
220
230
  const session = makeSession();
221
231
  session.pendingPermissions.set("req-1", {
@@ -294,3 +304,34 @@ describe("serializeForStore", () => {
294
304
  expect(first).toBe(second);
295
305
  });
296
306
  });
307
+
308
+ // ─── findLastUserMessageAt ───────────────────────────────────────────────────
309
+
310
+ describe("findLastUserMessageAt", () => {
311
+ // Backfills Session.lastUserMessageAt for session files persisted before the
312
+ // field existed; it must pick the NEWEST user message, ignoring other types.
313
+ it("returns the timestamp of the newest user_message", () => {
314
+ const history: BrowserIncomingMessage[] = [
315
+ { type: "user_message", content: "a", timestamp: 100 },
316
+ makeAssistantMsg("m1"),
317
+ { type: "user_message", content: "b", timestamp: 300 },
318
+ makeAssistantMsg("m2"),
319
+ ];
320
+ expect(findLastUserMessageAt(history)).toBe(300);
321
+ });
322
+
323
+ it("returns undefined when the history has no user messages", () => {
324
+ expect(findLastUserMessageAt([])).toBeUndefined();
325
+ expect(findLastUserMessageAt([makeAssistantMsg("m1")])).toBeUndefined();
326
+ });
327
+
328
+ it("skips user messages without a usable timestamp", () => {
329
+ // Defensive: malformed/legacy entries with a zero timestamp must not win
330
+ // over an older valid one.
331
+ const history: BrowserIncomingMessage[] = [
332
+ { type: "user_message", content: "valid", timestamp: 500 },
333
+ { type: "user_message", content: "broken", timestamp: 0 },
334
+ ];
335
+ expect(findLastUserMessageAt(history)).toBe(500);
336
+ });
337
+ });
@@ -62,5 +62,22 @@ export function serializeForStore(session: Session): PersistedSession {
62
62
  nextEventSeq: session.nextEventSeq,
63
63
  lastAckSeq: session.lastAckSeq,
64
64
  processedClientMessageIds: session.processedClientMessageIds,
65
+ lastUserMessageAt: session.lastUserMessageAt,
65
66
  };
66
67
  }
68
+
69
+ /**
70
+ * Timestamp of the newest user_message in a history, or undefined if there is
71
+ * none. Used to backfill Session.lastUserMessageAt for sessions persisted
72
+ * before that field existed (history is capped, so very old sessions whose
73
+ * last prompt scrolled out simply fall back to createdAt on the client).
74
+ */
75
+ export function findLastUserMessageAt(history: BrowserIncomingMessage[]): number | undefined {
76
+ for (let i = history.length - 1; i >= 0; i--) {
77
+ const msg = history[i];
78
+ if (msg.type === "user_message" && typeof msg.timestamp === "number" && msg.timestamp > 0) {
79
+ return msg.timestamp;
80
+ }
81
+ }
82
+ return undefined;
83
+ }
@@ -57,6 +57,12 @@ export interface Session {
57
57
  processedClientMessageIdSet: Set<string>;
58
58
  /** Timestamp of last non-keepalive CLI message (for idle detection) */
59
59
  lastCliActivityTs: number;
60
+ /**
61
+ * Epoch ms of the most recent user_message routed into this session (typed by
62
+ * a human or injected by cron/agents). Drives the sidebar's "recently used
63
+ * first" ordering. Persisted; undefined until the first message is sent.
64
+ */
65
+ lastUserMessageAt?: number;
60
66
  /** Formal session state machine tracking phase and validating transitions. */
61
67
  stateMachine: SessionStateMachine;
62
68
  /** Cleanup function for state machine transition listener — call on session teardown. */
@@ -2422,6 +2422,45 @@ describe("Browser message routing", () => {
2422
2422
  }
2423
2423
  });
2424
2424
 
2425
+ it("user_message: records lastUserMessageAt for sidebar ordering", () => {
2426
+ // The sidebar orders sessions by the last time a user sent a message, so
2427
+ // routing a user_message must stamp the session (and expose it via
2428
+ // getLastUserMessageAt, which GET /api/sessions reads).
2429
+ const session = bridge.getSession("s1")!;
2430
+ expect(bridge.getLastUserMessageAt("s1")).toBeUndefined();
2431
+
2432
+ const before = Date.now();
2433
+ bridge.handleBrowserMessage(browser, JSON.stringify({
2434
+ type: "user_message",
2435
+ content: "bump me to the top",
2436
+ }));
2437
+ const after = Date.now();
2438
+
2439
+ expect(session.lastUserMessageAt).toBeGreaterThanOrEqual(before);
2440
+ expect(session.lastUserMessageAt).toBeLessThanOrEqual(after);
2441
+ expect(bridge.getLastUserMessageAt("s1")).toBe(session.lastUserMessageAt);
2442
+ // Same timestamp as the history entry, so restore/backfill stays consistent
2443
+ const entry = session.messageHistory[session.messageHistory.length - 1];
2444
+ expect(entry.type === "user_message" && entry.timestamp).toBe(session.lastUserMessageAt);
2445
+ });
2446
+
2447
+ it("user_message: rejected messages do not bump lastUserMessageAt", () => {
2448
+ // An attachment that fails validation is dropped before reaching the
2449
+ // agent, so it must not reorder the sidebar either.
2450
+ bridge.handleBrowserMessage(browser, JSON.stringify({
2451
+ type: "user_message",
2452
+ content: "bad attachment",
2453
+ // Invalid base64 payload → validateAttachments rejects the whole message
2454
+ attachments: [{ name: "x.bin", media_type: "application/octet-stream", data: "not base64!", size: 8 }],
2455
+ }));
2456
+ expect(cli.send).not.toHaveBeenCalled();
2457
+ expect(bridge.getLastUserMessageAt("s1")).toBeUndefined();
2458
+ });
2459
+
2460
+ it("getLastUserMessageAt: returns undefined for unknown sessions", () => {
2461
+ expect(bridge.getLastUserMessageAt("does-not-exist")).toBeUndefined();
2462
+ });
2463
+
2425
2464
  it("user_message: queues when CLI not connected", () => {
2426
2465
  // Close CLI
2427
2466
  bridge.handleCLIClose(cli);
@@ -3018,6 +3057,89 @@ describe("Persistence", () => {
3018
3057
  expect(session!.processedClientMessageIdSet.has("restored-client-1")).toBe(true);
3019
3058
  });
3020
3059
 
3060
+ it("restoreFromDisk: keeps the persisted lastUserMessageAt", () => {
3061
+ // The persisted field wins over the history scan: history is capped, so the
3062
+ // stored value is the only reliable record once old prompts scroll out.
3063
+ store.saveSync({
3064
+ id: "persisted-lum",
3065
+ state: {
3066
+ session_id: "persisted-lum",
3067
+ model: "claude-sonnet-4-6",
3068
+ cwd: "/saved",
3069
+ tools: [],
3070
+ permissionMode: "default",
3071
+ claude_code_version: "1.0",
3072
+ mcp_servers: [],
3073
+ agents: [],
3074
+ slash_commands: [],
3075
+ skills: [],
3076
+ total_cost_usd: 0,
3077
+ num_turns: 2,
3078
+ context_used_percent: 0,
3079
+ is_compacting: false,
3080
+ git_branch: "",
3081
+ is_worktree: false,
3082
+ is_containerized: false,
3083
+ repo_root: "",
3084
+ git_ahead: 0,
3085
+ git_behind: 0,
3086
+ total_lines_added: 0,
3087
+ total_lines_removed: 0,
3088
+ },
3089
+ messageHistory: [
3090
+ { type: "user_message", content: "older", timestamp: 1000 },
3091
+ ],
3092
+ pendingMessages: [],
3093
+ pendingPermissions: [],
3094
+ lastUserMessageAt: 9000,
3095
+ });
3096
+
3097
+ bridge.restoreFromDisk();
3098
+ expect(bridge.getLastUserMessageAt("persisted-lum")).toBe(9000);
3099
+ });
3100
+
3101
+ it("restoreFromDisk: backfills lastUserMessageAt from history for older session files", () => {
3102
+ // Session files written before the field existed have no lastUserMessageAt;
3103
+ // the newest user_message in the history is the best available answer.
3104
+ store.saveSync({
3105
+ id: "persisted-legacy",
3106
+ state: {
3107
+ session_id: "persisted-legacy",
3108
+ model: "claude-sonnet-4-6",
3109
+ cwd: "/saved",
3110
+ tools: [],
3111
+ permissionMode: "default",
3112
+ claude_code_version: "1.0",
3113
+ mcp_servers: [],
3114
+ agents: [],
3115
+ slash_commands: [],
3116
+ skills: [],
3117
+ total_cost_usd: 0,
3118
+ num_turns: 2,
3119
+ context_used_percent: 0,
3120
+ is_compacting: false,
3121
+ git_branch: "",
3122
+ is_worktree: false,
3123
+ is_containerized: false,
3124
+ repo_root: "",
3125
+ git_ahead: 0,
3126
+ git_behind: 0,
3127
+ total_lines_added: 0,
3128
+ total_lines_removed: 0,
3129
+ },
3130
+ messageHistory: [
3131
+ { type: "user_message", content: "first", timestamp: 1000 },
3132
+ { type: "user_message", content: "second", timestamp: 2000 },
3133
+ { type: "result", data: { type: "result", subtype: "success" } } as any,
3134
+ ],
3135
+ pendingMessages: [],
3136
+ pendingPermissions: [],
3137
+ });
3138
+
3139
+ bridge.restoreFromDisk();
3140
+ expect(bridge.getLastUserMessageAt("persisted-legacy")).toBe(2000);
3141
+ });
3142
+
3021
3143
  it("restoreFromDisk: does not overwrite live sessions", () => {
3022
3144
  // Create a live session first
3023
3145
  const liveSession = bridge.getOrCreateSession("live-1");
@@ -33,6 +33,7 @@ import {
33
33
  } from "./ws-bridge-browser-ingest.js";
34
34
  import {
35
35
  appendHistory as appendHistoryFn,
36
+ findLastUserMessageAt,
36
37
  persistSession as persistSessionFn,
37
38
  } from "./ws-bridge-persist.js";
38
39
  import {
@@ -216,6 +217,10 @@ export class WsBridge {
216
217
  Array.isArray(p.processedClientMessageIds) ? p.processedClientMessageIds : [],
217
218
  ),
218
219
  lastCliActivityTs: Date.now(),
220
+ lastUserMessageAt:
221
+ typeof p.lastUserMessageAt === "number"
222
+ ? p.lastUserMessageAt
223
+ : findLastUserMessageAt(p.messageHistory || []),
219
224
  stateMachine: new SessionStateMachine(p.id, "terminated"),
220
225
  };
221
226
  session.state.backend_type = session.backendType;
@@ -374,6 +379,11 @@ export class WsBridge {
374
379
  return Array.from(this.sessions.values()).map((s) => s.state);
375
380
  }
376
381
 
382
+ /** Epoch ms of the last user_message sent to a session (undefined if none yet). */
383
+ getLastUserMessageAt(sessionId: string): number | undefined {
384
+ return this.sessions.get(sessionId)?.lastUserMessageAt;
385
+ }
386
+
377
387
  /** Return per-session memory stats for diagnostics. */
378
388
  getSessionMemoryStats(): { id: string; browsers: number; historyLen: number; eventBufferLen: number; pendingMsgs: number }[] {
379
389
  return Array.from(this.sessions.values()).map((s) => ({
@@ -1426,6 +1436,7 @@ export class WsBridge {
1426
1436
  attachments: msg.attachments,
1427
1437
  };
1428
1438
  this.appendHistory(session, userMessage);
1439
+ session.lastUserMessageAt = ts;
1429
1440
  const transitioned = session.stateMachine.transition("streaming", "user_message");
1430
1441
  if (!transitioned) {
1431
1442
  // Session not ready yet (e.g. still initializing). Log a warning so