@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.
- package/dist/assets/{AgentsPage-7oHDiJoh.js → AgentsPage-BhKdcyXZ.js} +1 -1
- package/dist/assets/{CronManager-fsEUcByi.js → CronManager-DRZ66TkG.js} +1 -1
- package/dist/assets/{IntegrationsPage-DhiOq9T9.js → IntegrationsPage-ClTWLg6W.js} +1 -1
- package/dist/assets/{LinearOAuthSettingsPage-DJ3p4Zyh.js → LinearOAuthSettingsPage-CWz5AdGt.js} +1 -1
- package/dist/assets/{LinearSettingsPage-EJXrPlao.js → LinearSettingsPage-di0JH5lO.js} +1 -1
- package/dist/assets/{Playground-BJi1T7KP.js → Playground-BVqqQ5J3.js} +1 -1
- package/dist/assets/{PromptsPage-DiMm5U1r.js → PromptsPage-C1oMpJK9.js} +1 -1
- package/dist/assets/{RunsPage-LPrcOaUc.js → RunsPage-B2Z2eRQO.js} +1 -1
- package/dist/assets/{SandboxManager-9KiHL5rI.js → SandboxManager-CaBqRrPE.js} +1 -1
- package/dist/assets/{SettingsPage-BXy1ZF1F.js → SettingsPage-C4WdQ-ba.js} +1 -1
- package/dist/assets/{TailscalePage-ycECxxya.js → TailscalePage-trYybm9W.js} +1 -1
- package/dist/assets/index-BCS1TCne.css +1 -0
- package/dist/assets/index-BY3_XaK9.js +134 -0
- package/dist/assets/{sw-register-Duj0Mw6k.js → sw-register-Cf5LH9-W.js} +1 -1
- package/dist/index.html +2 -2
- package/dist/sw.js +1 -1
- package/package.json +1 -1
- package/server/claude-adapter.test.ts +114 -0
- package/server/claude-adapter.ts +56 -8
- package/server/claude-session-history.ts +15 -0
- package/server/routes.ts +20 -0
- package/server/session-export.test.ts +169 -0
- package/server/session-export.ts +382 -0
- package/dist/assets/index-D-JiBkdW.js +0 -134
- package/dist/assets/index-DwVmncqT.css +0 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
import{_ as u}from"./index-
|
|
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-
|
|
15
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
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":"
|
|
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
|
@@ -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
|
+
});
|
package/server/claude-adapter.ts
CHANGED
|
@@ -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
|
|
219
|
-
//
|
|
220
|
-
//
|
|
221
|
-
//
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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("<script>");
|
|
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
|
+
});
|