@hellcoder/companion 0.100.0 → 0.101.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (25) hide show
  1. package/dist/assets/{AgentsPage-7oHDiJoh.js → AgentsPage-BhKdcyXZ.js} +1 -1
  2. package/dist/assets/{CronManager-fsEUcByi.js → CronManager-DRZ66TkG.js} +1 -1
  3. package/dist/assets/{IntegrationsPage-DhiOq9T9.js → IntegrationsPage-ClTWLg6W.js} +1 -1
  4. package/dist/assets/{LinearOAuthSettingsPage-DJ3p4Zyh.js → LinearOAuthSettingsPage-CWz5AdGt.js} +1 -1
  5. package/dist/assets/{LinearSettingsPage-EJXrPlao.js → LinearSettingsPage-di0JH5lO.js} +1 -1
  6. package/dist/assets/{Playground-BJi1T7KP.js → Playground-BVqqQ5J3.js} +1 -1
  7. package/dist/assets/{PromptsPage-DiMm5U1r.js → PromptsPage-C1oMpJK9.js} +1 -1
  8. package/dist/assets/{RunsPage-LPrcOaUc.js → RunsPage-B2Z2eRQO.js} +1 -1
  9. package/dist/assets/{SandboxManager-9KiHL5rI.js → SandboxManager-CaBqRrPE.js} +1 -1
  10. package/dist/assets/{SettingsPage-BXy1ZF1F.js → SettingsPage-C4WdQ-ba.js} +1 -1
  11. package/dist/assets/{TailscalePage-ycECxxya.js → TailscalePage-trYybm9W.js} +1 -1
  12. package/dist/assets/index-BCS1TCne.css +1 -0
  13. package/dist/assets/index-BY3_XaK9.js +134 -0
  14. package/dist/assets/{sw-register-Duj0Mw6k.js → sw-register-Cf5LH9-W.js} +1 -1
  15. package/dist/index.html +2 -2
  16. package/dist/sw.js +1 -1
  17. package/package.json +1 -1
  18. package/server/claude-adapter.test.ts +114 -0
  19. package/server/claude-adapter.ts +56 -8
  20. package/server/claude-session-history.ts +15 -0
  21. package/server/routes.ts +20 -0
  22. package/server/session-export.test.ts +169 -0
  23. package/server/session-export.ts +382 -0
  24. package/dist/assets/index-D-JiBkdW.js +0 -134
  25. package/dist/assets/index-DwVmncqT.css +0 -1
@@ -1 +1 @@
1
- import{_ as u}from"./index-D-JiBkdW.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-BY3_XaK9.js";function w(r={}){const{immediate:i=!1,onNeedReload:d,onNeedRefresh:_,onOfflineReady:s,onRegistered:n,onRegisteredSW:o,onRegisterError:t}=r;let a,c;const l=async(e=!0)=>{await c};async function f(){if("serviceWorker"in navigator){if(a=await u(async()=>{const{Workbox:e}=await import("./workbox-window.prod.es5-BBnX5xw4.js");return{Workbox:e}},[]).then(({Workbox:e})=>new e("/sw.js",{scope:"/",type:"classic"})).catch(e=>{t==null||t(e)}),!a)return;a.addEventListener("activated",e=>{(e.isUpdate||e.isExternal)&&(d?d():window.location.reload())}),a.addEventListener("installed",e=>{e.isUpdate||s==null||s()}),a.register({immediate:i}).then(e=>{o?o("/sw.js",e):n==null||n(e)}).catch(e=>{t==null||t(e)})}}return c=f(),l}w({onRegisteredSW(r,i){i&&setInterval(()=>{i.update()},3600*1e3)},onOfflineReady(){console.log("[SW] Offline-ready: all assets precached")}});
package/dist/index.html CHANGED
@@ -11,8 +11,8 @@
11
11
  <meta name="apple-mobile-web-app-title" content="Companion ME" />
12
12
  <meta name="theme-color" content="#d97757" />
13
13
  <title>The Companion — Moritz Edition</title>
14
- <script type="module" crossorigin src="/assets/index-D-JiBkdW.js"></script>
15
- <link rel="stylesheet" crossorigin href="/assets/index-DwVmncqT.css">
14
+ <script type="module" crossorigin src="/assets/index-BY3_XaK9.js"></script>
15
+ <link rel="stylesheet" crossorigin href="/assets/index-BCS1TCne.css">
16
16
  </head>
17
17
  <body class="bg-cc-bg text-cc-fg">
18
18
  <div id="root"></div>
package/dist/sw.js CHANGED
@@ -1,2 +1,2 @@
1
1
  try{self["workbox:core:7.4.0"]&&_()}catch{}const x=(a,...e)=>{let t=a;return e.length>0&&(t+=` :: ${JSON.stringify(e)}`),t},N=x;class l extends Error{constructor(e,t){const s=N(e,t);super(s),this.name=e,this.details=t}}const E=new Set,f={googleAnalytics:"googleAnalytics",precache:"precache-v2",prefix:"workbox",runtime:"runtime",suffix:typeof registration<"u"?registration.scope:""},b=a=>[f.prefix,a,f.suffix].filter(e=>e&&e.length>0).join("-"),O=a=>{for(const e of Object.keys(f))a(e)},C={updateDetails:a=>{O(e=>{typeof a[e]=="string"&&(f[e]=a[e])})},getGoogleAnalyticsName:a=>a||b(f.googleAnalytics),getPrecacheName:a=>a||b(f.precache),getPrefix:()=>f.prefix,getRuntimeName:a=>a||b(f.runtime),getSuffix:()=>f.suffix};function P(a,e){const t=new URL(a);for(const s of e)t.searchParams.delete(s);return t.href}async function I(a,e,t,s){const n=P(e.url,t);if(e.url===n)return a.match(e,s);const r=Object.assign(Object.assign({},s),{ignoreSearch:!0}),c=await a.keys(e,r);for(const i of c){const o=P(i.url,t);if(n===o)return a.match(i,s)}}let y;function M(){if(y===void 0){const a=new Response("");if("body"in a)try{new Response(a.body),y=!0}catch{y=!1}y=!1}return y}class D{constructor(){this.promise=new Promise((e,t)=>{this.resolve=e,this.reject=t})}}async function S(){for(const a of E)await a()}const W=a=>new URL(String(a),location.href).href.replace(new RegExp(`^${location.origin}`),"");function A(a){return new Promise(e=>setTimeout(e,a))}function K(a,e){const t=e();return a.waitUntil(t),t}async function q(a,e){let t=null;if(a.url&&(t=new URL(a.url).origin),t!==self.location.origin)throw new l("cross-origin-copy-response",{origin:t});const s=a.clone(),r={headers:new Headers(s.headers),status:s.status,statusText:s.statusText},c=M()?s.body:await s.blob();return new Response(c,r)}function j(){self.addEventListener("activate",()=>self.clients.claim())}try{self["workbox:precaching:7.4.0"]&&_()}catch{}const H="__WB_REVISION__";function F(a){if(!a)throw new l("add-to-cache-list-unexpected-type",{entry:a});if(typeof a=="string"){const r=new URL(a,location.href);return{cacheKey:r.href,url:r.href}}const{revision:e,url:t}=a;if(!t)throw new l("add-to-cache-list-unexpected-type",{entry:a});if(!e){const r=new URL(t,location.href);return{cacheKey:r.href,url:r.href}}const s=new URL(t,location.href),n=new URL(t,location.href);return s.searchParams.set(H,e),{cacheKey:s.href,url:n.href}}class B{constructor(){this.updatedURLs=[],this.notUpdatedURLs=[],this.handlerWillStart=async({request:e,state:t})=>{t&&(t.originalRequest=e)},this.cachedResponseWillBeUsed=async({event:e,state:t,cachedResponse:s})=>{if(e.type==="install"&&t&&t.originalRequest&&t.originalRequest instanceof Request){const n=t.originalRequest.url;s?this.notUpdatedURLs.push(n):this.updatedURLs.push(n)}return s}}}class ${constructor({precacheController:e}){this.cacheKeyWillBeUsed=async({request:t,params:s})=>{const n=(s==null?void 0:s.cacheKey)||this._precacheController.getCacheKeyForURL(t.url);return n?new Request(n,{headers:t.headers}):t},this._precacheController=e}}try{self["workbox:strategies:7.4.0"]&&_()}catch{}function m(a){return typeof a=="string"?new Request(a):a}class G{constructor(e,t){this._cacheKeys={},Object.assign(this,t),this.event=t.event,this._strategy=e,this._handlerDeferred=new D,this._extendLifetimePromises=[],this._plugins=[...e.plugins],this._pluginStateMap=new Map;for(const s of this._plugins)this._pluginStateMap.set(s,{});this.event.waitUntil(this._handlerDeferred.promise)}async fetch(e){const{event:t}=this;let s=m(e);if(s.mode==="navigate"&&t instanceof FetchEvent&&t.preloadResponse){const c=await t.preloadResponse;if(c)return c}const n=this.hasCallback("fetchDidFail")?s.clone():null;try{for(const c of this.iterateCallbacks("requestWillFetch"))s=await c({request:s.clone(),event:t})}catch(c){if(c instanceof Error)throw new l("plugin-error-request-will-fetch",{thrownErrorMessage:c.message})}const r=s.clone();try{let c;c=await fetch(s,s.mode==="navigate"?void 0:this._strategy.fetchOptions);for(const i of this.iterateCallbacks("fetchDidSucceed"))c=await i({event:t,request:r,response:c});return c}catch(c){throw n&&await this.runCallbacks("fetchDidFail",{error:c,event:t,originalRequest:n.clone(),request:r.clone()}),c}}async fetchAndCachePut(e){const t=await this.fetch(e),s=t.clone();return this.waitUntil(this.cachePut(e,s)),t}async cacheMatch(e){const t=m(e);let s;const{cacheName:n,matchOptions:r}=this._strategy,c=await this.getCacheKey(t,"read"),i=Object.assign(Object.assign({},r),{cacheName:n});s=await caches.match(c,i);for(const o of this.iterateCallbacks("cachedResponseWillBeUsed"))s=await o({cacheName:n,matchOptions:r,cachedResponse:s,request:c,event:this.event})||void 0;return s}async cachePut(e,t){const s=m(e);await A(0);const n=await this.getCacheKey(s,"write");if(!t)throw new l("cache-put-with-no-response",{url:W(n.url)});const r=await this._ensureResponseSafeToCache(t);if(!r)return!1;const{cacheName:c,matchOptions:i}=this._strategy,o=await self.caches.open(c),h=this.hasCallback("cacheDidUpdate"),p=h?await I(o,n.clone(),["__WB_REVISION__"],i):null;try{await o.put(n,h?r.clone():r)}catch(u){if(u instanceof Error)throw u.name==="QuotaExceededError"&&await S(),u}for(const u of this.iterateCallbacks("cacheDidUpdate"))await u({cacheName:c,oldResponse:p,newResponse:r.clone(),request:n,event:this.event});return!0}async getCacheKey(e,t){const s=`${e.url} | ${t}`;if(!this._cacheKeys[s]){let n=e;for(const r of this.iterateCallbacks("cacheKeyWillBeUsed"))n=m(await r({mode:t,request:n,event:this.event,params:this.params}));this._cacheKeys[s]=n}return this._cacheKeys[s]}hasCallback(e){for(const t of this._strategy.plugins)if(e in t)return!0;return!1}async runCallbacks(e,t){for(const s of this.iterateCallbacks(e))await s(t)}*iterateCallbacks(e){for(const t of this._strategy.plugins)if(typeof t[e]=="function"){const s=this._pluginStateMap.get(t);yield r=>{const c=Object.assign(Object.assign({},r),{state:s});return t[e](c)}}}waitUntil(e){return this._extendLifetimePromises.push(e),e}async doneWaiting(){for(;this._extendLifetimePromises.length;){const e=this._extendLifetimePromises.splice(0),s=(await Promise.allSettled(e)).find(n=>n.status==="rejected");if(s)throw s.reason}}destroy(){this._handlerDeferred.resolve(null)}async _ensureResponseSafeToCache(e){let t=e,s=!1;for(const n of this.iterateCallbacks("cacheWillUpdate"))if(t=await n({request:this.request,response:t,event:this.event})||void 0,s=!0,!t)break;return s||t&&t.status!==200&&(t=void 0),t}}class V{constructor(e={}){this.cacheName=C.getRuntimeName(e.cacheName),this.plugins=e.plugins||[],this.fetchOptions=e.fetchOptions,this.matchOptions=e.matchOptions}handle(e){const[t]=this.handleAll(e);return t}handleAll(e){e instanceof FetchEvent&&(e={event:e,request:e.request});const t=e.event,s=typeof e.request=="string"?new Request(e.request):e.request,n="params"in e?e.params:void 0,r=new G(this,{event:t,request:s,params:n}),c=this._getResponse(r,s,t),i=this._awaitComplete(c,r,s,t);return[c,i]}async _getResponse(e,t,s){await e.runCallbacks("handlerWillStart",{event:s,request:t});let n;try{if(n=await this._handle(t,e),!n||n.type==="error")throw new l("no-response",{url:t.url})}catch(r){if(r instanceof Error){for(const c of e.iterateCallbacks("handlerDidError"))if(n=await c({error:r,event:s,request:t}),n)break}if(!n)throw r}for(const r of e.iterateCallbacks("handlerWillRespond"))n=await r({event:s,request:t,response:n});return n}async _awaitComplete(e,t,s,n){let r,c;try{r=await e}catch{}try{await t.runCallbacks("handlerDidRespond",{event:n,request:s,response:r}),await t.doneWaiting()}catch(i){i instanceof Error&&(c=i)}if(await t.runCallbacks("handlerDidComplete",{event:n,request:s,response:r,error:c}),t.destroy(),c)throw c}}class d extends V{constructor(e={}){e.cacheName=C.getPrecacheName(e.cacheName),super(e),this._fallbackToNetwork=e.fallbackToNetwork!==!1,this.plugins.push(d.copyRedirectedCacheableResponsesPlugin)}async _handle(e,t){const s=await t.cacheMatch(e);return s||(t.event&&t.event.type==="install"?await this._handleInstall(e,t):await this._handleFetch(e,t))}async _handleFetch(e,t){let s;const n=t.params||{};if(this._fallbackToNetwork){const r=n.integrity,c=e.integrity,i=!c||c===r;s=await t.fetch(new Request(e,{integrity:e.mode!=="no-cors"?c||r:void 0})),r&&i&&e.mode!=="no-cors"&&(this._useDefaultCacheabilityPluginIfNeeded(),await t.cachePut(e,s.clone()))}else throw new l("missing-precache-entry",{cacheName:this.cacheName,url:e.url});return s}async _handleInstall(e,t){this._useDefaultCacheabilityPluginIfNeeded();const s=await t.fetch(e);if(!await t.cachePut(e,s.clone()))throw new l("bad-precaching-response",{url:e.url,status:s.status});return s}_useDefaultCacheabilityPluginIfNeeded(){let e=null,t=0;for(const[s,n]of this.plugins.entries())n!==d.copyRedirectedCacheableResponsesPlugin&&(n===d.defaultPrecacheCacheabilityPlugin&&(e=s),n.cacheWillUpdate&&t++);t===0?this.plugins.push(d.defaultPrecacheCacheabilityPlugin):t>1&&e!==null&&this.plugins.splice(e,1)}}d.defaultPrecacheCacheabilityPlugin={async cacheWillUpdate({response:a}){return!a||a.status>=400?null:a}};d.copyRedirectedCacheableResponsesPlugin={async cacheWillUpdate({response:a}){return a.redirected?await q(a):a}};class Q{constructor({cacheName:e,plugins:t=[],fallbackToNetwork:s=!0}={}){this._urlsToCacheKeys=new Map,this._urlsToCacheModes=new Map,this._cacheKeysToIntegrities=new Map,this._strategy=new d({cacheName:C.getPrecacheName(e),plugins:[...t,new $({precacheController:this})],fallbackToNetwork:s}),this.install=this.install.bind(this),this.activate=this.activate.bind(this)}get strategy(){return this._strategy}precache(e){this.addToCacheList(e),this._installAndActiveListenersAdded||(self.addEventListener("install",this.install),self.addEventListener("activate",this.activate),this._installAndActiveListenersAdded=!0)}addToCacheList(e){const t=[];for(const s of e){typeof s=="string"?t.push(s):s&&s.revision===void 0&&t.push(s.url);const{cacheKey:n,url:r}=F(s),c=typeof s!="string"&&s.revision?"reload":"default";if(this._urlsToCacheKeys.has(r)&&this._urlsToCacheKeys.get(r)!==n)throw new l("add-to-cache-list-conflicting-entries",{firstEntry:this._urlsToCacheKeys.get(r),secondEntry:n});if(typeof s!="string"&&s.integrity){if(this._cacheKeysToIntegrities.has(n)&&this._cacheKeysToIntegrities.get(n)!==s.integrity)throw new l("add-to-cache-list-conflicting-integrities",{url:r});this._cacheKeysToIntegrities.set(n,s.integrity)}if(this._urlsToCacheKeys.set(r,n),this._urlsToCacheModes.set(r,c),t.length>0){const i=`Workbox is precaching URLs without revision info: ${t.join(", ")}
2
- This is generally NOT safe. Learn more at https://bit.ly/wb-precache`;console.warn(i)}}}install(e){return K(e,async()=>{const t=new B;this.strategy.plugins.push(t);for(const[r,c]of this._urlsToCacheKeys){const i=this._cacheKeysToIntegrities.get(c),o=this._urlsToCacheModes.get(r),h=new Request(r,{integrity:i,cache:o,credentials:"same-origin"});await Promise.all(this.strategy.handleAll({params:{cacheKey:c},request:h,event:e}))}const{updatedURLs:s,notUpdatedURLs:n}=t;return{updatedURLs:s,notUpdatedURLs:n}})}activate(e){return K(e,async()=>{const t=await self.caches.open(this.strategy.cacheName),s=await t.keys(),n=new Set(this._urlsToCacheKeys.values()),r=[];for(const c of s)n.has(c.url)||(await t.delete(c),r.push(c.url));return{deletedURLs:r}})}getURLsToCacheKeys(){return this._urlsToCacheKeys}getCachedURLs(){return[...this._urlsToCacheKeys.keys()]}getCacheKeyForURL(e){const t=new URL(e,location.href);return this._urlsToCacheKeys.get(t.href)}getIntegrityForCacheKey(e){return this._cacheKeysToIntegrities.get(e)}async matchPrecache(e){const t=e instanceof Request?e.url:e,s=this.getCacheKeyForURL(t);if(s)return(await self.caches.open(this.strategy.cacheName)).match(s)}createHandlerBoundToURL(e){const t=this.getCacheKeyForURL(e);if(!t)throw new l("non-precached-url",{url:e});return s=>(s.request=new Request(e),s.params=Object.assign({cacheKey:t},s.params),this.strategy.handle(s))}}let U;const L=()=>(U||(U=new Q),U);try{self["workbox:routing:7.4.0"]&&_()}catch{}const v="GET",R=a=>a&&typeof a=="object"?a:{handle:a};class g{constructor(e,t,s=v){this.handler=R(t),this.match=e,this.method=s}setCatchHandler(e){this.catchHandler=R(e)}}class z extends g{constructor(e,t,s){const n=({url:r})=>{const c=e.exec(r.href);if(c&&!(r.origin!==location.origin&&c.index!==0))return c.slice(1)};super(n,t,s)}}class J{constructor(){this._routes=new Map,this._defaultHandlerMap=new Map}get routes(){return this._routes}addFetchListener(){self.addEventListener("fetch",(e=>{const{request:t}=e,s=this.handleRequest({request:t,event:e});s&&e.respondWith(s)}))}addCacheListener(){self.addEventListener("message",(e=>{if(e.data&&e.data.type==="CACHE_URLS"){const{payload:t}=e.data,s=Promise.all(t.urlsToCache.map(n=>{typeof n=="string"&&(n=[n]);const r=new Request(...n);return this.handleRequest({request:r,event:e})}));e.waitUntil(s),e.ports&&e.ports[0]&&s.then(()=>e.ports[0].postMessage(!0))}}))}handleRequest({request:e,event:t}){const s=new URL(e.url,location.href);if(!s.protocol.startsWith("http"))return;const n=s.origin===location.origin,{params:r,route:c}=this.findMatchingRoute({event:t,request:e,sameOrigin:n,url:s});let i=c&&c.handler;const o=e.method;if(!i&&this._defaultHandlerMap.has(o)&&(i=this._defaultHandlerMap.get(o)),!i)return;let h;try{h=i.handle({url:s,request:e,event:t,params:r})}catch(u){h=Promise.reject(u)}const p=c&&c.catchHandler;return h instanceof Promise&&(this._catchHandler||p)&&(h=h.catch(async u=>{if(p)try{return await p.handle({url:s,request:e,event:t,params:r})}catch(k){k instanceof Error&&(u=k)}if(this._catchHandler)return this._catchHandler.handle({url:s,request:e,event:t});throw u})),h}findMatchingRoute({url:e,sameOrigin:t,request:s,event:n}){const r=this._routes.get(s.method)||[];for(const c of r){let i;const o=c.match({url:e,sameOrigin:t,request:s,event:n});if(o)return i=o,(Array.isArray(i)&&i.length===0||o.constructor===Object&&Object.keys(o).length===0||typeof o=="boolean")&&(i=void 0),{route:c,params:i}}return{}}setDefaultHandler(e,t=v){this._defaultHandlerMap.set(t,R(e))}setCatchHandler(e){this._catchHandler=R(e)}registerRoute(e){this._routes.has(e.method)||this._routes.set(e.method,[]),this._routes.get(e.method).push(e)}unregisterRoute(e){if(!this._routes.has(e.method))throw new l("unregister-route-but-not-found-with-method",{method:e.method});const t=this._routes.get(e.method).indexOf(e);if(t>-1)this._routes.get(e.method).splice(t,1);else throw new l("unregister-route-route-not-registered")}}let w;const X=()=>(w||(w=new J,w.addFetchListener(),w.addCacheListener()),w);function T(a,e,t){let s;if(typeof a=="string"){const r=new URL(a,location.href),c=({url:i})=>i.href===r.href;s=new g(c,e,t)}else if(a instanceof RegExp)s=new z(a,e,t);else if(typeof a=="function")s=new g(a,e,t);else if(a instanceof g)s=a;else throw new l("unsupported-route-type",{moduleName:"workbox-routing",funcName:"registerRoute",paramName:"capture"});return X().registerRoute(s),s}function Y(a,e=[]){for(const t of[...a.searchParams.keys()])e.some(s=>s.test(t))&&a.searchParams.delete(t);return a}function*Z(a,{ignoreURLParametersMatching:e=[/^utm_/,/^fbclid$/],directoryIndex:t="index.html",cleanURLs:s=!0,urlManipulation:n}={}){const r=new URL(a,location.href);r.hash="",yield r.href;const c=Y(r,e);if(yield c.href,t&&c.pathname.endsWith("/")){const i=new URL(c.href);i.pathname+=t,yield i.href}if(s){const i=new URL(c.href);i.pathname+=".html",yield i.href}if(n){const i=n({url:r});for(const o of i)yield o.href}}class ee extends g{constructor(e,t){const s=({request:n})=>{const r=e.getURLsToCacheKeys();for(const c of Z(n.url,t)){const i=r.get(c);if(i){const o=e.getIntegrityForCacheKey(i);return{cacheKey:i,integrity:o}}}};super(s,e.strategy)}}function te(a){const e=L(),t=new ee(e,a);T(t)}const se="-precache-",ae=async(a,e=se)=>{const s=(await self.caches.keys()).filter(n=>n.includes(e)&&n.includes(self.registration.scope)&&n!==a);return await Promise.all(s.map(n=>self.caches.delete(n))),s};function ne(){self.addEventListener("activate",(a=>{const e=C.getPrecacheName();a.waitUntil(ae(e).then(t=>{}))}))}function re(a){return L().createHandlerBoundToURL(a)}function ce(a){L().precache(a)}function ie(a,e){ce(a),te(e)}class oe extends g{constructor(e,{allowlist:t=[/./],denylist:s=[]}={}){super(n=>this._match(n),e),this._allowlist=t,this._denylist=s}_match({url:e,request:t}){if(t&&t.mode!=="navigate")return!1;const s=e.pathname+e.search;for(const n of this._denylist)if(n.test(s))return!1;return!!this._allowlist.some(n=>n.test(s))}}self.skipWaiting();j();ie([{"revision":"fab346dacb0b9b9af12554e9a59f004d","url":"logo.svg"},{"revision":"623840baec64a3438663496bb1050d30","url":"logo-docker.svg"},{"revision":"c5c8611b432190ec28522f1a2dfcbb7f","url":"logo-codex.svg"},{"revision":"d4530f6e8502ef256c441896d250e8be","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-Duj0Mw6k.js"},{"revision":null,"url":"assets/index-DwVmncqT.css"},{"revision":null,"url":"assets/index-D-JiBkdW.js"},{"revision":null,"url":"assets/TailscalePage-ycECxxya.js"},{"revision":null,"url":"assets/SettingsPage-BXy1ZF1F.js"},{"revision":null,"url":"assets/SandboxManager-9KiHL5rI.js"},{"revision":null,"url":"assets/RunsPage-LPrcOaUc.js"},{"revision":null,"url":"assets/PromptsPage-DiMm5U1r.js"},{"revision":null,"url":"assets/Playground-BJi1T7KP.js"},{"revision":null,"url":"assets/LinearSettingsPage-EJXrPlao.js"},{"revision":null,"url":"assets/LinearOAuthSettingsPage-DJ3p4Zyh.js"},{"revision":null,"url":"assets/IntegrationsPage-DhiOq9T9.js"},{"revision":null,"url":"assets/CronManager-fsEUcByi.js"},{"revision":null,"url":"assets/AgentsPage-7oHDiJoh.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":"87901a67cbf3de1c275ce0d12fa0fc0a","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-Cf5LH9-W.js"},{"revision":null,"url":"assets/index-BY3_XaK9.js"},{"revision":null,"url":"assets/index-BCS1TCne.css"},{"revision":null,"url":"assets/TailscalePage-trYybm9W.js"},{"revision":null,"url":"assets/SettingsPage-C4WdQ-ba.js"},{"revision":null,"url":"assets/SandboxManager-CaBqRrPE.js"},{"revision":null,"url":"assets/RunsPage-B2Z2eRQO.js"},{"revision":null,"url":"assets/PromptsPage-C1oMpJK9.js"},{"revision":null,"url":"assets/Playground-BVqqQ5J3.js"},{"revision":null,"url":"assets/LinearSettingsPage-di0JH5lO.js"},{"revision":null,"url":"assets/LinearOAuthSettingsPage-CWz5AdGt.js"},{"revision":null,"url":"assets/IntegrationsPage-ClTWLg6W.js"},{"revision":null,"url":"assets/CronManager-DRZ66TkG.js"},{"revision":null,"url":"assets/AgentsPage-BhKdcyXZ.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.100.0",
3
+ "version": "0.101.0",
4
4
  "type": "module",
5
5
  "description": "Web UI for launching and interacting with Claude Code agents — Moritz Edition (fork of the-companion)",
6
6
  "license": "MIT",
@@ -1440,3 +1440,117 @@ describe("prompt_suggestion", () => {
1440
1440
  expect(emitted.suggestions).toEqual(["Fix the bug", "Add tests"]);
1441
1441
  });
1442
1442
  });
1443
+
1444
+ // ─── Stdio transport disconnect propagation ──────────────────────────────────
1445
+ //
1446
+ // Regression coverage for the "endless generating, no output" bug: when the
1447
+ // stdio (stream-json) transport dies, the adapter must fire its disconnect
1448
+ // callback so the bridge can show the reconnect banner and trigger relaunch.
1449
+ // Previously the stdio paths only flipped `stdioConnected = false` silently,
1450
+ // leaving the bridge believing the backend was still attached.
1451
+
1452
+ /**
1453
+ * Build a minimal mock of Bun's Subprocess driving the stdio transport.
1454
+ * - `stdout` is a controllable ReadableStream (call `endStdout()` to close it).
1455
+ * - `exited` resolves when `resolveExit()` is called.
1456
+ * - `kill()` is a spy that flips `killed` and resolves `exited`.
1457
+ */
1458
+ function createMockProc() {
1459
+ let stdoutController: ReadableStreamDefaultController<Uint8Array>;
1460
+ const stdout = new ReadableStream<Uint8Array>({
1461
+ start(controller) {
1462
+ stdoutController = controller;
1463
+ },
1464
+ });
1465
+
1466
+ const stdinWrites: Uint8Array[] = [];
1467
+ const stdin = { write: (data: Uint8Array) => (stdinWrites.push(data), data.length) };
1468
+
1469
+ let resolveExit!: () => void;
1470
+ const exited = new Promise<number>((resolve) => {
1471
+ resolveExit = () => resolve(0);
1472
+ });
1473
+
1474
+ const proc = {
1475
+ pid: 4242,
1476
+ stdin,
1477
+ stdout,
1478
+ exitCode: null as number | null,
1479
+ killed: false,
1480
+ kill: vi.fn(() => {
1481
+ proc.killed = true;
1482
+ proc.exitCode = 0;
1483
+ resolveExit();
1484
+ }),
1485
+ exited,
1486
+ };
1487
+
1488
+ return {
1489
+ proc: proc as any,
1490
+ endStdout: () => stdoutController.close(),
1491
+ resolveExit: () => {
1492
+ proc.exitCode = 0;
1493
+ resolveExit();
1494
+ },
1495
+ };
1496
+ }
1497
+
1498
+ describe("stdio transport disconnect propagation", () => {
1499
+ it("fires disconnect and kills a lingering process when stdout closes while alive", async () => {
1500
+ // The wedge case: the worker stops emitting (e.g. after a 429) and closes
1501
+ // stdout but the process keeps running. The adapter must kill it (so the
1502
+ // relaunch path's PID-liveness guard is cleared) and report disconnect.
1503
+ const { proc, endStdout } = createMockProc();
1504
+ adapter.attachStdio(proc);
1505
+ expect(adapter.isConnected()).toBe(true);
1506
+
1507
+ endStdout();
1508
+ // Let the async stdout reader observe the close and run its finally block.
1509
+ await new Promise((r) => setTimeout(r, 0));
1510
+
1511
+ expect(proc.kill).toHaveBeenCalledTimes(1);
1512
+ expect(adapter.isConnected()).toBe(false);
1513
+ expect(disconnectCb).toHaveBeenCalledTimes(1);
1514
+ });
1515
+
1516
+ it("fires disconnect on process exit", async () => {
1517
+ const { proc, resolveExit } = createMockProc();
1518
+ adapter.attachStdio(proc);
1519
+
1520
+ resolveExit();
1521
+ await proc.exited;
1522
+ await new Promise((r) => setTimeout(r, 0));
1523
+
1524
+ expect(adapter.isConnected()).toBe(false);
1525
+ expect(disconnectCb).toHaveBeenCalledTimes(1);
1526
+ });
1527
+
1528
+ it("fires the disconnect callback at most once across both teardown paths", async () => {
1529
+ // Both the stdout-reader finally AND proc.exited can trip; the disconnectCb
1530
+ // must still fire exactly once (the bridge dedupes relaunch, but the UI
1531
+ // banner/state transition should not flap).
1532
+ const { proc, endStdout, resolveExit } = createMockProc();
1533
+ adapter.attachStdio(proc);
1534
+
1535
+ endStdout();
1536
+ resolveExit();
1537
+ await proc.exited;
1538
+ await new Promise((r) => setTimeout(r, 0));
1539
+
1540
+ expect(disconnectCb).toHaveBeenCalledTimes(1);
1541
+ });
1542
+
1543
+ it("does not kill a process that has already exited", async () => {
1544
+ // If the process exited on its own, stdout closes too — but we must not
1545
+ // call kill() on an already-dead process.
1546
+ const { proc, endStdout } = createMockProc();
1547
+ adapter.attachStdio(proc);
1548
+
1549
+ proc.exitCode = 0; // simulate process already exited before stdout drains
1550
+ endStdout();
1551
+ await new Promise((r) => setTimeout(r, 0));
1552
+
1553
+ expect(proc.kill).not.toHaveBeenCalled();
1554
+ expect(disconnectCb).toHaveBeenCalledTimes(1);
1555
+ });
1556
+ });
@@ -75,6 +75,13 @@ export class ClaudeAdapter implements IBackendAdapter {
75
75
  private stdioBuffer = "";
76
76
  /** Whether the one-time `initialize` control_request handshake has been sent. */
77
77
  private stdioInitialized = false;
78
+ /** The spawned child process (stdio transport), so we can detect/kill a
79
+ * process that lingers after its stdout transport has died. */
80
+ private stdioProc: Subprocess | null = null;
81
+ /** Guard so the disconnect callback fires at most once per transport, no
82
+ * matter which teardown path (stdout reader end, reader error, or process
83
+ * exit) trips first. Mirrors CodexAdapter.disconnectFired. */
84
+ private disconnectFired = false;
78
85
 
79
86
  // Callbacks registered by the bridge via on*() methods
80
87
  private browserMessageCb: ((msg: BrowserIncomingMessage) => void) | null = null;
@@ -182,6 +189,8 @@ export class ClaudeAdapter implements IBackendAdapter {
182
189
  attachStdio(proc: Subprocess): void {
183
190
  this.transportKind = "stdio";
184
191
  this.stdioConnected = true;
192
+ this.disconnectFired = false;
193
+ this.stdioProc = proc;
185
194
 
186
195
  // Wrap Bun's FileSink stdin (which exposes a synchronous `.write()`) in a
187
196
  // WritableStream and hold a single writer — matches the proven CodexAdapter
@@ -215,16 +224,36 @@ export class ClaudeAdapter implements IBackendAdapter {
215
224
  }
216
225
  }
217
226
 
218
- // Mark the transport gone on process exit. Relaunch is intentionally NOT
219
- // driven from here — the launcher emits `session:exited`, and the
220
- // orchestrator's proactive keepalive relaunches with `--resume`. This
221
- // mirrors the Codex stdio path and avoids double-relaunch.
227
+ // Mark the transport gone on process exit and notify the bridge. The
228
+ // launcher independently emits `session:exited` (driving the proactive
229
+ // `--resume` relaunch); the disconnect callback here drives the bridge's
230
+ // reconnecting/`cli_disconnected` flow (reconnect banner). Both relaunch
231
+ // requests are de-duplicated by the orchestrator's relaunchingSet, so this
232
+ // does not double-relaunch. Mirrors the Codex stdio path
233
+ // (`proc.exited` → `cleanupAndDisconnect`).
222
234
  proc.exited.then(() => {
223
- this.stdioConnected = false;
224
- this.stdioWriter = null;
235
+ this.notifyStdioDisconnect();
225
236
  });
226
237
  }
227
238
 
239
+ /**
240
+ * Mark the stdio transport gone and fire the disconnect callback exactly
241
+ * once. Routed (by the bridge's `onDisconnect` handler) into the same
242
+ * recovery flow as a WebSocket drop: transition to "reconnecting", broadcast
243
+ * `cli_disconnected` (so the UI shows the reconnect banner), and request an
244
+ * auto-relaunch. Without this, a dead stdio transport left `isConnected()`
245
+ * false while the bridge still believed the backend was attached — every
246
+ * browser message queued forever ("Backend not connected") and the UI span
247
+ * an endless "generating" spinner with no banner.
248
+ */
249
+ private notifyStdioDisconnect(): void {
250
+ this.stdioConnected = false;
251
+ this.stdioWriter = null;
252
+ if (this.disconnectFired) return;
253
+ this.disconnectFired = true;
254
+ this.disconnectCb?.();
255
+ }
256
+
228
257
  /** Line-buffered stdout reader: splits NDJSON and routes complete lines. */
229
258
  private async readStdioStdout(stdout: ReadableStream<Uint8Array>): Promise<void> {
230
259
  const reader = stdout.getReader();
@@ -247,7 +276,26 @@ export class ClaudeAdapter implements IBackendAdapter {
247
276
  error: err instanceof Error ? err.message : String(err),
248
277
  });
249
278
  } finally {
250
- this.stdioConnected = false;
279
+ // The stdout stream ended: the stream-json transport is dead and cannot
280
+ // be revived on this process. If the child somehow lingers (e.g. it
281
+ // stopped emitting after a fatal API error such as a 429 but never
282
+ // exited), kill it so `proc.exited` resolves and the launcher's
283
+ // `session:exited` → proactive `--resume` relaunch can recover it. Left
284
+ // alive, the process would keep `handleAutoRelaunch`'s PID-liveness guard
285
+ // satisfied, blocking every relaunch path indefinitely.
286
+ const proc = this.stdioProc;
287
+ if (proc && proc.exitCode === null && !proc.killed) {
288
+ log.warn("claude-adapter", "stdout closed while process still alive; killing stale process", {
289
+ sessionId: this.sessionId,
290
+ pid: proc.pid,
291
+ });
292
+ try {
293
+ proc.kill();
294
+ } catch {
295
+ // Process may have exited between the check and the kill.
296
+ }
297
+ }
298
+ this.notifyStdioDisconnect();
251
299
  }
252
300
  }
253
301
 
@@ -330,7 +378,7 @@ export class ClaudeAdapter implements IBackendAdapter {
330
378
  */
331
379
  handleTransportClose(): void {
332
380
  if (this.transportKind === "stdio") {
333
- this.stdioConnected = false;
381
+ this.notifyStdioDisconnect();
334
382
  return;
335
383
  }
336
384
  this.cliSocket = null;
@@ -408,3 +408,18 @@ export function getClaudeSessionHistoryPage(
408
408
  export function clearClaudeSessionHistoryCacheForTests(): void {
409
409
  parsedHistoryCache.clear();
410
410
  }
411
+
412
+ /**
413
+ * Resolve the on-disk Claude history `.jsonl` file for a session, if any.
414
+ * Exposed so the export feature can read the raw transcript (including image
415
+ * content blocks, which the paginated history view strips for the UI).
416
+ */
417
+ export function resolveClaudeSessionFilePath(
418
+ sessionId: string,
419
+ projectsRoot?: string,
420
+ ): string | null {
421
+ const trimmed = sessionId.trim();
422
+ if (!trimmed) return null;
423
+ const resolved = resolveSessionSourceFile(trimmed, getProjectsRoot(projectsRoot));
424
+ return resolved ? resolved.sourceFile : null;
425
+ }
package/server/routes.ts CHANGED
@@ -37,6 +37,7 @@ import { registerLinearOAuthConnectionRoutes } from "./routes/linear-oauth-conne
37
37
  import { getSettings } from "./settings-manager.js";
38
38
  import { discoverClaudeSessions } from "./claude-session-discovery.js";
39
39
  import { getClaudeSessionHistoryPage } from "./claude-session-history.js";
40
+ import { buildSessionExport } from "./session-export.js";
40
41
  import { verifyToken, getToken, regenerateToken, getAllAddresses } from "./auth-manager.js";
41
42
  import QRCode from "qrcode";
42
43
  import { VSCODE_EDITOR_CONTAINER_PORT, NOVNC_CONTAINER_PORT } from "./constants.js";
@@ -284,6 +285,25 @@ export function createRoutes(
284
285
  return c.json(page);
285
286
  });
286
287
 
288
+ api.get("/sessions/:id/export", (c) => {
289
+ const id = c.req.param("id");
290
+ const formatRaw = (c.req.query("format") || "html").toLowerCase();
291
+ const format = formatRaw === "txt" ? "txt" : "html";
292
+ const session = launcher.getSession(id);
293
+ // History files are keyed by the Claude CLI session id; fall back to the
294
+ // companion id in case the caller passed a raw Claude session id.
295
+ const claudeId = session?.cliSessionId || id;
296
+ const title =
297
+ sessionNames.getName(id) || session?.name || `Session ${id.slice(0, 8)}`;
298
+ const result = buildSessionExport({ sessionId: claudeId, format, title });
299
+ if (!result) {
300
+ return c.json({ error: "Session history not found" }, 404);
301
+ }
302
+ c.header("Content-Type", result.contentType);
303
+ c.header("Content-Disposition", `attachment; filename="${result.filename}"`);
304
+ return c.body(result.body);
305
+ });
306
+
287
307
  api.post("/sessions/:id/editor/start", async (c) => {
288
308
  const id = c.req.param("id");
289
309
  const session = launcher.getSession(id);
@@ -0,0 +1,169 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { tmpdir } from "node:os";
5
+ import { buildSessionExport } from "./session-export.js";
6
+
7
+ // ─── Fixtures ────────────────────────────────────────────────────────────────
8
+ //
9
+ // buildSessionExport() reads the raw Claude transcript `.jsonl` from disk via
10
+ // resolveClaudeSessionFilePath(), which scans `<projectsRoot>/<project>/<id>.jsonl`
11
+ // and picks the newest match. These tests build a throwaway projects root so the
12
+ // parser + HTML/TXT renderers can be exercised end-to-end, including image
13
+ // recovery (the whole reason this path reads raw JSONL instead of the paginated
14
+ // history view, which strips image blocks).
15
+
16
+ const SESSION_ID = "cli-session-abcdef";
17
+ // A tiny 1x1 PNG, base64. Used to assert images embed (HTML) vs. placeholder (TXT).
18
+ const PNG_B64 =
19
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==";
20
+
21
+ let projectsRoot: string;
22
+
23
+ /** Write a transcript made of raw JSONL lines (one stringified object per line). */
24
+ function writeTranscript(lines: object[]): void {
25
+ const projectDir = join(projectsRoot, "-some-project");
26
+ mkdirSync(projectDir, { recursive: true });
27
+ const body = lines.map((l) => JSON.stringify(l)).join("\n") + "\n";
28
+ writeFileSync(join(projectDir, `${SESSION_ID}.jsonl`), body, "utf8");
29
+ }
30
+
31
+ function userText(text: string, timestamp = "2026-06-11T12:00:00.000Z") {
32
+ return { type: "user", timestamp, message: { role: "user", content: [{ type: "text", text }] } };
33
+ }
34
+
35
+ beforeEach(() => {
36
+ projectsRoot = mkdtempSync(join(tmpdir(), "companion-export-test-"));
37
+ });
38
+
39
+ afterEach(() => {
40
+ rmSync(projectsRoot, { recursive: true, force: true });
41
+ });
42
+
43
+ describe("buildSessionExport", () => {
44
+ it("returns null when no transcript file exists for the session", () => {
45
+ const result = buildSessionExport({ sessionId: "missing", format: "html", title: "X", projectsRoot });
46
+ expect(result).toBeNull();
47
+ });
48
+
49
+ it("renders HTML with embedded images, thinking, tool calls and metadata", () => {
50
+ writeTranscript([
51
+ userText("Hello there"),
52
+ {
53
+ type: "assistant",
54
+ timestamp: "2026-06-11T12:00:05.000Z",
55
+ message: {
56
+ role: "assistant",
57
+ content: [
58
+ { type: "thinking", thinking: "Let me reason about this" },
59
+ { type: "text", text: "Hi! Here is your answer." },
60
+ { type: "tool_use", name: "Bash", input: { command: "ls -la" } },
61
+ ],
62
+ },
63
+ },
64
+ {
65
+ type: "user",
66
+ timestamp: "2026-06-11T12:00:06.000Z",
67
+ message: {
68
+ role: "user",
69
+ content: [
70
+ {
71
+ type: "tool_result",
72
+ content: [
73
+ { type: "text", text: "total 0" },
74
+ { type: "image", source: { type: "base64", media_type: "image/png", data: PNG_B64 } },
75
+ ],
76
+ },
77
+ ],
78
+ },
79
+ },
80
+ ]);
81
+
82
+ const result = buildSessionExport({
83
+ sessionId: SESSION_ID,
84
+ format: "html",
85
+ title: "My Session",
86
+ projectsRoot,
87
+ });
88
+
89
+ expect(result).not.toBeNull();
90
+ expect(result!.contentType).toBe("text/html; charset=utf-8");
91
+ expect(result!.filename).toMatch(/^My-Session-\d{4}-\d{2}-\d{2}\.html$/);
92
+
93
+ const html = result!.body;
94
+ expect(html.startsWith("<!doctype html>")).toBe(true);
95
+ expect(html).toContain("<title>My Session</title>");
96
+ expect(html).toContain("Hello there");
97
+ expect(html).toContain("Hi! Here is your answer.");
98
+ // Thinking is rendered as a collapsible <details> block.
99
+ expect(html).toContain("<details class=\"thinking\">");
100
+ expect(html).toContain("Let me reason about this");
101
+ // Tool call name + input present.
102
+ expect(html).toContain("Bash");
103
+ expect(html).toContain("ls -la");
104
+ // Image embedded inline as a data: URI (the key feature of HTML export).
105
+ expect(html).toContain(`data:image/png;base64,${PNG_B64}`);
106
+ // Header reports the image count.
107
+ expect(html).toContain("1 image");
108
+ });
109
+
110
+ it("renders TXT with image placeholders instead of embedded data", () => {
111
+ writeTranscript([
112
+ userText("Plain please"),
113
+ {
114
+ type: "user",
115
+ timestamp: "2026-06-11T12:00:06.000Z",
116
+ message: {
117
+ role: "user",
118
+ content: [{ type: "image", source: { type: "base64", media_type: "image/png", data: PNG_B64 } }],
119
+ },
120
+ },
121
+ ]);
122
+
123
+ const result = buildSessionExport({
124
+ sessionId: SESSION_ID,
125
+ format: "txt",
126
+ title: "My Session",
127
+ projectsRoot,
128
+ });
129
+
130
+ expect(result).not.toBeNull();
131
+ expect(result!.contentType).toBe("text/plain; charset=utf-8");
132
+ expect(result!.filename).toMatch(/\.txt$/);
133
+ const txt = result!.body;
134
+ expect(txt).toContain("Plain please");
135
+ // No base64 payload in text output — only a size-annotated placeholder.
136
+ expect(txt).not.toContain(PNG_B64);
137
+ expect(txt).toMatch(/\[image: image\/png, ~\d+ bytes\]/);
138
+ });
139
+
140
+ it("escapes HTML in message content to prevent markup injection", () => {
141
+ writeTranscript([userText("<script>alert('xss')</script> & \"quotes\"")]);
142
+ const result = buildSessionExport({ sessionId: SESSION_ID, format: "html", title: "T", projectsRoot });
143
+ const html = result!.body;
144
+ expect(html).toContain("&lt;script&gt;");
145
+ expect(html).not.toContain("<script>alert");
146
+ });
147
+
148
+ it("filters out slash-command noise lines from the transcript", () => {
149
+ writeTranscript([
150
+ userText("<command-name>/btw</command-name>"),
151
+ userText("real user message"),
152
+ ]);
153
+ const result = buildSessionExport({ sessionId: SESSION_ID, format: "txt", title: "T", projectsRoot });
154
+ expect(result!.body).toContain("real user message");
155
+ expect(result!.body).not.toContain("/btw");
156
+ });
157
+
158
+ it("sanitizes the title into a safe filename, falling back when empty", () => {
159
+ writeTranscript([userText("hi")]);
160
+ const result = buildSessionExport({
161
+ sessionId: SESSION_ID,
162
+ format: "html",
163
+ title: " ??? !!! ",
164
+ projectsRoot,
165
+ });
166
+ // All unsafe chars stripped → falls back to the "session" base name.
167
+ expect(result!.filename).toMatch(/^session-\d{4}-\d{2}-\d{2}\.html$/);
168
+ });
169
+ });