@hellcoder/companion 0.111.0 → 0.111.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (28) hide show
  1. package/dist/assets/{AgentsPage-DO5MiZyp.js → AgentsPage-Dc23pQBM.js} +1 -1
  2. package/dist/assets/{CronManager-BSNkRBGn.js → CronManager-CKvnO3df.js} +1 -1
  3. package/dist/assets/{DashboardPage-DqwmmyJ6.js → DashboardPage-_gU5oXmV.js} +1 -1
  4. package/dist/assets/{IntegrationsPage-CxavpMnn.js → IntegrationsPage-zBdnuEDO.js} +1 -1
  5. package/dist/assets/{LinearOAuthSettingsPage-Bs_FXqPA.js → LinearOAuthSettingsPage-v4LYNLyW.js} +1 -1
  6. package/dist/assets/{LinearSettingsPage-zerN3EmK.js → LinearSettingsPage-C-Gm_aeW.js} +1 -1
  7. package/dist/assets/{Playground-DNlOjOnC.js → Playground-BheM7iin.js} +1 -1
  8. package/dist/assets/{PromptsPage-D20LKwla.js → PromptsPage-Bt2idDR8.js} +1 -1
  9. package/dist/assets/{RunsPage-ho-uEj6h.js → RunsPage-ib31kftd.js} +1 -1
  10. package/dist/assets/{SandboxManager-BY2NPfsU.js → SandboxManager-Ch0-1iE8.js} +1 -1
  11. package/dist/assets/SettingsPage-fqy7b9b8.js +1 -0
  12. package/dist/assets/{TailscalePage-pv47ZX2f.js → TailscalePage-Cf9_ENdO.js} +1 -1
  13. package/dist/assets/{index-C7qt99R_.js → index-Baw3PrqK.js} +52 -52
  14. package/dist/assets/{sw-register-DZ08pZIJ.js → sw-register-D-9XveNW.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/auto-namer.test.ts +4 -0
  19. package/server/claude-adapter.test.ts +111 -6
  20. package/server/claude-adapter.ts +106 -0
  21. package/server/linear-connections.test.ts +4 -0
  22. package/server/routes/settings-routes.ts +10 -0
  23. package/server/routes.test.ts +27 -0
  24. package/server/session-orchestrator.test.ts +3 -0
  25. package/server/settings-manager.test.ts +3 -0
  26. package/server/settings-manager.ts +14 -1
  27. package/server/ws-bridge-codex.test.ts +6 -0
  28. package/dist/assets/SettingsPage-CvYCqkm1.js +0 -1
@@ -1 +1 @@
1
- import{_ as u}from"./index-C7qt99R_.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-Baw3PrqK.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-C7qt99R_.js"></script>
14
+ <script type="module" crossorigin src="/assets/index-Baw3PrqK.js"></script>
15
15
  <link rel="stylesheet" crossorigin href="/assets/index-C9eyQq6F.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":"b3412b88c0247a22ce9e437656a4e593","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-DZ08pZIJ.js"},{"revision":null,"url":"assets/index-C9eyQq6F.css"},{"revision":null,"url":"assets/index-C7qt99R_.js"},{"revision":null,"url":"assets/TailscalePage-pv47ZX2f.js"},{"revision":null,"url":"assets/SettingsPage-CvYCqkm1.js"},{"revision":null,"url":"assets/SandboxManager-BY2NPfsU.js"},{"revision":null,"url":"assets/RunsPage-ho-uEj6h.js"},{"revision":null,"url":"assets/PromptsPage-D20LKwla.js"},{"revision":null,"url":"assets/Playground-DNlOjOnC.js"},{"revision":null,"url":"assets/LinearSettingsPage-zerN3EmK.js"},{"revision":null,"url":"assets/LinearOAuthSettingsPage-Bs_FXqPA.js"},{"revision":null,"url":"assets/IntegrationsPage-CxavpMnn.js"},{"revision":null,"url":"assets/DashboardPage-DqwmmyJ6.js"},{"revision":null,"url":"assets/CronManager-BSNkRBGn.js"},{"revision":null,"url":"assets/AgentsPage-DO5MiZyp.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":"60133672097c53eb46cee2ded4a53250","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-D-9XveNW.js"},{"revision":null,"url":"assets/index-C9eyQq6F.css"},{"revision":null,"url":"assets/index-Baw3PrqK.js"},{"revision":null,"url":"assets/TailscalePage-Cf9_ENdO.js"},{"revision":null,"url":"assets/SettingsPage-fqy7b9b8.js"},{"revision":null,"url":"assets/SandboxManager-Ch0-1iE8.js"},{"revision":null,"url":"assets/RunsPage-ib31kftd.js"},{"revision":null,"url":"assets/PromptsPage-Bt2idDR8.js"},{"revision":null,"url":"assets/Playground-BheM7iin.js"},{"revision":null,"url":"assets/LinearSettingsPage-C-Gm_aeW.js"},{"revision":null,"url":"assets/LinearOAuthSettingsPage-v4LYNLyW.js"},{"revision":null,"url":"assets/IntegrationsPage-zBdnuEDO.js"},{"revision":null,"url":"assets/DashboardPage-_gU5oXmV.js"},{"revision":null,"url":"assets/CronManager-CKvnO3df.js"},{"revision":null,"url":"assets/AgentsPage-Dc23pQBM.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.111.0",
3
+ "version": "0.111.1",
4
4
  "type": "module",
5
5
  "description": "Web UI for launching and interacting with Claude Code agents — Moritz Edition (fork of the-companion)",
6
6
  "license": "MIT",
@@ -43,6 +43,7 @@ beforeEach(() => {
43
43
  dockerAutoUpdate: false,
44
44
  proactiveKeepaliveEnabled: true,
45
45
  wedgeKillEnabled: true,
46
+ silenceProbeEnabled: true,
46
47
  updatedAt: 0,
47
48
  });
48
49
  });
@@ -92,6 +93,7 @@ describe("generateSessionTitle", () => {
92
93
  dockerAutoUpdate: false,
93
94
  proactiveKeepaliveEnabled: true,
94
95
  wedgeKillEnabled: true,
96
+ silenceProbeEnabled: true,
95
97
  updatedAt: 0,
96
98
  });
97
99
 
@@ -148,6 +150,7 @@ describe("generateSessionTitle", () => {
148
150
  dockerAutoUpdate: false,
149
151
  proactiveKeepaliveEnabled: true,
150
152
  wedgeKillEnabled: true,
153
+ silenceProbeEnabled: true,
151
154
  updatedAt: 0,
152
155
  });
153
156
  mockFetch.mockResolvedValueOnce({
@@ -233,6 +236,7 @@ describe("generateSessionTitle", () => {
233
236
  dockerAutoUpdate: false,
234
237
  proactiveKeepaliveEnabled: true,
235
238
  wedgeKillEnabled: true,
239
+ silenceProbeEnabled: true,
236
240
  updatedAt: 0,
237
241
  });
238
242
  mockFetch.mockResolvedValueOnce({
@@ -33,13 +33,18 @@ vi.mock("./proc-diagnostics.js", async (importOriginal) => ({
33
33
  countDescendants: mockCountDescendants,
34
34
  }));
35
35
 
36
+ // Settings are stubbed so the wedge-kill and silence-probe switches can be
37
+ // driven per test. Both default to enabled, matching production.
38
+ const mockSettings = vi.hoisted(() => ({
39
+ aiValidationEnabled: false,
40
+ aiValidationAutoApprove: false,
41
+ aiValidationAutoDeny: false,
42
+ anthropicApiKey: "",
43
+ wedgeKillEnabled: true,
44
+ silenceProbeEnabled: true,
45
+ }));
36
46
  vi.mock("./settings-manager.js", () => ({
37
- getSettings: () => ({
38
- aiValidationEnabled: false,
39
- aiValidationAutoApprove: false,
40
- aiValidationAutoDeny: false,
41
- anthropicApiKey: "",
42
- }),
47
+ getSettings: () => mockSettings,
43
48
  DEFAULT_ANTHROPIC_MODEL: "claude-sonnet-4-6",
44
49
  }));
45
50
 
@@ -1765,3 +1770,103 @@ describe("stdio transport disconnect propagation", () => {
1765
1770
  expect(disconnectCb).toHaveBeenCalledTimes(1);
1766
1771
  });
1767
1772
  });
1773
+
1774
+ /**
1775
+ * Silence probe — a failure mode none of the stdout-EOF machinery can see.
1776
+ *
1777
+ * Reported from production: the CLI stops emitting mid-`thinking_delta` while
1778
+ * its stdout stays OPEN and the process stays healthy (S/ep_poll, 12s CPU over
1779
+ * 13min), with upstream HTTPS sockets ESTABLISHED and idle. Because nothing
1780
+ * ever closes the stream, no wedge warning, kill, relaunch or error fires, and
1781
+ * the session hangs indefinitely showing "Still working…".
1782
+ *
1783
+ * A stalled CLI also stops answering control requests, which is the signal the
1784
+ * probe keys on.
1785
+ */
1786
+ describe("stdio silence probe", () => {
1787
+ let adapter: ClaudeAdapter;
1788
+ let disconnectCb: ReturnType<typeof vi.fn>;
1789
+
1790
+ beforeEach(() => {
1791
+ adapter = new ClaudeAdapter("silence-session");
1792
+ disconnectCb = vi.fn();
1793
+ adapter.onDisconnect(disconnectCb as unknown as () => void);
1794
+ mockSettings.silenceProbeEnabled = true;
1795
+ });
1796
+
1797
+ afterEach(() => {
1798
+ mockSettings.silenceProbeEnabled = true;
1799
+ vi.useRealTimers();
1800
+ });
1801
+
1802
+ it("declares the transport dead when a silent CLI never answers the probe", async () => {
1803
+ vi.useFakeTimers();
1804
+ const { proc } = createMockProc();
1805
+ adapter.attachStdio(proc);
1806
+ expect(adapter.isConnected()).toBe(true);
1807
+
1808
+ // Silence short of the probe threshold: nothing happens yet.
1809
+ await vi.advanceTimersByTimeAsync(110_000);
1810
+ expect(disconnectCb).not.toHaveBeenCalled();
1811
+
1812
+ // Past the threshold the probe is sent, but the CLI never replies.
1813
+ await vi.advanceTimersByTimeAsync(20_000);
1814
+ expect(disconnectCb).not.toHaveBeenCalled();
1815
+
1816
+ // Past the probe timeout the transport is treated as dead, handing off to
1817
+ // the relaunch path that replays the in-flight turn.
1818
+ await vi.advanceTimersByTimeAsync(65_000);
1819
+ expect(disconnectCb).toHaveBeenCalledTimes(1);
1820
+ expect(adapter.isConnected()).toBe(false);
1821
+ });
1822
+
1823
+ it("does NOT fire while the CLI keeps emitting", async () => {
1824
+ // The critical false-positive guard. A working session must never be torn
1825
+ // down — every previous timer in this file was too aggressive and killed
1826
+ // healthy processes.
1827
+ vi.useFakeTimers();
1828
+ const { proc, pushStdout } = createMockProc();
1829
+ adapter.attachStdio(proc);
1830
+
1831
+ for (let i = 0; i < 10; i++) {
1832
+ await vi.advanceTimersByTimeAsync(60_000);
1833
+ pushStdout(
1834
+ JSON.stringify({ type: "keep_alive", uuid: `ka-${i}`, session_id: "s" }) + "\n",
1835
+ );
1836
+ }
1837
+ await vi.advanceTimersByTimeAsync(60_000);
1838
+
1839
+ // 10+ minutes elapsed, but the stream kept proving life.
1840
+ expect(disconnectCb).not.toHaveBeenCalled();
1841
+ expect(adapter.isConnected()).toBe(true);
1842
+ });
1843
+
1844
+ it("treats a late reply as proof of life and cancels the pending probe", async () => {
1845
+ // The CLI answers *after* the probe goes out but before the timeout. That
1846
+ // is a slow CLI, not a dead one, and must not be torn down.
1847
+ vi.useFakeTimers();
1848
+ const { proc, pushStdout } = createMockProc();
1849
+ adapter.attachStdio(proc);
1850
+
1851
+ await vi.advanceTimersByTimeAsync(130_000); // probe sent
1852
+ pushStdout(
1853
+ JSON.stringify({ type: "keep_alive", uuid: "late", session_id: "s" }) + "\n",
1854
+ );
1855
+ await vi.advanceTimersByTimeAsync(65_000); // past what would have been the timeout
1856
+
1857
+ expect(disconnectCb).not.toHaveBeenCalled();
1858
+ expect(adapter.isConnected()).toBe(true);
1859
+ });
1860
+
1861
+ it("does nothing when the silence probe is disabled", async () => {
1862
+ vi.useFakeTimers();
1863
+ mockSettings.silenceProbeEnabled = false;
1864
+ const { proc } = createMockProc();
1865
+ adapter.attachStdio(proc);
1866
+
1867
+ await vi.advanceTimersByTimeAsync(600_000);
1868
+
1869
+ expect(disconnectCb).not.toHaveBeenCalled();
1870
+ expect(adapter.isConnected()).toBe(true);
1871
+ });
1872
+ });
@@ -102,6 +102,33 @@ const SIGKILL_ESCALATION_MS = Number(process.env.COMPANION_SIGKILL_ESCALATION_MS
102
102
  */
103
103
  const TEARDOWN_MAX_MS = Number(process.env.COMPANION_TEARDOWN_MAX_MS) || 660_000;
104
104
 
105
+ /**
106
+ * Silence-probe thresholds.
107
+ *
108
+ * A separate failure mode from the wedge: the CLI's stdout stays **open** and
109
+ * the process stays healthy (S/ep_poll, low CPU), but it stops emitting
110
+ * entirely — observed mid-`thinking_delta`, with its upstream HTTPS sockets
111
+ * ESTABLISHED and idle (Recv-Q/Send-Q 0) and no client-side timeout ever
112
+ * firing. Because stdout never closes, none of the stdout-EOF machinery runs:
113
+ * no warning, no kill, no relaunch. The session hangs forever and the UI shows
114
+ * "Still working…" indefinitely.
115
+ *
116
+ * A stalled CLI also stops answering control requests, while a healthy one
117
+ * replies promptly. So after SILENCE_PROBE_AFTER_MS of total silence we send a
118
+ * cheap control_request; if nothing at all comes back within
119
+ * SILENCE_PROBE_TIMEOUT_MS we treat the transport as dead and hand off to the
120
+ * existing relaunch path, which replays the in-flight turn rather than losing
121
+ * it.
122
+ *
123
+ * Thresholds are deliberately generous (~3 minutes end to end). Every previous
124
+ * timer in this file has turned out to be too aggressive and killed healthy
125
+ * processes, and the cost of being wrong here is the same: a needless restart.
126
+ * Any inbound frame — including `keep_alive` — counts as proof of life.
127
+ */
128
+ const SILENCE_PROBE_AFTER_MS = Number(process.env.COMPANION_SILENCE_PROBE_AFTER_MS) || 120_000;
129
+ const SILENCE_PROBE_TIMEOUT_MS = Number(process.env.COMPANION_SILENCE_PROBE_TIMEOUT_MS) || 60_000;
130
+ const SILENCE_CHECK_INTERVAL_MS = Number(process.env.COMPANION_SILENCE_CHECK_INTERVAL_MS) || 15_000;
131
+
105
132
  /** How often to re-check the descendant count while waiting for teardown. */
106
133
  const TEARDOWN_POLL_MS = Number(process.env.COMPANION_TEARDOWN_POLL_MS) || 500;
107
134
 
@@ -141,6 +168,11 @@ export class ClaudeAdapter implements IBackendAdapter {
141
168
  * (`keep_alive`, `system` status/keepalive) does not reset it; any
142
169
  * substantive turn activity does. */
143
170
  private lastInboundWasResult = false;
171
+ /** Timestamp of the last inbound frame of any kind, for the silence probe. */
172
+ private lastInboundAt = Date.now();
173
+ /** When the outstanding silence probe was sent, or null if none is in flight. */
174
+ private probeSentAt: number | null = null;
175
+ private silenceTimer: ReturnType<typeof setInterval> | null = null;
144
176
 
145
177
  // Callbacks registered by the bridge via on*() methods
146
178
  private browserMessageCb: ((msg: BrowserIncomingMessage) => void) | null = null;
@@ -273,6 +305,10 @@ export class ClaudeAdapter implements IBackendAdapter {
273
305
  const stdout = proc.stdout as ReadableStream<Uint8Array>;
274
306
  void this.readStdioStdout(stdout);
275
307
 
308
+ // Watch for the CLI going silent while stdout stays open — invisible to the
309
+ // stdout-EOF path, which only runs when the stream actually closes.
310
+ this.startSilenceProbe();
311
+
276
312
  // Flush anything queued before the transport attached (applies the lazy
277
313
  // `initialize` handshake before the first real message).
278
314
  if (this.pendingMessages.length > 0) {
@@ -308,6 +344,7 @@ export class ClaudeAdapter implements IBackendAdapter {
308
344
  private notifyStdioDisconnect(): void {
309
345
  this.stdioConnected = false;
310
346
  this.stdioWriter = null;
347
+ this.stopSilenceProbe();
311
348
  if (this.disconnectFired) return;
312
349
  this.disconnectFired = true;
313
350
  this.disconnectCb?.();
@@ -440,6 +477,68 @@ export class ClaudeAdapter implements IBackendAdapter {
440
477
  /** Why the last teardown wait ended, for the kill warning. */
441
478
  private lastTeardownOutcome: string | undefined;
442
479
 
480
+ /**
481
+ * Start the silence probe for the stdio transport.
482
+ *
483
+ * Detects a CLI that has gone silent with its stdout still **open** — the one
484
+ * failure mode none of the stdout-EOF machinery can see, because nothing ever
485
+ * closes. Left alone the session hangs indefinitely with no error surfaced.
486
+ */
487
+ private startSilenceProbe(): void {
488
+ this.stopSilenceProbe();
489
+ this.lastInboundAt = Date.now();
490
+ this.probeSentAt = null;
491
+ this.silenceTimer = setInterval(() => this.checkSilence(), SILENCE_CHECK_INTERVAL_MS);
492
+ // Never hold the process open for a diagnostic timer.
493
+ (this.silenceTimer as unknown as { unref?: () => void }).unref?.();
494
+ }
495
+
496
+ private stopSilenceProbe(): void {
497
+ if (this.silenceTimer) {
498
+ clearInterval(this.silenceTimer);
499
+ this.silenceTimer = null;
500
+ }
501
+ this.probeSentAt = null;
502
+ }
503
+
504
+ private checkSilence(): void {
505
+ if (this.transportKind !== "stdio" || !this.stdioConnected) return;
506
+ if (getSettings().silenceProbeEnabled === false) return;
507
+
508
+ const now = Date.now();
509
+
510
+ // An unanswered probe is the actual failure signal: a healthy CLI replies
511
+ // to control requests promptly even while working.
512
+ if (this.probeSentAt !== null) {
513
+ if (now - this.probeSentAt >= SILENCE_PROBE_TIMEOUT_MS) {
514
+ log.warn("claude-adapter", "CLI silent with stdout open and probe unanswered; treating transport as dead", {
515
+ sessionId: this.sessionId,
516
+ pid: this.stdioProc?.pid,
517
+ silentForMs: now - this.lastInboundAt,
518
+ probeUnansweredForMs: now - this.probeSentAt,
519
+ proc: captureProcState(this.stdioProc?.pid),
520
+ });
521
+ this.stopSilenceProbe();
522
+ // Hand off to the existing recovery path, which relaunches and replays
523
+ // the in-flight turn rather than losing it.
524
+ this.notifyStdioDisconnect();
525
+ }
526
+ return;
527
+ }
528
+
529
+ if (now - this.lastInboundAt >= SILENCE_PROBE_AFTER_MS) {
530
+ this.probeSentAt = now;
531
+ log.info("claude-adapter", "CLI silent; sending liveness probe", {
532
+ sessionId: this.sessionId,
533
+ pid: this.stdioProc?.pid,
534
+ silentForMs: now - this.lastInboundAt,
535
+ });
536
+ // mcp_status is cheap and side-effect free. We do not care about the
537
+ // payload — any inbound frame clears probeSentAt in routeCLIMessage.
538
+ this.sendControlRequest({ subtype: "mcp_status" });
539
+ }
540
+ }
541
+
443
542
  /**
444
543
  * Wait for a process to exit after its stdout transport has died.
445
544
  *
@@ -955,6 +1054,13 @@ export class ClaudeAdapter implements IBackendAdapter {
955
1054
  // -- CLI message routing (NDJSON -> BrowserIncomingMessage) -----------------
956
1055
 
957
1056
  private routeCLIMessage(msg: CLIMessage): void {
1057
+ // Any frame at all — keep_alive included — proves the transport is alive
1058
+ // and clears any outstanding silence probe. This is deliberately broader
1059
+ // than the activity tracking below: `keep_alive` is not *work*, but it is
1060
+ // proof the CLI is still answering, which is all the probe asks.
1061
+ this.lastInboundAt = Date.now();
1062
+ this.probeSentAt = null;
1063
+
958
1064
  // Track activity for idle detection (skip keepalives -- they don't indicate real work)
959
1065
  if (msg.type !== "keep_alive") {
960
1066
  this.onActivityUpdate?.();
@@ -69,6 +69,7 @@ beforeEach(() => {
69
69
  dockerAutoUpdate: false,
70
70
  proactiveKeepaliveEnabled: true,
71
71
  wedgeKillEnabled: true,
72
+ silenceProbeEnabled: true,
72
73
  updatedAt: 0,
73
74
  });
74
75
  });
@@ -206,6 +207,7 @@ describe("linear-connections", () => {
206
207
  dockerAutoUpdate: false,
207
208
  proactiveKeepaliveEnabled: true,
208
209
  wedgeKillEnabled: true,
210
+ silenceProbeEnabled: true,
209
211
  updatedAt: 0,
210
212
  });
211
213
  // Need to reset so migration runs with updated mock
@@ -253,6 +255,7 @@ describe("linear-connections", () => {
253
255
  dockerAutoUpdate: false,
254
256
  proactiveKeepaliveEnabled: true,
255
257
  wedgeKillEnabled: true,
258
+ silenceProbeEnabled: true,
256
259
  updatedAt: 0,
257
260
  });
258
261
  _resetForTest(join(tempDir, "linear-connections-migrate.json"));
@@ -319,6 +322,7 @@ describe("linear-connections", () => {
319
322
  dockerAutoUpdate: false,
320
323
  proactiveKeepaliveEnabled: true,
321
324
  wedgeKillEnabled: true,
325
+ silenceProbeEnabled: true,
322
326
  updatedAt: 0,
323
327
  });
324
328
 
@@ -35,6 +35,7 @@ export function registerSettingsRoutes(api: Hono): void {
35
35
  dockerAutoUpdate: settings.dockerAutoUpdate,
36
36
  proactiveKeepaliveEnabled: settings.proactiveKeepaliveEnabled,
37
37
  wedgeKillEnabled: settings.wedgeKillEnabled,
38
+ silenceProbeEnabled: settings.silenceProbeEnabled,
38
39
  cliBridgeMode: settings.cliBridgeMode,
39
40
  });
40
41
  });
@@ -136,6 +137,9 @@ export function registerSettingsRoutes(api: Hono): void {
136
137
  if (body.wedgeKillEnabled !== undefined && typeof body.wedgeKillEnabled !== "boolean") {
137
138
  return c.json({ error: "wedgeKillEnabled must be a boolean" }, 400);
138
139
  }
140
+ if (body.silenceProbeEnabled !== undefined && typeof body.silenceProbeEnabled !== "boolean") {
141
+ return c.json({ error: "silenceProbeEnabled must be a boolean" }, 400);
142
+ }
139
143
  if (body.cliBridgeMode !== undefined && body.cliBridgeMode !== "loopback" && body.cliBridgeMode !== "jsonHandoff") {
140
144
  return c.json({ error: "cliBridgeMode must be 'loopback' or 'jsonHandoff'" }, 400);
141
145
  }
@@ -157,6 +161,7 @@ export function registerSettingsRoutes(api: Hono): void {
157
161
  || body.dockerAutoUpdate !== undefined
158
162
  || body.proactiveKeepaliveEnabled !== undefined
159
163
  || body.wedgeKillEnabled !== undefined
164
+ || body.silenceProbeEnabled !== undefined
160
165
  || body.cliBridgeMode !== undefined;
161
166
  if (!hasAnyField) {
162
167
  return c.json({ error: "At least one settings field is required" }, 400);
@@ -275,6 +280,10 @@ export function registerSettingsRoutes(api: Hono): void {
275
280
  typeof body.wedgeKillEnabled === "boolean"
276
281
  ? body.wedgeKillEnabled
277
282
  : undefined,
283
+ silenceProbeEnabled:
284
+ typeof body.silenceProbeEnabled === "boolean"
285
+ ? body.silenceProbeEnabled
286
+ : undefined,
278
287
  cliBridgeMode:
279
288
  body.cliBridgeMode === "loopback" || body.cliBridgeMode === "jsonHandoff"
280
289
  ? (body.cliBridgeMode as CliBridgeMode)
@@ -309,6 +318,7 @@ export function registerSettingsRoutes(api: Hono): void {
309
318
  dockerAutoUpdate: settings.dockerAutoUpdate,
310
319
  proactiveKeepaliveEnabled: settings.proactiveKeepaliveEnabled,
311
320
  wedgeKillEnabled: settings.wedgeKillEnabled,
321
+ silenceProbeEnabled: settings.silenceProbeEnabled,
312
322
  cliBridgeMode: settings.cliBridgeMode,
313
323
  });
314
324
  });