a5bf653a47
On Windows, Bun doesn't properly release socket handles when the worker process exits, causing "zombie ports" that remain bound even after all processes are dead. This required a system reboot to clear. Solution: Introduce a wrapper process (worker-wrapper.cjs) that: - Spawns the actual worker as a child with IPC channel - On restart/shutdown, uses `taskkill /T /F` to kill the entire process tree - Exits itself, allowing hooks to start fresh The wrapper has no sockets, so Bun's socket cleanup bug doesn't affect it. When the wrapper kills the inner worker tree and exits, the port is properly released and can be immediately reused. Key changes: - New worker-wrapper.ts for Windows process lifecycle management - ProcessManager starts wrapper on Windows, worker directly on Unix - Worker sends IPC messages to wrapper for restart/shutdown - Health endpoint now includes debug info (build ID, managed status, hasIpc) Tested: Restart API now properly releases port and new worker binds to same port. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
17 lines
14 KiB
JavaScript
Executable File
17 lines
14 KiB
JavaScript
Executable File
#!/usr/bin/env bun
|
|
import kt from"path";import{stdin as b}from"process";import M from"path";import{homedir as At}from"os";import{spawnSync as Mt}from"child_process";import{existsSync as Dt,writeFileSync as K,readFileSync as wt,mkdirSync as Lt}from"fs";import{readFileSync as tt,writeFileSync as et,existsSync as rt}from"fs";import{join as nt}from"path";import{homedir as ot}from"os";var z=["bugfix","feature","refactor","discovery","decision","change"],Z=["how-it-works","why-it-exists","what-changed","problem-solution","gotcha","pattern","trade-off"];var U=z.join(","),N=Z.join(",");var p=class{static DEFAULTS={CLAUDE_MEM_MODEL:"claude-sonnet-4-5",CLAUDE_MEM_CONTEXT_OBSERVATIONS:"50",CLAUDE_MEM_WORKER_PORT:"37777",CLAUDE_MEM_WORKER_HOST:"127.0.0.1",CLAUDE_MEM_SKIP_TOOLS:"ListMcpResourcesTool,SlashCommand,Skill,TodoWrite,AskUserQuestion",CLAUDE_MEM_DATA_DIR:nt(ot(),".claude-mem"),CLAUDE_MEM_LOG_LEVEL:"INFO",CLAUDE_MEM_PYTHON_VERSION:"3.13",CLAUDE_CODE_PATH:"",CLAUDE_MEM_CONTEXT_SHOW_READ_TOKENS:"true",CLAUDE_MEM_CONTEXT_SHOW_WORK_TOKENS:"true",CLAUDE_MEM_CONTEXT_SHOW_SAVINGS_AMOUNT:"true",CLAUDE_MEM_CONTEXT_SHOW_SAVINGS_PERCENT:"true",CLAUDE_MEM_CONTEXT_OBSERVATION_TYPES:U,CLAUDE_MEM_CONTEXT_OBSERVATION_CONCEPTS:N,CLAUDE_MEM_CONTEXT_FULL_COUNT:"5",CLAUDE_MEM_CONTEXT_FULL_FIELD:"narrative",CLAUDE_MEM_CONTEXT_SESSION_COUNT:"10",CLAUDE_MEM_CONTEXT_SHOW_LAST_SUMMARY:"true",CLAUDE_MEM_CONTEXT_SHOW_LAST_MESSAGE:"false"};static getAllDefaults(){return{...this.DEFAULTS}}static get(t){return this.DEFAULTS[t]}static getInt(t){let e=this.get(t);return parseInt(e,10)}static getBool(t){return this.get(t)==="true"}static loadFromFile(t){if(!rt(t))return this.getAllDefaults();let e=tt(t,"utf-8"),r=JSON.parse(e),o=r;if(r.env&&typeof r.env=="object"){o=r.env;try{et(t,JSON.stringify(o,null,2),"utf-8"),u.info("SETTINGS","Migrated settings file from nested to flat schema",{settingsPath:t})}catch(i){u.warn("SETTINGS","Failed to auto-migrate settings file",{settingsPath:t},i)}}let s={...this.DEFAULTS};for(let i of Object.keys(this.DEFAULTS))o[i]!==void 0&&(s[i]=o[i]);return s}};var D=(s=>(s[s.DEBUG=0]="DEBUG",s[s.INFO=1]="INFO",s[s.WARN=2]="WARN",s[s.ERROR=3]="ERROR",s[s.SILENT=4]="SILENT",s))(D||{}),w=class{level=null;useColor;constructor(){this.useColor=process.stdout.isTTY??!1}getLevel(){if(this.level===null){let t=p.get("CLAUDE_MEM_LOG_LEVEL").toUpperCase();this.level=D[t]??1}return this.level}correlationId(t,e){return`obs-${t}-${e}`}sessionId(t){return`session-${t}`}formatData(t){if(t==null)return"";if(typeof t=="string")return t;if(typeof t=="number"||typeof t=="boolean")return t.toString();if(typeof t=="object"){if(t instanceof Error)return this.getLevel()===0?`${t.message}
|
|
${t.stack}`:t.message;if(Array.isArray(t))return`[${t.length} items]`;let e=Object.keys(t);return e.length===0?"{}":e.length<=3?JSON.stringify(t):`{${e.length} keys: ${e.slice(0,3).join(", ")}...}`}return String(t)}formatTool(t,e){if(!e)return t;try{let r=typeof e=="string"?JSON.parse(e):e;if(t==="Bash"&&r.command){let o=r.command.length>50?r.command.substring(0,50)+"...":r.command;return`${t}(${o})`}if(t==="Read"&&r.file_path){let o=r.file_path.split("/").pop()||r.file_path;return`${t}(${o})`}if(t==="Edit"&&r.file_path){let o=r.file_path.split("/").pop()||r.file_path;return`${t}(${o})`}if(t==="Write"&&r.file_path){let o=r.file_path.split("/").pop()||r.file_path;return`${t}(${o})`}return t}catch{return t}}formatTimestamp(t){let e=t.getFullYear(),r=String(t.getMonth()+1).padStart(2,"0"),o=String(t.getDate()).padStart(2,"0"),s=String(t.getHours()).padStart(2,"0"),i=String(t.getMinutes()).padStart(2,"0"),a=String(t.getSeconds()).padStart(2,"0"),l=String(t.getMilliseconds()).padStart(3,"0");return`${e}-${r}-${o} ${s}:${i}:${a}.${l}`}log(t,e,r,o,s){if(t<this.getLevel())return;let i=this.formatTimestamp(new Date),a=D[t].padEnd(5),l=e.padEnd(6),f="";o?.correlationId?f=`[${o.correlationId}] `:o?.sessionId&&(f=`[session-${o.sessionId}] `);let c="";s!=null&&(this.getLevel()===0&&typeof s=="object"?c=`
|
|
`+JSON.stringify(s,null,2):c=" "+this.formatData(s));let _="";if(o){let{sessionId:bt,sdkSessionId:vt,correlationId:Ut,...v}=o;Object.keys(v).length>0&&(_=` {${Object.entries(v).map(([q,Q])=>`${q}=${Q}`).join(", ")}}`)}let S=`[${i}] [${a}] [${l}] ${f}${r}${_}${c}`;t===3?console.error(S):console.log(S)}debug(t,e,r,o){this.log(0,t,e,r,o)}info(t,e,r,o){this.log(1,t,e,r,o)}warn(t,e,r,o){this.log(2,t,e,r,o)}error(t,e,r,o){this.log(3,t,e,r,o)}dataIn(t,e,r,o){this.info(t,`\u2192 ${e}`,r,o)}dataOut(t,e,r,o){this.info(t,`\u2190 ${e}`,r,o)}success(t,e,r,o){this.info(t,`\u2713 ${e}`,r,o)}failure(t,e,r,o){this.error(t,`\u2717 ${e}`,r,o)}timing(t,e,r,o){this.info(t,`\u23F1 ${e}`,o,{duration:`${r}ms`})}happyPathError(t,e,r,o,s=""){let f=((new Error().stack||"").split(`
|
|
`)[2]||"").match(/at\s+(?:.*\s+)?\(?([^:]+):(\d+):(\d+)\)?/),c=f?`${f[1].split("/").pop()}:${f[2]}`:"unknown",_={...r,location:c};return this.warn(t,`[HAPPY-PATH] ${e}`,_,o),s}},u=new w;var T={DEFAULT:5e3,HEALTH_CHECK:1e3,WORKER_STARTUP_WAIT:1e3,WORKER_STARTUP_RETRIES:15,PRE_RESTART_SETTLE_DELAY:2e3,WINDOWS_MULTIPLIER:1.5};function L(n){return process.platform==="win32"?Math.round(n*T.WINDOWS_MULTIPLIER):n}import{existsSync as I,readFileSync as pt,writeFileSync as gt,unlinkSync as mt,mkdirSync as F}from"fs";import{createWriteStream as ft}from"fs";import{join as C}from"path";import{spawn as Et,spawnSync as _t}from"child_process";import{homedir as dt}from"os";import{join as g,dirname as st,basename as Xt}from"path";import{homedir as it}from"os";import{fileURLToPath as at}from"url";function ct(){return typeof __dirname<"u"?__dirname:st(at(import.meta.url))}var Zt=ct(),m=p.get("CLAUDE_MEM_DATA_DIR"),R=process.env.CLAUDE_CONFIG_DIR||g(it(),".claude"),te=g(m,"archives"),ee=g(m,"logs"),re=g(m,"trash"),ne=g(m,"backups"),oe=g(m,"settings.json"),se=g(m,"claude-mem.db"),ie=g(m,"vector-db"),ae=g(R,"settings.json"),ce=g(R,"commands"),ue=g(R,"CLAUDE.md");import{spawnSync as ut}from"child_process";import{existsSync as lt}from"fs";import{join as x}from"path";import{homedir as $}from"os";function P(){let n=process.platform==="win32";try{if(ut("bun",["--version"],{encoding:"utf-8",stdio:["pipe","pipe","pipe"],shell:!1}).status===0)return"bun"}catch{}let t=n?[x($(),".bun","bin","bun.exe")]:[x($(),".bun","bin","bun"),"/usr/local/bin/bun","/opt/homebrew/bin/bun","/home/linuxbrew/.linuxbrew/bin/bun"];for(let e of t)if(lt(e))return e;return null}function W(){return P()!==null}var O=C(m,"worker.pid"),H=C(m,"logs"),k=C(dt(),".claude","plugins","marketplaces","thedotmack"),St=5e3,Tt=1e4,ht=200,Ot=1e3,Ct=100,A=class{static async start(t){if(isNaN(t)||t<1024||t>65535)return{success:!1,error:`Invalid port ${t}. Must be between 1024 and 65535`};if(await this.isRunning())return{success:!0,pid:this.getPidInfo()?.pid};F(H,{recursive:!0});let e=process.platform==="win32"?"worker-wrapper.cjs":"worker-service.cjs",r=C(k,"plugin","scripts",e);if(!I(r))return{success:!1,error:`Worker script not found at ${r}`};let o=this.getLogFilePath();return this.startWithBun(r,o,t)}static isBunAvailable(){return W()}static escapePowerShellString(t){return t.replace(/'/g,"''")}static async startWithBun(t,e,r){let o=P();if(!o)return{success:!1,error:"Bun is required but not found in PATH or common installation paths. Install from https://bun.sh"};try{if(process.platform==="win32"){let i=this.escapePowerShellString(o),a=this.escapePowerShellString(t),l=this.escapePowerShellString(k),c=`${`$env:CLAUDE_MEM_WORKER_PORT='${r}'`}; Start-Process -FilePath '${i}' -ArgumentList '${a}' -WorkingDirectory '${l}' -WindowStyle Hidden -PassThru | Select-Object -ExpandProperty Id`,_=_t("powershell",["-Command",c],{stdio:"pipe",timeout:1e4,windowsHide:!0});if(_.status!==0)return{success:!1,error:`PowerShell spawn failed: ${_.stderr?.toString()||"unknown error"}`};let S=parseInt(_.stdout.toString().trim(),10);return isNaN(S)?{success:!1,error:"Failed to get PID from PowerShell"}:(this.writePidFile({pid:S,port:r,startedAt:new Date().toISOString(),version:process.env.npm_package_version||"unknown"}),this.waitForHealth(S,r))}else{let i=Et(o,[t],{detached:!0,stdio:["ignore","pipe","pipe"],env:{...process.env,CLAUDE_MEM_WORKER_PORT:String(r)},cwd:k}),a=ft(e,{flags:"a"});return i.stdout?.pipe(a),i.stderr?.pipe(a),i.unref(),i.pid?(this.writePidFile({pid:i.pid,port:r,startedAt:new Date().toISOString(),version:process.env.npm_package_version||"unknown"}),this.waitForHealth(i.pid,r)):{success:!1,error:"Failed to get PID from spawned process"}}}catch(s){return{success:!1,error:s instanceof Error?s.message:String(s)}}}static async stop(t=St){let e=this.getPidInfo();if(!e)return!0;try{if(process.platform==="win32"){let{execSync:r}=await import("child_process");try{r(`taskkill /PID ${e.pid} /T /F`,{timeout:1e4,stdio:"ignore"})}catch{}}else process.kill(e.pid,"SIGTERM"),await this.waitForExit(e.pid,t)}catch{try{process.kill(e.pid,"SIGKILL")}catch{}}return this.removePidFile(),!0}static async restart(t){return await this.stop(),this.start(t)}static async status(){let t=this.getPidInfo();if(!t)return{running:!1};let e=this.isProcessAlive(t.pid);return{running:e,pid:e?t.pid:void 0,port:e?t.port:void 0,uptime:e?this.formatUptime(t.startedAt):void 0}}static async isRunning(){let t=this.getPidInfo();if(!t)return!1;let e=this.isProcessAlive(t.pid);return e||this.removePidFile(),e}static getPidInfo(){try{if(!I(O))return null;let t=pt(O,"utf-8"),e=JSON.parse(t);return typeof e.pid!="number"||typeof e.port!="number"?null:e}catch{return null}}static writePidFile(t){F(m,{recursive:!0}),gt(O,JSON.stringify(t,null,2))}static removePidFile(){try{I(O)&&mt(O)}catch{}}static isProcessAlive(t){try{return process.kill(t,0),!0}catch{return!1}}static async waitForHealth(t,e,r=Tt){let o=Date.now();for(;Date.now()-o<r;){if(!this.isProcessAlive(t))return{success:!1,error:"Process died during startup"};try{if((await fetch(`http://127.0.0.1:${e}/health`,{signal:AbortSignal.timeout(Ot)})).ok)return{success:!0,pid:t}}catch{}await new Promise(s=>setTimeout(s,ht))}return{success:!1,error:"Health check timed out"}}static async waitForExit(t,e){let r=Date.now();for(;Date.now()-r<e;){if(!this.isProcessAlive(t))return;await new Promise(o=>setTimeout(o,Ct))}throw new Error("Process did not exit within timeout")}static getLogFilePath(){let t=new Date().toISOString().slice(0,10);return C(H,`worker-${t}.log`)}static formatUptime(t){let e=new Date(t).getTime(),o=Date.now()-e,s=Math.floor(o/1e3),i=Math.floor(s/60),a=Math.floor(i/60),l=Math.floor(a/24);return l>0?`${l}d ${a%24}h`:a>0?`${a}h ${i%60}m`:i>0?`${i}m ${s%60}s`:`${s}s`}};function d(n={}){let{port:t,includeSkillFallback:e=!1,customPrefix:r,actualError:o}=n,s=process.platform==="win32",i=s?"%USERPROFILE%\\.claude\\plugins\\marketplaces\\thedotmack":"~/.claude/plugins/marketplaces/thedotmack",a=s?"Command Prompt or PowerShell":"Terminal",l=r||"Worker service connection failed.",f=t?` (port ${t})`:"",c=`${l}${f}
|
|
|
|
`;return c+=`To restart the worker:
|
|
`,c+=`1. Exit Claude Code completely
|
|
`,c+=`2. Open ${a}
|
|
`,c+=`3. Navigate to: ${i}
|
|
`,c+=`4. Run: npm run worker:restart
|
|
`,c+="5. Restart Claude Code",e&&(c+=`
|
|
|
|
If that doesn't work, try: /troubleshoot`),o&&(c=`Worker Error: ${o}
|
|
|
|
${c}`),c}var B=M.join(At(),".claude","plugins","marketplaces","thedotmack"),V=L(T.HEALTH_CHECK),h=null;function E(){if(h!==null)return h;try{let n=M.join(p.get("CLAUDE_MEM_DATA_DIR"),"settings.json"),t=p.loadFromFile(n);return h=parseInt(t.CLAUDE_MEM_WORKER_PORT,10),h}catch(n){return u.debug("SYSTEM","Failed to load port from settings, using default",{error:n}),h=parseInt(p.get("CLAUDE_MEM_WORKER_PORT"),10),h}}async function y(){try{let n=E();return(await fetch(`http://127.0.0.1:${n}/health`,{signal:AbortSignal.timeout(V)})).ok}catch(n){return u.debug("SYSTEM","Worker health check failed",{error:n instanceof Error?n.message:String(n),errorType:n?.constructor?.name}),!1}}function Rt(){try{let n=M.join(B,"package.json");return JSON.parse(wt(n,"utf-8")).version}catch(n){return u.debug("SYSTEM","Failed to read plugin version",{error:n instanceof Error?n.message:String(n)}),null}}async function Pt(){try{let n=E(),t=await fetch(`http://127.0.0.1:${n}/api/version`,{signal:AbortSignal.timeout(V)});return t.ok?(await t.json()).version:null}catch(n){return u.debug("SYSTEM","Failed to get worker version",{error:n instanceof Error?n.message:String(n)}),null}}async function j(){let n=Rt(),t=await Pt();!n||!t||n!==t&&(u.info("SYSTEM","Worker version mismatch detected - restarting worker",{pluginVersion:n,workerVersion:t}),await new Promise(e=>setTimeout(e,L(T.PRE_RESTART_SETTLE_DELAY))),await A.restart(E()),await new Promise(e=>setTimeout(e,1e3)),await y()||u.error("SYSTEM","Worker failed to restart after version mismatch",{expectedVersion:n,runningVersion:t,port:E()}))}async function It(){let n=p.get("CLAUDE_MEM_DATA_DIR"),t=M.join(n,".pm2-migrated");if(Lt(n,{recursive:!0}),!Dt(t))try{Mt("pm2",["delete","claude-mem-worker"],{stdio:"ignore"}),K(t,new Date().toISOString(),"utf-8"),u.debug("SYSTEM","PM2 cleanup completed and marked")}catch{K(t,new Date().toISOString(),"utf-8")}let e=E(),r=await A.start(e);return r.success||u.error("SYSTEM","Failed to start worker",{platform:process.platform,port:e,error:r.error,marketplaceRoot:B}),r.success}async function Y(){if(await y()){await j();return}if(!await It()){let e=E();throw new Error(d({port:e,customPrefix:`Worker service failed to start on port ${e}.`}))}for(let e=0;e<5;e++)if(await new Promise(r=>setTimeout(r,500)),await y()){await j();return}let t=E();throw u.error("SYSTEM","Worker started but not responding to health checks"),new Error(d({port:t,customPrefix:`Worker service started but is not responding on port ${t}.`}))}function G(n){throw n.cause?.code==="ECONNREFUSED"||n.code==="ConnectionRefused"||n.name==="TimeoutError"||n.message?.includes("fetch failed")||n.message?.includes("Unable to connect")?new Error(d()):n}function X(n,t,e){u.error("HOOK",`${e.operation} failed`,{status:n.status,...e},t);let r=e.toolName?`Failed ${e.operation} for ${e.toolName}: ${d()}`:`${e.operation} failed: ${d()}`;throw new Error(r)}async function J(n){await Y();let t=n?.cwd??process.cwd(),e=t?kt.basename(t):"unknown-project",r=E(),o=`http://127.0.0.1:${r}/api/context/inject?project=${encodeURIComponent(e)}`;try{let s=await fetch(o,{signal:AbortSignal.timeout(T.DEFAULT)});if(!s.ok){let a=await s.text();X(s,a,{hookName:"context",operation:"Context generation",project:e,port:r})}return(await s.text()).trim()}catch(s){G(s)}}var yt=process.argv.includes("--colors");if(b.isTTY||yt)J(void 0).then(n=>{console.log(n),process.exit(0)});else{let n="";b.on("data",t=>n+=t),b.on("end",async()=>{let t=n.trim()?JSON.parse(n):void 0,e=await J(t);console.log(JSON.stringify({hookSpecificOutput:{hookEventName:"SessionStart",additionalContext:e}})),process.exit(0)})}
|