@hellcoder/companion 0.113.2 → 0.113.3

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 (29) hide show
  1. package/dist/assets/{AgentsPage-1K8kPnm0.js → AgentsPage-B2NtxJig.js} +1 -1
  2. package/dist/assets/{CronManager-BZyF07KE.js → CronManager-CES2-E0_.js} +1 -1
  3. package/dist/assets/{DashboardPage-CbI1O6Zk.js → DashboardPage-aFf4uQfU.js} +1 -1
  4. package/dist/assets/{IntegrationsPage-CI70O4xo.js → IntegrationsPage-C_tZ2P0K.js} +1 -1
  5. package/dist/assets/{LinearOAuthSettingsPage-B4FLu5lN.js → LinearOAuthSettingsPage-DUQik5kh.js} +1 -1
  6. package/dist/assets/{LinearSettingsPage-cPsVQjgm.js → LinearSettingsPage-DFh8jfhh.js} +1 -1
  7. package/dist/assets/{Playground-CtT6Yg7z.js → Playground-C4t0-Zia.js} +1 -1
  8. package/dist/assets/{PromptsPage-aPQqBbHT.js → PromptsPage-Ma4gA0R7.js} +1 -1
  9. package/dist/assets/{RunsPage-DN71HYQf.js → RunsPage-jk2yW392.js} +1 -1
  10. package/dist/assets/{SandboxManager-DUH9hgUI.js → SandboxManager-BA6-RlkR.js} +1 -1
  11. package/dist/assets/{SettingsPage-BOzfigwV.js → SettingsPage-BehGv-KV.js} +1 -1
  12. package/dist/assets/{TailscalePage-DyKEeXYp.js → TailscalePage-CZ6V3kc7.js} +1 -1
  13. package/dist/assets/{index-Ck0r9rSX.js → index-B1Oejky6.js} +4 -4
  14. package/dist/assets/{sw-register-U38bnRB2.js → sw-register-B9GK06Mo.js} +1 -1
  15. package/dist/index.html +1 -1
  16. package/dist/sw.js +1 -1
  17. package/package.json +1 -1
  18. package/server/claude-adapter.ts +26 -1
  19. package/server/fs-utils.test.ts +105 -0
  20. package/server/fs-utils.ts +100 -2
  21. package/server/logger.test.ts +69 -6
  22. package/server/logger.ts +49 -6
  23. package/server/proc-diagnostics.test.ts +44 -0
  24. package/server/proc-diagnostics.ts +28 -1
  25. package/server/recorder.test.ts +12 -10
  26. package/server/recorder.ts +30 -7
  27. package/server/session-git-info.ts +34 -19
  28. package/server/ws-bridge.test.ts +34 -10
  29. package/server/ws-bridge.ts +63 -6
@@ -1 +1 @@
1
- import{_ as u}from"./index-Ck0r9rSX.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-B1Oejky6.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-Ck0r9rSX.js"></script>
14
+ <script type="module" crossorigin src="/assets/index-B1Oejky6.js"></script>
15
15
  <link rel="stylesheet" crossorigin href="/assets/index-C86AT0KJ.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":"33285017c4820702bdae9571e0b1997b","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-U38bnRB2.js"},{"revision":null,"url":"assets/index-Ck0r9rSX.js"},{"revision":null,"url":"assets/index-C86AT0KJ.css"},{"revision":null,"url":"assets/TailscalePage-DyKEeXYp.js"},{"revision":null,"url":"assets/SettingsPage-BOzfigwV.js"},{"revision":null,"url":"assets/SandboxManager-DUH9hgUI.js"},{"revision":null,"url":"assets/RunsPage-DN71HYQf.js"},{"revision":null,"url":"assets/PromptsPage-aPQqBbHT.js"},{"revision":null,"url":"assets/Playground-CtT6Yg7z.js"},{"revision":null,"url":"assets/LinearSettingsPage-cPsVQjgm.js"},{"revision":null,"url":"assets/LinearOAuthSettingsPage-B4FLu5lN.js"},{"revision":null,"url":"assets/IntegrationsPage-CI70O4xo.js"},{"revision":null,"url":"assets/DashboardPage-CbI1O6Zk.js"},{"revision":null,"url":"assets/CronManager-BZyF07KE.js"},{"revision":null,"url":"assets/AgentsPage-1K8kPnm0.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":"7f44d9303f254456c9939ad64d6d7c34","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-B9GK06Mo.js"},{"revision":null,"url":"assets/index-C86AT0KJ.css"},{"revision":null,"url":"assets/index-B1Oejky6.js"},{"revision":null,"url":"assets/TailscalePage-CZ6V3kc7.js"},{"revision":null,"url":"assets/SettingsPage-BehGv-KV.js"},{"revision":null,"url":"assets/SandboxManager-BA6-RlkR.js"},{"revision":null,"url":"assets/RunsPage-jk2yW392.js"},{"revision":null,"url":"assets/PromptsPage-Ma4gA0R7.js"},{"revision":null,"url":"assets/Playground-C4t0-Zia.js"},{"revision":null,"url":"assets/LinearSettingsPage-DFh8jfhh.js"},{"revision":null,"url":"assets/LinearOAuthSettingsPage-DUQik5kh.js"},{"revision":null,"url":"assets/IntegrationsPage-C_tZ2P0K.js"},{"revision":null,"url":"assets/DashboardPage-aFf4uQfU.js"},{"revision":null,"url":"assets/CronManager-CES2-E0_.js"},{"revision":null,"url":"assets/AgentsPage-B2NtxJig.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.113.2",
3
+ "version": "0.113.3",
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",
@@ -47,7 +47,7 @@ import type {
47
47
  import type { SocketData } from "./ws-bridge-types.js";
48
48
  import type { PendingControlRequest } from "./ws-bridge-types.js";
49
49
  import type { RecorderManager } from "./recorder.js";
50
- import { captureProcState, hasLiveDescendants, countDescendants, getDescendants, type ProcDescendant } from "./proc-diagnostics.js";
50
+ import { captureProcState, hasLiveDescendants, countDescendants, getDescendants, stdoutFdOpen, type ProcDescendant } from "./proc-diagnostics.js";
51
51
  import { getSettings } from "./settings-manager.js";
52
52
  import { parseNDJSON, isDuplicateCLIMessage } from "./ws-bridge-cli-ingest.js";
53
53
  import type { CLIDedupState } from "./ws-bridge-cli-ingest.js";
@@ -499,6 +499,7 @@ export class ClaudeAdapter implements IBackendAdapter {
499
499
  log.info("claude-adapter", "stdout closed but wedge-kill is disabled; leaving process alone", {
500
500
  sessionId: this.sessionId,
501
501
  pid: proc.pid,
502
+ cliStdoutOpen: stdoutFdOpen(proc.pid),
502
503
  proc: captureProcState(proc.pid),
503
504
  stderrTail: this.stderrTailForLog(),
504
505
  });
@@ -533,9 +534,12 @@ export class ClaudeAdapter implements IBackendAdapter {
533
534
  // Capture kernel state BEFORE the kill: once we SIGTERM, the
534
535
  // evidence is gone. A wedged CLI writes nothing to stderr, so this
535
536
  // is the only signal about why it is still alive.
537
+ const cliStdoutOpen = stdoutFdOpen(proc.pid);
538
+ this.warnIfReaderSideEof(cliStdoutOpen, proc.pid);
536
539
  log.warn("claude-adapter", "stdout closed after result but process did not exit within grace; killing wedged process", {
537
540
  sessionId: this.sessionId,
538
541
  pid: proc.pid,
542
+ cliStdoutOpen,
539
543
  graceReason: this.lastInboundWasResult ? "result" : "descendants_alive",
540
544
  teardownOutcome: this.lastTeardownOutcome,
541
545
  proc: captureProcState(proc.pid),
@@ -560,9 +564,12 @@ export class ClaudeAdapter implements IBackendAdapter {
560
564
  // Reaching here now means a genuine wedge: stdout closed, no live
561
565
  // descendants to reap, and still not exited. Snapshot kernel state
562
566
  // before the kill destroys it.
567
+ const cliStdoutOpen = stdoutFdOpen(proc.pid);
568
+ this.warnIfReaderSideEof(cliStdoutOpen, proc.pid);
563
569
  log.warn("claude-adapter", "stdout closed mid-stream and process did not exit within grace; killing wedged process", {
564
570
  sessionId: this.sessionId,
565
571
  pid: proc.pid,
572
+ cliStdoutOpen,
566
573
  graceMs: STDOUT_CLOSE_GRACE_MS,
567
574
  graceReason: "no_descendants",
568
575
  proc: captureProcState(proc.pid),
@@ -637,6 +644,24 @@ export class ClaudeAdapter implements IBackendAdapter {
637
644
  }
638
645
  }
639
646
 
647
+ /**
648
+ * Called on the kill path when the stdout-fd probe says the CLI still holds
649
+ * its stdout open. A pipe cannot EOF at the read end while a write end is
650
+ * open, so this state proves the "EOF" was synthetic on OUR side (Bun
651
+ * subprocess-stream teardown), not the CLI closing its output. The process
652
+ * is still killed — the stream cannot be re-attached, and an unkilled live
653
+ * PID blocks `handleAutoRelaunch`'s liveness guard, wedging recovery — but
654
+ * the warning attributes the root cause so these events can be counted and
655
+ * the reader-side failure fixed where it lives.
656
+ */
657
+ private warnIfReaderSideEof(cliStdoutOpen: boolean | null, pid: number | undefined): void {
658
+ if (cliStdoutOpen !== true) return;
659
+ log.warn("claude-adapter", "CLI still holds its stdout open — EOF was reader-side (server stream failure), not a CLI wedge; killing anyway so --resume recovery can proceed", {
660
+ sessionId: this.sessionId,
661
+ pid,
662
+ });
663
+ }
664
+
640
665
  private checkSilence(): void {
641
666
  if (this.transportKind !== "stdio" || !this.stdioConnected) return;
642
667
 
@@ -0,0 +1,105 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { mkdtempSync, writeFileSync, appendFileSync, rmSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { tmpdir } from "node:os";
5
+ import {
6
+ countFileLines,
7
+ countFileLinesAsync,
8
+ countFileLinesCached,
9
+ cachedFileLines,
10
+ type FileLinesCacheEntry,
11
+ } from "./fs-utils.js";
12
+
13
+ function makeTmpFile(content: string): { dir: string; path: string } {
14
+ const dir = mkdtempSync(join(tmpdir(), "fs-utils-test-"));
15
+ const path = join(dir, "file.log");
16
+ writeFileSync(path, content);
17
+ return { dir, path };
18
+ }
19
+
20
+ describe("countFileLinesAsync", () => {
21
+ // The async counter exists because the sync one reads whole files on the
22
+ // event loop (a 5-minute rotation scan of a multi-hundred-MB dir starved
23
+ // live CLI stdio streams). It must agree with the sync counter exactly.
24
+ it("matches countFileLines on the same content", async () => {
25
+ const { dir, path } = makeTmpFile("a\nb\nc\nno-trailing-newline");
26
+ try {
27
+ expect(await countFileLinesAsync(path)).toBe(countFileLines(path));
28
+ expect(await countFileLinesAsync(path)).toBe(3);
29
+ } finally {
30
+ rmSync(dir, { recursive: true, force: true });
31
+ }
32
+ });
33
+
34
+ it("resolves 0 for a missing file instead of throwing", async () => {
35
+ expect(await countFileLinesAsync("/nonexistent/definitely/missing.log")).toBe(0);
36
+ });
37
+
38
+ it("counts across chunk boundaries on a file larger than one chunk", async () => {
39
+ // 2.5MB of 100-byte lines crosses the 1MB highWaterMark multiple times.
40
+ const line = "x".repeat(99) + "\n";
41
+ const { dir, path } = makeTmpFile(line.repeat(25_000));
42
+ try {
43
+ expect(await countFileLinesAsync(path)).toBe(25_000);
44
+ } finally {
45
+ rmSync(dir, { recursive: true, force: true });
46
+ }
47
+ });
48
+ });
49
+
50
+ describe("countFileLinesCached", () => {
51
+ // Rotated log/recording files are immutable, so a (size, mtime) match must
52
+ // serve from cache without re-reading — that is the whole point: in steady
53
+ // state only the actively-written file is ever re-counted.
54
+ it("serves an unchanged file from cache and recounts after append", async () => {
55
+ const { dir, path } = makeTmpFile("1\n2\n");
56
+ const cache = new Map<string, FileLinesCacheEntry>();
57
+ try {
58
+ expect(await countFileLinesCached(path, cache)).toBe(2);
59
+ expect(cache.size).toBe(1);
60
+
61
+ // Poison the cached count to prove the second call does NOT re-read.
62
+ const entry = cache.get(path)!;
63
+ cache.set(path, { ...entry, lines: 999 });
64
+ expect(await countFileLinesCached(path, cache)).toBe(999);
65
+
66
+ // Append (size changes) → cache invalidated → real recount.
67
+ appendFileSync(path, "3\n4\n");
68
+ expect(await countFileLinesCached(path, cache)).toBe(4);
69
+ } finally {
70
+ rmSync(dir, { recursive: true, force: true });
71
+ }
72
+ });
73
+
74
+ it("returns 0 and drops the cache entry for a deleted file", async () => {
75
+ const { dir, path } = makeTmpFile("1\n");
76
+ const cache = new Map<string, FileLinesCacheEntry>();
77
+ try {
78
+ await countFileLinesCached(path, cache);
79
+ rmSync(path);
80
+ expect(await countFileLinesCached(path, cache)).toBe(0);
81
+ expect(cache.has(path)).toBe(false);
82
+ } finally {
83
+ rmSync(dir, { recursive: true, force: true });
84
+ }
85
+ });
86
+ });
87
+
88
+ describe("cachedFileLines", () => {
89
+ it("returns the cached count on a hit and null on a miss", async () => {
90
+ const { dir, path } = makeTmpFile("1\n2\n3\n");
91
+ const cache = new Map<string, FileLinesCacheEntry>();
92
+ try {
93
+ // Cold cache → miss.
94
+ expect(cachedFileLines(path, cache)).toBeNull();
95
+ // Warm via the async path → hit.
96
+ await countFileLinesCached(path, cache);
97
+ expect(cachedFileLines(path, cache)).toBe(3);
98
+ // Stale after append → miss again (must not serve a wrong count).
99
+ appendFileSync(path, "4\n");
100
+ expect(cachedFileLines(path, cache)).toBeNull();
101
+ } finally {
102
+ rmSync(dir, { recursive: true, force: true });
103
+ }
104
+ });
105
+ });
@@ -1,6 +1,14 @@
1
- import { readFileSync } from "node:fs";
1
+ import { readFileSync, createReadStream, statSync } from "node:fs";
2
2
 
3
- /** Count newlines in a file. Fast: reads raw buffer, counts 0x0A bytes. */
3
+ /** Count newlines in a file. Fast: reads raw buffer, counts 0x0A bytes.
4
+ *
5
+ * Synchronous — reads the ENTIRE file on the event loop. Only safe for small
6
+ * files or cold paths. Periodic rotation over multi-hundred-MB log/recording
7
+ * directories must use `countFileLinesAsync`/`countFileLinesCached` instead:
8
+ * measured on a production box, sync counting of 66MB logs + 385MB recordings
9
+ * every 5 minutes stalled the event loop long enough to starve live CLI
10
+ * stdio streams.
11
+ */
4
12
  export function countFileLines(path: string): number {
5
13
  try {
6
14
  const buf = readFileSync(path);
@@ -13,3 +21,93 @@ export function countFileLines(path: string): number {
13
21
  return 0;
14
22
  }
15
23
  }
24
+
25
+ /**
26
+ * Count newlines in a file without monopolizing the event loop.
27
+ *
28
+ * Streams the file in 1MB chunks; between chunks the event loop is free to
29
+ * service WebSocket frames and child-process stdio. Resolves 0 on any error
30
+ * (missing file, permission) — counting is always best-effort here.
31
+ */
32
+ export function countFileLinesAsync(path: string): Promise<number> {
33
+ return new Promise((resolve) => {
34
+ let count = 0;
35
+ let stream: ReturnType<typeof createReadStream>;
36
+ try {
37
+ stream = createReadStream(path, { highWaterMark: 1 << 20 });
38
+ } catch {
39
+ resolve(0);
40
+ return;
41
+ }
42
+ stream.on("data", (chunk: Buffer | string) => {
43
+ const buf = typeof chunk === "string" ? Buffer.from(chunk) : chunk;
44
+ for (let i = 0; i < buf.length; i++) {
45
+ if (buf[i] === 0x0a) count++;
46
+ }
47
+ });
48
+ stream.on("error", () => resolve(0));
49
+ stream.on("end", () => resolve(count));
50
+ });
51
+ }
52
+
53
+ /** Cache entry for `countFileLinesCached`: valid while size AND mtime match. */
54
+ export interface FileLinesCacheEntry {
55
+ size: number;
56
+ mtimeMs: number;
57
+ lines: number;
58
+ }
59
+
60
+ /**
61
+ * Count newlines with a stat-validated cache.
62
+ *
63
+ * Rotated log/recording files are append-only while active and immutable once
64
+ * rotated, so an unchanged (size, mtime) pair means the cached count is still
65
+ * exact. In steady state only the currently-written file misses the cache,
66
+ * turning the periodic rotation scan from "re-read every byte of the
67
+ * directory" into one stat per file plus one streamed read of the active file.
68
+ */
69
+ export async function countFileLinesCached(
70
+ path: string,
71
+ cache: Map<string, FileLinesCacheEntry>,
72
+ ): Promise<number> {
73
+ let size: number;
74
+ let mtimeMs: number;
75
+ try {
76
+ const st = statSync(path);
77
+ size = st.size;
78
+ mtimeMs = st.mtimeMs;
79
+ } catch {
80
+ cache.delete(path);
81
+ return 0;
82
+ }
83
+
84
+ const cached = cache.get(path);
85
+ if (cached && cached.size === size && cached.mtimeMs === mtimeMs) {
86
+ return cached.lines;
87
+ }
88
+
89
+ const lines = await countFileLinesAsync(path);
90
+ cache.set(path, { size, mtimeMs, lines });
91
+ return lines;
92
+ }
93
+
94
+ /**
95
+ * Synchronous cache lookup: returns the cached line count if still valid,
96
+ * or null on a miss. Lets on-demand sync paths (REST listings) reuse counts
97
+ * warmed by the periodic async scan without re-reading files.
98
+ */
99
+ export function cachedFileLines(
100
+ path: string,
101
+ cache: Map<string, FileLinesCacheEntry>,
102
+ ): number | null {
103
+ try {
104
+ const st = statSync(path);
105
+ const cached = cache.get(path);
106
+ if (cached && cached.size === st.size && cached.mtimeMs === st.mtimeMs) {
107
+ return cached.lines;
108
+ }
109
+ } catch {
110
+ cache.delete(path);
111
+ }
112
+ return null;
113
+ }
@@ -59,6 +59,69 @@ describe("logger", () => {
59
59
  spy.mockRestore();
60
60
  });
61
61
 
62
+ // Regression: warn/error used to go only to stderr. In service mode stdout
63
+ // and stderr are redirected to different files, so every kill/stall
64
+ // warning was invisible in companion.log (the main grep target) — weeks of
65
+ // "killing wedged process" warnings were hidden this way. warn/error must
66
+ // now be mirrored onto stdout whenever stdout is not a TTY.
67
+ it("mirrors warn and error lines to stdout when stdout is not a TTY", () => {
68
+ const ttyDesc = Object.getOwnPropertyDescriptor(process.stdout, "isTTY");
69
+ Object.defineProperty(process.stdout, "isTTY", { value: false, configurable: true });
70
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
71
+ const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
72
+ const writeSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
73
+ try {
74
+ log.warn("claude-adapter", "killing wedged process", { pid: 42 });
75
+ log.error("cli-launcher", "spawn failed", { exitCode: 1 });
76
+ const written = writeSpy.mock.calls.map((c) => String(c[0])).join("");
77
+ expect(written).toContain("killing wedged process");
78
+ expect(written).toContain("spawn failed");
79
+ } finally {
80
+ writeSpy.mockRestore();
81
+ warnSpy.mockRestore();
82
+ errorSpy.mockRestore();
83
+ if (ttyDesc) Object.defineProperty(process.stdout, "isTTY", ttyDesc);
84
+ }
85
+ });
86
+
87
+ // On a TTY both streams share the terminal — mirroring would double-print.
88
+ it("does not mirror to stdout when stdout is a TTY", () => {
89
+ const ttyDesc = Object.getOwnPropertyDescriptor(process.stdout, "isTTY");
90
+ Object.defineProperty(process.stdout, "isTTY", { value: true, configurable: true });
91
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
92
+ const writeSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
93
+ try {
94
+ log.warn("claude-adapter", "tty warn line", {});
95
+ const written = writeSpy.mock.calls.map((c) => String(c[0])).join("");
96
+ expect(written).not.toContain("tty warn line");
97
+ } finally {
98
+ writeSpy.mockRestore();
99
+ warnSpy.mockRestore();
100
+ if (ttyDesc) Object.defineProperty(process.stdout, "isTTY", ttyDesc);
101
+ }
102
+ });
103
+
104
+ // Severity must survive in a combined stdout log: warn/error lines carry
105
+ // an explicit level tag ("WARN:"/"ERROR:") so `grep WARN: companion.log`
106
+ // finds them; info lines keep the historical untagged format.
107
+ it("tags warn and error lines with their level in human format", () => {
108
+ const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
109
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
110
+ const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
111
+ try {
112
+ log.info("mod", "plain info");
113
+ log.warn("mod", "warned");
114
+ log.error("mod", "errored");
115
+ expect(String(logSpy.mock.calls[0][0])).toBe("[mod] plain info");
116
+ expect(String(warnSpy.mock.calls[0][0])).toContain("[mod] WARN: warned");
117
+ expect(String(errorSpy.mock.calls[0][0])).toContain("[mod] ERROR: errored");
118
+ } finally {
119
+ logSpy.mockRestore();
120
+ warnSpy.mockRestore();
121
+ errorSpy.mockRestore();
122
+ }
123
+ });
124
+
62
125
  it("handles messages without data", () => {
63
126
  const spy = vi.spyOn(console, "log").mockImplementation(() => {});
64
127
  log.info("server", "Started");
@@ -188,7 +251,7 @@ describe("LogFileWriter", () => {
188
251
  });
189
252
 
190
253
  describe("rotation", () => {
191
- it("deletes oldest log files when total lines exceed maxLines", () => {
254
+ it("deletes oldest log files when total lines exceed maxLines", async () => {
192
255
  // Pre-create two old log files with known line counts and distinct mtimes
193
256
  // so rotation deletes the oldest first.
194
257
  const oldFile1 = join(tmpDir, "companion_2020-01-01T00-00-00_1.log");
@@ -207,7 +270,7 @@ describe("LogFileWriter", () => {
207
270
  const writer = new LogFileWriter({ logsDir: tmpDir, maxLines: 8 });
208
271
  try {
209
272
  // Initial cleanup is deferred — run it explicitly for the test
210
- writer.cleanup();
273
+ await writer.cleanup();
211
274
  const files = readdirSync(tmpDir).filter((f) => f.endsWith(".log"));
212
275
  // oldFile1 should have been deleted by cleanup, oldFile2 and current remain
213
276
  expect(files).toHaveLength(2);
@@ -220,7 +283,7 @@ describe("LogFileWriter", () => {
220
283
  }
221
284
  });
222
285
 
223
- it("does not delete the current log file during cleanup", () => {
286
+ it("does not delete the current log file during cleanup", async () => {
224
287
  // Pre-create one old file that puts us over the limit, with an old mtime
225
288
  const oldFile = join(tmpDir, "companion_2020-01-01T00-00-00_1.log");
226
289
  writeFileSync(oldFile, "line1\nline2\nline3\n");
@@ -234,7 +297,7 @@ describe("LogFileWriter", () => {
234
297
  writer.write("current line 3");
235
298
 
236
299
  // Force another cleanup pass
237
- const deleted = writer.cleanup();
300
+ const deleted = await writer.cleanup();
238
301
  expect(deleted).toBeGreaterThanOrEqual(0);
239
302
 
240
303
  // Current file must still exist and be writable
@@ -246,7 +309,7 @@ describe("LogFileWriter", () => {
246
309
  }
247
310
  });
248
311
 
249
- it("returns the number of files deleted during cleanup", () => {
312
+ it("returns the number of files deleted during cleanup", async () => {
250
313
  // Create 3 old files with 5 lines each = 15 lines total, with distinct mtimes
251
314
  for (let i = 0; i < 3; i++) {
252
315
  const f = join(tmpDir, `companion_2020-0${i + 1}-01T00-00-00_${i}.log`);
@@ -259,7 +322,7 @@ describe("LogFileWriter", () => {
259
322
  const writer = new LogFileWriter({ logsDir: tmpDir, maxLines: 5 });
260
323
  try {
261
324
  // Initial cleanup is deferred — run it explicitly for the test
262
- writer.cleanup();
325
+ await writer.cleanup();
263
326
  const files = readdirSync(tmpDir).filter((f) => f.endsWith(".log"));
264
327
  // At most the newest old file + current file should remain
265
328
  expect(files.length).toBeLessThanOrEqual(2);
package/server/logger.ts CHANGED
@@ -25,7 +25,7 @@ import {
25
25
  } from "node:fs";
26
26
  import { join } from "node:path";
27
27
  import { COMPANION_HOME } from "./paths.js";
28
- import { countFileLines } from "./fs-utils.js";
28
+ import { countFileLinesCached, type FileLinesCacheEntry } from "./fs-utils.js";
29
29
 
30
30
  type LogLevel = "info" | "warn" | "error";
31
31
 
@@ -52,7 +52,9 @@ function formatEntry(level: LogLevel, module: string, msg: string, data?: Record
52
52
  }
53
53
 
54
54
  // Human-readable format (default): [module] msg key=value key=value
55
- let line = `[${module}] ${msg}`;
55
+ // warn/error carry an explicit level tag so severity survives in a combined
56
+ // stdout log and stays greppable (`grep 'WARN:' companion.log`).
57
+ let line = level === "info" ? `[${module}] ${msg}` : `[${module}] ${level.toUpperCase()}: ${msg}`;
56
58
  if (data) {
57
59
  const pairs = Object.entries(data)
58
60
  .map(([k, v]) => `${k}=${typeof v === "object" ? JSON.stringify(v) : v}`)
@@ -83,6 +85,12 @@ export class LogFileWriter {
83
85
  private dirCreated = false;
84
86
  private initialCleanupTimer: ReturnType<typeof setTimeout> | null = null;
85
87
  private cleanupTimer: ReturnType<typeof setInterval> | null = null;
88
+ /** Stat-validated line-count cache: rotated files are immutable, so only the
89
+ * active file is ever re-read. Keeps the 5-minute scan off the event loop's
90
+ * critical path (a sync scan of a 66MB dir was starving CLI stdio streams). */
91
+ private lineCountCache = new Map<string, FileLinesCacheEntry>();
92
+ /** Serializes cleanup passes so a slow scan can't overlap the next timer tick. */
93
+ private cleanupInFlight: Promise<number> | null = null;
86
94
 
87
95
  constructor(options?: { logsDir?: string; maxLines?: number }) {
88
96
  this.logsDir = options?.logsDir ?? LogFileWriter.resolveDir();
@@ -101,10 +109,10 @@ export class LogFileWriter {
101
109
  // Defer initial cleanup so it doesn't block the event loop at startup
102
110
  this.initialCleanupTimer = setTimeout(() => {
103
111
  this.initialCleanupTimer = null;
104
- this.cleanup();
112
+ void this.cleanup();
105
113
  }, 2000);
106
114
  if (this.initialCleanupTimer.unref) this.initialCleanupTimer.unref();
107
- this.cleanupTimer = setInterval(() => this.cleanup(), LOG_CLEANUP_INTERVAL_MS);
115
+ this.cleanupTimer = setInterval(() => void this.cleanup(), LOG_CLEANUP_INTERVAL_MS);
108
116
  if (this.cleanupTimer.unref) this.cleanupTimer.unref();
109
117
  }
110
118
 
@@ -139,8 +147,21 @@ export class LogFileWriter {
139
147
  /**
140
148
  * Delete oldest log files until total lines are under maxLines.
141
149
  * Skips the current log file (still being written to).
150
+ *
151
+ * Async and cache-backed on purpose: the previous sync implementation
152
+ * re-read every byte of the logs directory on the event loop every 5
153
+ * minutes, which on a 66MB directory stalled the loop long enough to starve
154
+ * live CLI stdio streams. Overlapping passes are coalesced.
142
155
  */
143
- cleanup(): number {
156
+ cleanup(): Promise<number> {
157
+ if (this.cleanupInFlight) return this.cleanupInFlight;
158
+ this.cleanupInFlight = this.runCleanup().finally(() => {
159
+ this.cleanupInFlight = null;
160
+ });
161
+ return this.cleanupInFlight;
162
+ }
163
+
164
+ private async runCleanup(): Promise<number> {
144
165
  try {
145
166
  this.ensureDir();
146
167
  const files = readdirSync(this.logsDir).filter((f) => f.endsWith(".log"));
@@ -151,7 +172,7 @@ export class LogFileWriter {
151
172
 
152
173
  for (const filename of files) {
153
174
  const fullPath = join(this.logsDir, filename);
154
- const lines = countFileLines(fullPath);
175
+ const lines = await countFileLinesCached(fullPath, this.lineCountCache);
155
176
  let mtimeMs = 0;
156
177
  try {
157
178
  mtimeMs = statSync(fullPath).mtimeMs;
@@ -174,6 +195,7 @@ export class LogFileWriter {
174
195
  if (entry.path === this.filePath) continue;
175
196
  try {
176
197
  unlinkSync(entry.path);
198
+ this.lineCountCache.delete(entry.path);
177
199
  totalLines -= entry.lines;
178
200
  deleted++;
179
201
  } catch {
@@ -238,6 +260,25 @@ export function closeLogFile(): void {
238
260
 
239
261
  // ─── Public logger ──────────────────────────────────────────────────────────
240
262
 
263
+ /**
264
+ * Mirror a warn/error line onto stdout when stdout is redirected to a file.
265
+ *
266
+ * In service mode stdout and stderr land in different files (`companion.log`
267
+ * vs `companion.error.log`). warn/error used to go only to stderr, so every
268
+ * kill and stall warning was invisible in `companion.log` — the file everyone
269
+ * greps first. That hid weeks of "killing wedged process" warnings while the
270
+ * main log showed only unexplained 143 exits. Mirroring is skipped on a TTY,
271
+ * where both streams share the terminal and would print twice.
272
+ */
273
+ function mirrorToStdout(line: string): void {
274
+ if (process.stdout.isTTY) return;
275
+ try {
276
+ process.stdout.write(line + "\n");
277
+ } catch {
278
+ // Logging must never disrupt normal operation
279
+ }
280
+ }
281
+
241
282
  export const log = {
242
283
  info(module: string, msg: string, data?: Record<string, unknown>): void {
243
284
  const line = formatEntry("info", module, msg, data);
@@ -248,12 +289,14 @@ export const log = {
248
289
  warn(module: string, msg: string, data?: Record<string, unknown>): void {
249
290
  const line = formatEntry("warn", module, msg, data);
250
291
  console.warn(line);
292
+ mirrorToStdout(line);
251
293
  fileWriter?.write(line);
252
294
  },
253
295
 
254
296
  error(module: string, msg: string, data?: Record<string, unknown>): void {
255
297
  const line = formatEntry("error", module, msg, data);
256
298
  console.error(line);
299
+ mirrorToStdout(line);
257
300
  fileWriter?.write(line);
258
301
  },
259
302
  };