@hellcoder/companion 0.107.0 → 0.107.1-preview.20260719040931.f983b4d

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 (40) hide show
  1. package/dist/assets/{AgentsPage-t4s7ZO2P.js → AgentsPage-DI5hdCnz.js} +5 -5
  2. package/dist/assets/{CronManager-C_0weC_z.js → CronManager-yX8i4C_S.js} +1 -1
  3. package/dist/assets/DashboardPage-D1nc0wHf.js +1 -0
  4. package/dist/assets/{IntegrationsPage-Gte53t7Z.js → IntegrationsPage-iSfvR9vX.js} +1 -1
  5. package/dist/assets/{LinearOAuthSettingsPage-DHJ73rFh.js → LinearOAuthSettingsPage-omExXsGc.js} +1 -1
  6. package/dist/assets/{LinearSettingsPage-D7ZHYkum.js → LinearSettingsPage-C4oBf1iO.js} +1 -1
  7. package/dist/assets/{Playground-DBWsv4R6.js → Playground-CjiZI9QY.js} +1 -1
  8. package/dist/assets/{PromptsPage-Cnjs3hD9.js → PromptsPage-CU6z49U6.js} +1 -1
  9. package/dist/assets/{RunsPage-B5PsNevy.js → RunsPage-mcU7OUoZ.js} +1 -1
  10. package/dist/assets/{SandboxManager-DpFA1it7.js → SandboxManager-BkYS-dK5.js} +1 -1
  11. package/dist/assets/SettingsPage-CyHMmTBB.js +1 -0
  12. package/dist/assets/{TailscalePage-CMG1g7e2.js → TailscalePage-CRyiyMcJ.js} +1 -1
  13. package/dist/assets/index-DMoY-FDD.css +1 -0
  14. package/dist/assets/index-DOle27vf.js +136 -0
  15. package/dist/assets/{sw-register-CS-6kwSW.js → sw-register-Dqdfho7x.js} +1 -1
  16. package/dist/index.html +2 -2
  17. package/dist/sw.js +1 -1
  18. package/package.json +1 -1
  19. package/server/auto-namer.test.ts +16 -0
  20. package/server/claude-cli-runner.ts +92 -0
  21. package/server/dashboard-scheduler.test.ts +73 -0
  22. package/server/dashboard-scheduler.ts +46 -0
  23. package/server/dashboard-store.test.ts +101 -0
  24. package/server/dashboard-store.ts +121 -0
  25. package/server/dashboard-summarizer.test.ts +216 -0
  26. package/server/dashboard-summarizer.ts +290 -0
  27. package/server/dashboard-types.ts +70 -0
  28. package/server/index.ts +4 -0
  29. package/server/linear-connections.test.ts +16 -0
  30. package/server/routes/dashboard-routes.test.ts +139 -0
  31. package/server/routes/dashboard-routes.ts +101 -0
  32. package/server/routes/settings-routes.ts +47 -1
  33. package/server/routes.test.ts +112 -0
  34. package/server/routes.ts +4 -0
  35. package/server/settings-manager.test.ts +12 -0
  36. package/server/settings-manager.ts +45 -1
  37. package/server/ws-bridge-codex.test.ts +24 -0
  38. package/dist/assets/SettingsPage-v8ndyXOz.js +0 -1
  39. package/dist/assets/index-Bp3e_166.js +0 -136
  40. package/dist/assets/index-D4AeOLki.css +0 -1
@@ -1 +1 @@
1
- import{_ as u}from"./index-Bp3e_166.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-DOle27vf.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,8 +11,8 @@
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-Bp3e_166.js"></script>
15
- <link rel="stylesheet" crossorigin href="/assets/index-D4AeOLki.css">
14
+ <script type="module" crossorigin src="/assets/index-DOle27vf.js"></script>
15
+ <link rel="stylesheet" crossorigin href="/assets/index-DMoY-FDD.css">
16
16
  </head>
17
17
  <body class="bg-cc-bg text-cc-fg">
18
18
  <div id="root"></div>
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":"38ea29793161da3e038ec48bcc426eb4","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-CS-6kwSW.js"},{"revision":null,"url":"assets/index-D4AeOLki.css"},{"revision":null,"url":"assets/index-Bp3e_166.js"},{"revision":null,"url":"assets/TailscalePage-CMG1g7e2.js"},{"revision":null,"url":"assets/SettingsPage-v8ndyXOz.js"},{"revision":null,"url":"assets/SandboxManager-DpFA1it7.js"},{"revision":null,"url":"assets/RunsPage-B5PsNevy.js"},{"revision":null,"url":"assets/PromptsPage-Cnjs3hD9.js"},{"revision":null,"url":"assets/Playground-DBWsv4R6.js"},{"revision":null,"url":"assets/LinearSettingsPage-D7ZHYkum.js"},{"revision":null,"url":"assets/LinearOAuthSettingsPage-DHJ73rFh.js"},{"revision":null,"url":"assets/IntegrationsPage-Gte53t7Z.js"},{"revision":null,"url":"assets/CronManager-C_0weC_z.js"},{"revision":null,"url":"assets/AgentsPage-t4s7ZO2P.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":"a3c00e29237a393a58718858b6fbf879","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-Dqdfho7x.js"},{"revision":null,"url":"assets/index-DOle27vf.js"},{"revision":null,"url":"assets/index-DMoY-FDD.css"},{"revision":null,"url":"assets/TailscalePage-CRyiyMcJ.js"},{"revision":null,"url":"assets/SettingsPage-CyHMmTBB.js"},{"revision":null,"url":"assets/SandboxManager-BkYS-dK5.js"},{"revision":null,"url":"assets/RunsPage-mcU7OUoZ.js"},{"revision":null,"url":"assets/PromptsPage-CU6z49U6.js"},{"revision":null,"url":"assets/Playground-CjiZI9QY.js"},{"revision":null,"url":"assets/LinearSettingsPage-C4oBf1iO.js"},{"revision":null,"url":"assets/LinearOAuthSettingsPage-omExXsGc.js"},{"revision":null,"url":"assets/IntegrationsPage-iSfvR9vX.js"},{"revision":null,"url":"assets/DashboardPage-D1nc0wHf.js"},{"revision":null,"url":"assets/CronManager-yX8i4C_S.js"},{"revision":null,"url":"assets/AgentsPage-DI5hdCnz.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.107.0",
3
+ "version": "0.107.1-preview.20260719040931.f983b4d",
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",
@@ -34,6 +34,10 @@ beforeEach(() => {
34
34
  aiValidationEnabled: false,
35
35
  aiValidationAutoApprove: true,
36
36
  aiValidationAutoDeny: false,
37
+ dashboardEnabled: false,
38
+ dashboardModel: "claude-haiku-4-5",
39
+ dashboardRunHour: 3,
40
+ dashboardMaxSessionsPerRun: 30,
37
41
  publicUrl: "",
38
42
  updateChannel: "stable",
39
43
  dockerAutoUpdate: false,
@@ -78,6 +82,10 @@ describe("generateSessionTitle", () => {
78
82
  aiValidationEnabled: false,
79
83
  aiValidationAutoApprove: true,
80
84
  aiValidationAutoDeny: false,
85
+ dashboardEnabled: false,
86
+ dashboardModel: "claude-haiku-4-5",
87
+ dashboardRunHour: 3,
88
+ dashboardMaxSessionsPerRun: 30,
81
89
  publicUrl: "",
82
90
  updateChannel: "stable",
83
91
  dockerAutoUpdate: false,
@@ -129,6 +137,10 @@ describe("generateSessionTitle", () => {
129
137
  aiValidationEnabled: false,
130
138
  aiValidationAutoApprove: true,
131
139
  aiValidationAutoDeny: false,
140
+ dashboardEnabled: false,
141
+ dashboardModel: "claude-haiku-4-5",
142
+ dashboardRunHour: 3,
143
+ dashboardMaxSessionsPerRun: 30,
132
144
  publicUrl: "",
133
145
  updateChannel: "stable",
134
146
  dockerAutoUpdate: false,
@@ -209,6 +221,10 @@ describe("generateSessionTitle", () => {
209
221
  aiValidationEnabled: false,
210
222
  aiValidationAutoApprove: true,
211
223
  aiValidationAutoDeny: false,
224
+ dashboardEnabled: false,
225
+ dashboardModel: "claude-haiku-4-5",
226
+ dashboardRunHour: 3,
227
+ dashboardMaxSessionsPerRun: 30,
212
228
  publicUrl: "",
213
229
  updateChannel: "stable",
214
230
  dockerAutoUpdate: false,
@@ -0,0 +1,92 @@
1
+ import { tmpdir } from "node:os";
2
+ import { getEnrichedPath, resolveBinary } from "./path-resolver.js";
3
+ import { getSettings } from "./settings-manager.js";
4
+
5
+ // ─── Headless one-shot Claude CLI runner ────────────────────────────────────
6
+ // Runs `claude --print` the same way cli-launcher spawns interactive sessions:
7
+ // same binary resolution, same enriched PATH, same CLAUDE_CODE_OAUTH_TOKEN
8
+ // injection from settings. This means background features (like the dashboard
9
+ // summarizer) authenticate with the user's normal Claude Code login instead of
10
+ // needing a separate Anthropic API key — but without creating a visible
11
+ // companion session per invocation.
12
+
13
+ export function isClaudeCliAvailable(): boolean {
14
+ return resolveBinary("claude") !== null;
15
+ }
16
+
17
+ export interface RunClaudePromptOptions {
18
+ prompt: string;
19
+ /** Model id or alias passed to --model. Omitted = CLI default. */
20
+ model?: string;
21
+ timeoutMs?: number;
22
+ }
23
+
24
+ const DEFAULT_TIMEOUT_MS = 120_000;
25
+
26
+ /**
27
+ * Runs one prompt through `claude --print` and returns the plain-text answer,
28
+ * or null on any failure (missing binary, non-zero exit, timeout).
29
+ */
30
+ export async function runClaudePrompt(options: RunClaudePromptOptions): Promise<string | null> {
31
+ const binary = resolveBinary("claude");
32
+ if (!binary) {
33
+ console.warn("[claude-cli-runner] Claude CLI not found in PATH");
34
+ return null;
35
+ }
36
+
37
+ const args = [binary, "--print", "--output-format", "text"];
38
+ if (options.model?.trim()) {
39
+ args.push("--model", options.model.trim());
40
+ }
41
+
42
+ const settings = getSettings();
43
+ const env: Record<string, string | undefined> = {
44
+ ...process.env,
45
+ // Same hygiene as cli-launcher: never let a parent Claude session leak in.
46
+ CLAUDECODE: undefined,
47
+ PATH: getEnrichedPath(),
48
+ };
49
+ if (settings.claudeCodeOAuthToken && !process.env.CLAUDE_CODE_OAUTH_TOKEN) {
50
+ env.CLAUDE_CODE_OAUTH_TOKEN = settings.claudeCodeOAuthToken;
51
+ }
52
+
53
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
54
+ let proc: ReturnType<typeof Bun.spawn> | null = null;
55
+ let timedOut = false;
56
+ const timer = setTimeout(() => {
57
+ timedOut = true;
58
+ proc?.kill();
59
+ }, timeoutMs);
60
+
61
+ try {
62
+ proc = Bun.spawn(args, {
63
+ // Neutral cwd so the CLI doesn't pull a project's CLAUDE.md into context.
64
+ cwd: tmpdir(),
65
+ env,
66
+ stdin: new TextEncoder().encode(options.prompt),
67
+ stdout: "pipe",
68
+ stderr: "pipe",
69
+ });
70
+
71
+ const [exitCode, stdout, stderr] = await Promise.all([
72
+ proc.exited,
73
+ new Response(proc.stdout as ReadableStream).text(),
74
+ new Response(proc.stderr as ReadableStream).text(),
75
+ ]);
76
+
77
+ if (timedOut) {
78
+ console.warn(`[claude-cli-runner] Prompt timed out after ${timeoutMs}ms`);
79
+ return null;
80
+ }
81
+ if (exitCode !== 0) {
82
+ console.warn(`[claude-cli-runner] claude --print exited with ${exitCode}: ${stderr.slice(0, 500)}`);
83
+ return null;
84
+ }
85
+ return stdout;
86
+ } catch (err) {
87
+ console.warn("[claude-cli-runner] Failed to run claude --print:", err);
88
+ return null;
89
+ } finally {
90
+ clearTimeout(timer);
91
+ }
92
+ }
@@ -0,0 +1,73 @@
1
+ import { describe, expect, it, beforeEach, afterEach, vi } from "vitest";
2
+ import { mkdtempSync, rmSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { tmpdir } from "node:os";
5
+ import { _resetForTest, updateSettings } from "./settings-manager.js";
6
+
7
+ // The hourly tick reads settings at fire time, so the gate logic
8
+ // (opt-in flag, configured hour, CLI availability) is what needs coverage.
9
+ // Both the CLI check and the summarizer are mocked — no real runs here.
10
+
11
+ const cliMock = { available: true };
12
+ const runMock = vi.fn();
13
+
14
+ vi.mock("./claude-cli-runner.js", () => ({
15
+ isClaudeCliAvailable: () => cliMock.available,
16
+ runClaudePrompt: vi.fn().mockResolvedValue(null),
17
+ }));
18
+
19
+ vi.mock("./dashboard-summarizer.js", () => ({
20
+ isDashboardRunActive: () => false,
21
+ runDashboardUpdate: (...args: unknown[]) => runMock(...args),
22
+ }));
23
+
24
+ import { runScheduledDashboardUpdateIfDue } from "./dashboard-scheduler.js";
25
+
26
+ describe("runScheduledDashboardUpdateIfDue", () => {
27
+ let tempDir: string;
28
+
29
+ beforeEach(() => {
30
+ tempDir = mkdtempSync(join(tmpdir(), "dashboard-scheduler-test-"));
31
+ _resetForTest(join(tempDir, "settings.json"));
32
+ cliMock.available = true;
33
+ runMock.mockReset().mockResolvedValue({
34
+ sessionsProcessed: 0,
35
+ sessionsSkipped: 0,
36
+ sessionsFailed: 0,
37
+ });
38
+ });
39
+
40
+ afterEach(() => {
41
+ _resetForTest();
42
+ rmSync(tempDir, { recursive: true, force: true });
43
+ });
44
+
45
+ it("does nothing while the dashboard is not opted in", async () => {
46
+ updateSettings({ dashboardRunHour: 3 });
47
+ const ran = await runScheduledDashboardUpdateIfDue(new Date(2026, 6, 19, 3, 0, 0));
48
+ expect(ran).toBe(false);
49
+ expect(runMock).not.toHaveBeenCalled();
50
+ });
51
+
52
+ it("does nothing outside the configured hour", async () => {
53
+ updateSettings({ dashboardEnabled: true, dashboardRunHour: 3 });
54
+ const ran = await runScheduledDashboardUpdateIfDue(new Date(2026, 6, 19, 4, 0, 0));
55
+ expect(ran).toBe(false);
56
+ expect(runMock).not.toHaveBeenCalled();
57
+ });
58
+
59
+ it("skips (without throwing) when the Claude CLI is unavailable", async () => {
60
+ updateSettings({ dashboardEnabled: true, dashboardRunHour: 3 });
61
+ cliMock.available = false;
62
+ const ran = await runScheduledDashboardUpdateIfDue(new Date(2026, 6, 19, 3, 0, 0));
63
+ expect(ran).toBe(false);
64
+ expect(runMock).not.toHaveBeenCalled();
65
+ });
66
+
67
+ it("runs the summarizer when enabled, on the hour, with the CLI present", async () => {
68
+ updateSettings({ dashboardEnabled: true, dashboardRunHour: 3 });
69
+ const ran = await runScheduledDashboardUpdateIfDue(new Date(2026, 6, 19, 3, 0, 0));
70
+ expect(ran).toBe(true);
71
+ expect(runMock).toHaveBeenCalledWith({ trigger: "scheduled" });
72
+ });
73
+ });
@@ -0,0 +1,46 @@
1
+ import { Cron } from "croner";
2
+ import { getSettings } from "./settings-manager.js";
3
+ import { isClaudeCliAvailable } from "./claude-cli-runner.js";
4
+ import { isDashboardRunActive, runDashboardUpdate } from "./dashboard-summarizer.js";
5
+
6
+ // ─── Nightly dashboard scheduler ────────────────────────────────────────────
7
+ // Fires every hour on the hour and runs the summarizer only when the current
8
+ // hour matches the configured dashboardRunHour. Reading the settings at fire
9
+ // time (instead of baking the hour into the cron expression) means changes to
10
+ // the schedule or the opt-in toggle apply without any reschedule plumbing.
11
+
12
+ let job: Cron | null = null;
13
+
14
+ export async function runScheduledDashboardUpdateIfDue(now: Date = new Date()): Promise<boolean> {
15
+ const settings = getSettings();
16
+ if (!settings.dashboardEnabled) return false;
17
+ if (now.getHours() !== settings.dashboardRunHour) return false;
18
+ if (isDashboardRunActive()) return false;
19
+ if (!isClaudeCliAvailable()) {
20
+ console.warn("[dashboard] Nightly update skipped: Claude Code CLI not found");
21
+ return false;
22
+ }
23
+
24
+ try {
25
+ const meta = await runDashboardUpdate({ trigger: "scheduled" });
26
+ console.log(
27
+ `[dashboard] Nightly update finished: ${meta.sessionsProcessed} summarized, `
28
+ + `${meta.sessionsSkipped} unchanged, ${meta.sessionsFailed} failed`,
29
+ );
30
+ } catch (err) {
31
+ console.warn("[dashboard] Nightly update failed:", err);
32
+ }
33
+ return true;
34
+ }
35
+
36
+ export function startDashboardScheduler(): void {
37
+ if (job) return;
38
+ job = new Cron("0 * * * *", { protect: true }, () => {
39
+ void runScheduledDashboardUpdateIfDue();
40
+ });
41
+ }
42
+
43
+ export function stopDashboardScheduler(): void {
44
+ job?.stop();
45
+ job = null;
46
+ }
@@ -0,0 +1,101 @@
1
+ import { describe, expect, it, beforeEach, afterEach } from "vitest";
2
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { tmpdir } from "node:os";
5
+ import { DashboardStore } from "./dashboard-store.js";
6
+ import type { DashboardRunMeta, DashboardSessionSummary } from "./dashboard-types.js";
7
+
8
+ // Validates the JSON-file persistence layer that the dashboard reads from:
9
+ // round-tripping summaries and run metadata, tolerating corrupt files, and
10
+ // rejecting session ids that could escape the storage directory.
11
+
12
+ function makeSummary(overrides: Partial<DashboardSessionSummary> = {}): DashboardSessionSummary {
13
+ return {
14
+ sessionId: "11111111-2222-3333-4444-555555555555",
15
+ cwd: "/root/projects/demo",
16
+ gitBranch: "main",
17
+ slug: "fix-login-bug",
18
+ summary: "Fixed the login redirect loop and added a regression test.",
19
+ status: "completed",
20
+ openItems: [],
21
+ archivable: true,
22
+ transcriptMtimeMs: 1000,
23
+ lastActivityAt: 1000,
24
+ summarizedAt: 2000,
25
+ model: "claude-haiku-4-5",
26
+ ...overrides,
27
+ };
28
+ }
29
+
30
+ describe("DashboardStore", () => {
31
+ let dir: string;
32
+ let store: DashboardStore;
33
+
34
+ beforeEach(() => {
35
+ dir = mkdtempSync(join(tmpdir(), "dashboard-store-test-"));
36
+ store = new DashboardStore(dir);
37
+ });
38
+
39
+ afterEach(() => {
40
+ rmSync(dir, { recursive: true, force: true });
41
+ });
42
+
43
+ it("round-trips a session summary", () => {
44
+ const summary = makeSummary();
45
+ store.saveSummary(summary);
46
+ expect(store.loadSummary(summary.sessionId)).toEqual(summary);
47
+ });
48
+
49
+ it("returns null for a missing summary", () => {
50
+ expect(store.loadSummary("00000000-0000-0000-0000-000000000000")).toBeNull();
51
+ });
52
+
53
+ it("lists all summaries sorted by lastActivityAt desc", () => {
54
+ store.saveSummary(makeSummary({ sessionId: "a-old", lastActivityAt: 100 }));
55
+ store.saveSummary(makeSummary({ sessionId: "b-new", lastActivityAt: 300 }));
56
+ store.saveSummary(makeSummary({ sessionId: "c-mid", lastActivityAt: 200 }));
57
+ expect(store.loadAllSummaries().map((s) => s.sessionId)).toEqual(["b-new", "c-mid", "a-old"]);
58
+ });
59
+
60
+ it("skips corrupt summary files instead of throwing", () => {
61
+ store.saveSummary(makeSummary({ sessionId: "good" }));
62
+ writeFileSync(join(dir, "sessions", "corrupt.json"), "{not json", "utf-8");
63
+ const all = store.loadAllSummaries();
64
+ expect(all).toHaveLength(1);
65
+ expect(all[0].sessionId).toBe("good");
66
+ });
67
+
68
+ it("rejects unsafe session ids (path traversal)", () => {
69
+ // Neither save nor load should touch anything outside sessions/.
70
+ store.saveSummary(makeSummary({ sessionId: "../evil" }));
71
+ expect(store.loadSummary("../evil")).toBeNull();
72
+ expect(store.loadAllSummaries()).toHaveLength(0);
73
+ });
74
+
75
+ it("removes a summary", () => {
76
+ const summary = makeSummary();
77
+ store.saveSummary(summary);
78
+ store.removeSummary(summary.sessionId);
79
+ expect(store.loadSummary(summary.sessionId)).toBeNull();
80
+ });
81
+
82
+ it("round-trips run meta and returns null when absent or invalid", () => {
83
+ expect(store.loadRunMeta()).toBeNull();
84
+
85
+ const meta: DashboardRunMeta = {
86
+ lastRunAt: 1000,
87
+ lastRunCompletedAt: 2000,
88
+ lastRunStatus: "success",
89
+ trigger: "manual",
90
+ model: "claude-haiku-4-5",
91
+ sessionsProcessed: 3,
92
+ sessionsSkipped: 5,
93
+ sessionsFailed: 0,
94
+ };
95
+ store.saveRunMeta(meta);
96
+ expect(store.loadRunMeta()).toEqual(meta);
97
+
98
+ writeFileSync(join(dir, "run-meta.json"), JSON.stringify({ bogus: true }), "utf-8");
99
+ expect(store.loadRunMeta()).toBeNull();
100
+ });
101
+ });
@@ -0,0 +1,121 @@
1
+ import {
2
+ mkdirSync,
3
+ readdirSync,
4
+ readFileSync,
5
+ unlinkSync,
6
+ writeFileSync,
7
+ } from "node:fs";
8
+ import { join } from "node:path";
9
+ import { COMPANION_HOME } from "./paths.js";
10
+ import type { DashboardRunMeta, DashboardSessionSummary } from "./dashboard-types.js";
11
+
12
+ // ─── Dashboard store ────────────────────────────────────────────────────────
13
+ // JSON-file persistence for nightly session summaries, following the same
14
+ // pattern as session-store.ts / cron-store.ts:
15
+ // ~/.companion/dashboard/run-meta.json — last run bookkeeping
16
+ // ~/.companion/dashboard/sessions/<id>.json — one summary per session
17
+
18
+ const DEFAULT_DIR = join(COMPANION_HOME, "dashboard");
19
+
20
+ /** Claude session ids are UUIDs; reject anything that could escape the dir. */
21
+ function isSafeSessionId(sessionId: string): boolean {
22
+ return /^[A-Za-z0-9_-]{1,128}$/.test(sessionId);
23
+ }
24
+
25
+ export class DashboardStore {
26
+ private dir: string;
27
+ private sessionsDir: string;
28
+
29
+ constructor(dir?: string) {
30
+ this.dir = dir || DEFAULT_DIR;
31
+ this.sessionsDir = join(this.dir, "sessions");
32
+ mkdirSync(this.sessionsDir, { recursive: true });
33
+ }
34
+
35
+ private summaryPath(sessionId: string): string {
36
+ return join(this.sessionsDir, `${sessionId}.json`);
37
+ }
38
+
39
+ saveSummary(summary: DashboardSessionSummary): void {
40
+ if (!isSafeSessionId(summary.sessionId)) {
41
+ console.warn(`[dashboard-store] Refusing to persist unsafe session id: ${summary.sessionId}`);
42
+ return;
43
+ }
44
+ try {
45
+ writeFileSync(this.summaryPath(summary.sessionId), JSON.stringify(summary, null, 2), "utf-8");
46
+ } catch (err) {
47
+ console.error(`[dashboard-store] Failed to save summary ${summary.sessionId}:`, err);
48
+ }
49
+ }
50
+
51
+ loadSummary(sessionId: string): DashboardSessionSummary | null {
52
+ if (!isSafeSessionId(sessionId)) return null;
53
+ try {
54
+ const raw = readFileSync(this.summaryPath(sessionId), "utf-8");
55
+ return JSON.parse(raw) as DashboardSessionSummary;
56
+ } catch {
57
+ return null;
58
+ }
59
+ }
60
+
61
+ loadAllSummaries(): DashboardSessionSummary[] {
62
+ const summaries: DashboardSessionSummary[] = [];
63
+ try {
64
+ const files = readdirSync(this.sessionsDir).filter((f) => f.endsWith(".json"));
65
+ for (const file of files) {
66
+ try {
67
+ const raw = readFileSync(join(this.sessionsDir, file), "utf-8");
68
+ const parsed = JSON.parse(raw) as DashboardSessionSummary;
69
+ if (parsed && typeof parsed.sessionId === "string" && typeof parsed.summary === "string") {
70
+ summaries.push(parsed);
71
+ }
72
+ } catch {
73
+ // Skip corrupt files
74
+ }
75
+ }
76
+ } catch {
77
+ // Dir doesn't exist yet
78
+ }
79
+ return summaries.sort((a, b) => b.lastActivityAt - a.lastActivityAt);
80
+ }
81
+
82
+ removeSummary(sessionId: string): void {
83
+ if (!isSafeSessionId(sessionId)) return;
84
+ try {
85
+ unlinkSync(this.summaryPath(sessionId));
86
+ } catch {
87
+ // File may not exist
88
+ }
89
+ }
90
+
91
+ saveRunMeta(meta: DashboardRunMeta): void {
92
+ try {
93
+ writeFileSync(join(this.dir, "run-meta.json"), JSON.stringify(meta, null, 2), "utf-8");
94
+ } catch (err) {
95
+ console.error("[dashboard-store] Failed to save run meta:", err);
96
+ }
97
+ }
98
+
99
+ loadRunMeta(): DashboardRunMeta | null {
100
+ try {
101
+ const raw = readFileSync(join(this.dir, "run-meta.json"), "utf-8");
102
+ const parsed = JSON.parse(raw) as DashboardRunMeta;
103
+ if (typeof parsed?.lastRunAt !== "number") return null;
104
+ return parsed;
105
+ } catch {
106
+ return null;
107
+ }
108
+ }
109
+
110
+ get directory(): string {
111
+ return this.dir;
112
+ }
113
+ }
114
+
115
+ let defaultStore: DashboardStore | null = null;
116
+
117
+ /** Lazily created singleton so importing this module never touches the disk. */
118
+ export function getDashboardStore(): DashboardStore {
119
+ if (!defaultStore) defaultStore = new DashboardStore();
120
+ return defaultStore;
121
+ }