@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.
- package/dist/assets/{AgentsPage-1K8kPnm0.js → AgentsPage-B2NtxJig.js} +1 -1
- package/dist/assets/{CronManager-BZyF07KE.js → CronManager-CES2-E0_.js} +1 -1
- package/dist/assets/{DashboardPage-CbI1O6Zk.js → DashboardPage-aFf4uQfU.js} +1 -1
- package/dist/assets/{IntegrationsPage-CI70O4xo.js → IntegrationsPage-C_tZ2P0K.js} +1 -1
- package/dist/assets/{LinearOAuthSettingsPage-B4FLu5lN.js → LinearOAuthSettingsPage-DUQik5kh.js} +1 -1
- package/dist/assets/{LinearSettingsPage-cPsVQjgm.js → LinearSettingsPage-DFh8jfhh.js} +1 -1
- package/dist/assets/{Playground-CtT6Yg7z.js → Playground-C4t0-Zia.js} +1 -1
- package/dist/assets/{PromptsPage-aPQqBbHT.js → PromptsPage-Ma4gA0R7.js} +1 -1
- package/dist/assets/{RunsPage-DN71HYQf.js → RunsPage-jk2yW392.js} +1 -1
- package/dist/assets/{SandboxManager-DUH9hgUI.js → SandboxManager-BA6-RlkR.js} +1 -1
- package/dist/assets/{SettingsPage-BOzfigwV.js → SettingsPage-BehGv-KV.js} +1 -1
- package/dist/assets/{TailscalePage-DyKEeXYp.js → TailscalePage-CZ6V3kc7.js} +1 -1
- package/dist/assets/{index-Ck0r9rSX.js → index-B1Oejky6.js} +4 -4
- package/dist/assets/{sw-register-U38bnRB2.js → sw-register-B9GK06Mo.js} +1 -1
- package/dist/index.html +1 -1
- package/dist/sw.js +1 -1
- package/package.json +1 -1
- package/server/claude-adapter.ts +26 -1
- package/server/fs-utils.test.ts +105 -0
- package/server/fs-utils.ts +100 -2
- package/server/logger.test.ts +69 -6
- package/server/logger.ts +49 -6
- package/server/proc-diagnostics.test.ts +44 -0
- package/server/proc-diagnostics.ts +28 -1
- package/server/recorder.test.ts +12 -10
- package/server/recorder.ts +30 -7
- package/server/session-git-info.ts +34 -19
- package/server/ws-bridge.test.ts +34 -10
- package/server/ws-bridge.ts +63 -6
|
@@ -1 +1 @@
|
|
|
1
|
-
import{_ as u}from"./index-
|
|
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-
|
|
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":"
|
|
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
package/server/claude-adapter.ts
CHANGED
|
@@ -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
|
+
});
|
package/server/fs-utils.ts
CHANGED
|
@@ -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
|
+
}
|
package/server/logger.test.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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 =
|
|
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
|
};
|