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
16 KiB
JavaScript
Executable File
17 lines
16 KiB
JavaScript
Executable File
#!/usr/bin/env bun
|
|
import yt from"path";import{stdin as q}from"process";function Z(n,t,e){return n==="SessionStart"?t&&e.context?{continue:!0,suppressOutput:!0,hookSpecificOutput:{hookEventName:"SessionStart",additionalContext:e.context}}:{continue:!0,suppressOutput:!0}:n==="UserPromptSubmit"||n==="PostToolUse"?{continue:!0,suppressOutput:!0}:n==="Stop"?{continue:!0,suppressOutput:!0}:{continue:t,suppressOutput:!0,...e.reason&&!t?{stopReason:e.reason}:{}}}function M(n,t,e={}){let r=Z(n,t,e);return JSON.stringify(r)}import D from"path";import{homedir as Mt}from"os";import{spawnSync as wt}from"child_process";import{existsSync as Rt,writeFileSync as V,readFileSync as Lt,mkdirSync as Pt}from"fs";import{readFileSync as rt,writeFileSync as nt,existsSync as ot}from"fs";import{join as st}from"path";import{homedir as it}from"os";var tt=["bugfix","feature","refactor","discovery","decision","change"],et=["how-it-works","why-it-exists","what-changed","problem-solution","gotcha","pattern","trade-off"];var $=tt.join(","),H=et.join(",");var m=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:st(it(),".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:$,CLAUDE_MEM_CONTEXT_OBSERVATION_CONCEPTS:H,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(!ot(t))return this.getAllDefaults();let e=rt(t,"utf-8"),r=JSON.parse(e),o=r;if(r.env&&typeof r.env=="object"){o=r.env;try{nt(t,JSON.stringify(o,null,2),"utf-8"),p.info("SETTINGS","Migrated settings file from nested to flat schema",{settingsPath:t})}catch(i){p.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 w=(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))(w||{}),R=class{level=null;useColor;constructor(){this.useColor=process.stdout.isTTY??!1}getLevel(){if(this.level===null){let t=m.get("CLAUDE_MEM_LOG_LEVEL").toUpperCase();this.level=w[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"),u=String(t.getSeconds()).padStart(2,"0"),l=String(t.getMilliseconds()).padStart(3,"0");return`${e}-${r}-${o} ${s}:${i}:${u}.${l}`}log(t,e,r,o,s){if(t<this.getLevel())return;let i=this.formatTimestamp(new Date),u=w[t].padEnd(5),l=e.padEnd(6),c="";o?.correlationId?c=`[${o.correlationId}] `:o?.sessionId&&(c=`[session-${o.sessionId}] `);let a="";s!=null&&(this.getLevel()===0&&typeof s=="object"?a=`
|
|
`+JSON.stringify(s,null,2):a=" "+this.formatData(s));let E="";if(o){let{sessionId:Nt,sdkSessionId:Ut,correlationId:xt,...x}=o;Object.keys(x).length>0&&(E=` {${Object.entries(x).map(([z,Q])=>`${z}=${Q}`).join(", ")}}`)}let _=`[${i}] [${u}] [${l}] ${c}${r}${E}${a}`;t===3?console.error(_):console.log(_)}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 c=((new Error().stack||"").split(`
|
|
`)[2]||"").match(/at\s+(?:.*\s+)?\(?([^:]+):(\d+):(\d+)\)?/),a=c?`${c[1].split("/").pop()}:${c[2]}`:"unknown",E={...r,location:a};return this.warn(t,`[HAPPY-PATH] ${e}`,E,o),s}},p=new R;var A={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*A.WINDOWS_MULTIPLIER):n}import{existsSync as k,readFileSync as ft,writeFileSync as gt,unlinkSync as Et,mkdirSync as j}from"fs";import{createWriteStream as St}from"fs";import{join as O}from"path";import{spawn as dt,spawnSync as _t}from"child_process";import{homedir as Tt}from"os";import{join as f,dirname as at,basename as zt}from"path";import{homedir as ct}from"os";import{fileURLToPath as ut}from"url";function pt(){return typeof __dirname<"u"?__dirname:at(ut(import.meta.url))}var re=pt(),g=m.get("CLAUDE_MEM_DATA_DIR"),P=process.env.CLAUDE_CONFIG_DIR||f(ct(),".claude"),ne=f(g,"archives"),oe=f(g,"logs"),se=f(g,"trash"),ie=f(g,"backups"),ae=f(g,"settings.json"),ce=f(g,"claude-mem.db"),ue=f(g,"vector-db"),pe=f(P,"settings.json"),le=f(P,"commands"),me=f(P,"CLAUDE.md");import{spawnSync as lt}from"child_process";import{existsSync as mt}from"fs";import{join as W}from"path";import{homedir as F}from"os";function b(){let n=process.platform==="win32";try{if(lt("bun",["--version"],{encoding:"utf-8",stdio:["pipe","pipe","pipe"],shell:!1}).status===0)return"bun"}catch{}let t=n?[W(F(),".bun","bin","bun.exe")]:[W(F(),".bun","bin","bun"),"/usr/local/bin/bun","/opt/homebrew/bin/bun","/home/linuxbrew/.linuxbrew/bin/bun"];for(let e of t)if(mt(e))return e;return null}function K(){return b()!==null}var h=O(g,"worker.pid"),B=O(g,"logs"),I=O(Tt(),".claude","plugins","marketplaces","thedotmack"),ht=5e3,Ot=1e4,Ct=200,At=1e3,Dt=100,C=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};j(B,{recursive:!0});let e=process.platform==="win32"?"worker-wrapper.cjs":"worker-service.cjs",r=O(I,"plugin","scripts",e);if(!k(r))return{success:!1,error:`Worker script not found at ${r}`};let o=this.getLogFilePath();return this.startWithBun(r,o,t)}static isBunAvailable(){return K()}static escapePowerShellString(t){return t.replace(/'/g,"''")}static async startWithBun(t,e,r){let o=b();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),u=this.escapePowerShellString(t),l=this.escapePowerShellString(I),a=`${`$env:CLAUDE_MEM_WORKER_PORT='${r}'`}; Start-Process -FilePath '${i}' -ArgumentList '${u}' -WorkingDirectory '${l}' -WindowStyle Hidden -PassThru | Select-Object -ExpandProperty Id`,E=_t("powershell",["-Command",a],{stdio:"pipe",timeout:1e4,windowsHide:!0});if(E.status!==0)return{success:!1,error:`PowerShell spawn failed: ${E.stderr?.toString()||"unknown error"}`};let _=parseInt(E.stdout.toString().trim(),10);return isNaN(_)?{success:!1,error:"Failed to get PID from PowerShell"}:(this.writePidFile({pid:_,port:r,startedAt:new Date().toISOString(),version:process.env.npm_package_version||"unknown"}),this.waitForHealth(_,r))}else{let i=dt(o,[t],{detached:!0,stdio:["ignore","pipe","pipe"],env:{...process.env,CLAUDE_MEM_WORKER_PORT:String(r)},cwd:I}),u=St(e,{flags:"a"});return i.stdout?.pipe(u),i.stderr?.pipe(u),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=ht){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(!k(h))return null;let t=ft(h,"utf-8"),e=JSON.parse(t);return typeof e.pid!="number"||typeof e.port!="number"?null:e}catch{return null}}static writePidFile(t){j(g,{recursive:!0}),gt(h,JSON.stringify(t,null,2))}static removePidFile(){try{k(h)&&Et(h)}catch{}}static isProcessAlive(t){try{return process.kill(t,0),!0}catch{return!1}}static async waitForHealth(t,e,r=Ot){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(At)})).ok)return{success:!0,pid:t}}catch{}await new Promise(s=>setTimeout(s,Ct))}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,Dt))}throw new Error("Process did not exit within timeout")}static getLogFilePath(){let t=new Date().toISOString().slice(0,10);return O(B,`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),u=Math.floor(i/60),l=Math.floor(u/24);return l>0?`${l}d ${u%24}h`:u>0?`${u}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",u=s?"Command Prompt or PowerShell":"Terminal",l=r||"Worker service connection failed.",c=t?` (port ${t})`:"",a=`${l}${c}
|
|
|
|
`;return a+=`To restart the worker:
|
|
`,a+=`1. Exit Claude Code completely
|
|
`,a+=`2. Open ${u}
|
|
`,a+=`3. Navigate to: ${i}
|
|
`,a+=`4. Run: npm run worker:restart
|
|
`,a+="5. Restart Claude Code",e&&(a+=`
|
|
|
|
If that doesn't work, try: /troubleshoot`),o&&(a=`Worker Error: ${o}
|
|
|
|
${a}`),a}var Y=D.join(Mt(),".claude","plugins","marketplaces","thedotmack"),X=L(A.HEALTH_CHECK),T=null;function S(){if(T!==null)return T;try{let n=D.join(m.get("CLAUDE_MEM_DATA_DIR"),"settings.json"),t=m.loadFromFile(n);return T=parseInt(t.CLAUDE_MEM_WORKER_PORT,10),T}catch(n){return p.debug("SYSTEM","Failed to load port from settings, using default",{error:n}),T=parseInt(m.get("CLAUDE_MEM_WORKER_PORT"),10),T}}async function y(){try{let n=S();return(await fetch(`http://127.0.0.1:${n}/health`,{signal:AbortSignal.timeout(X)})).ok}catch(n){return p.debug("SYSTEM","Worker health check failed",{error:n instanceof Error?n.message:String(n),errorType:n?.constructor?.name}),!1}}function bt(){try{let n=D.join(Y,"package.json");return JSON.parse(Lt(n,"utf-8")).version}catch(n){return p.debug("SYSTEM","Failed to read plugin version",{error:n instanceof Error?n.message:String(n)}),null}}async function kt(){try{let n=S(),t=await fetch(`http://127.0.0.1:${n}/api/version`,{signal:AbortSignal.timeout(X)});return t.ok?(await t.json()).version:null}catch(n){return p.debug("SYSTEM","Failed to get worker version",{error:n instanceof Error?n.message:String(n)}),null}}async function G(){let n=bt(),t=await kt();!n||!t||n!==t&&(p.info("SYSTEM","Worker version mismatch detected - restarting worker",{pluginVersion:n,workerVersion:t}),await new Promise(e=>setTimeout(e,L(A.PRE_RESTART_SETTLE_DELAY))),await C.restart(S()),await new Promise(e=>setTimeout(e,1e3)),await y()||p.error("SYSTEM","Worker failed to restart after version mismatch",{expectedVersion:n,runningVersion:t,port:S()}))}async function It(){let n=m.get("CLAUDE_MEM_DATA_DIR"),t=D.join(n,".pm2-migrated");if(Pt(n,{recursive:!0}),!Rt(t))try{wt("pm2",["delete","claude-mem-worker"],{stdio:"ignore"}),V(t,new Date().toISOString(),"utf-8"),p.debug("SYSTEM","PM2 cleanup completed and marked")}catch{V(t,new Date().toISOString(),"utf-8")}let e=S(),r=await C.start(e);return r.success||p.error("SYSTEM","Failed to start worker",{platform:process.platform,port:e,error:r.error,marketplaceRoot:Y}),r.success}async function J(){if(await y()){await G();return}if(!await It()){let e=S();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 G();return}let t=S();throw p.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 v(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 N(n,t,e){p.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 vt(n){if(await J(),!n)throw new Error("newHook requires input");let{session_id:t,cwd:e,prompt:r}=n,o=yt.basename(e),s=S(),i,u;try{let c=await fetch(`http://127.0.0.1:${s}/api/sessions/init`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({claudeSessionId:t,project:o,prompt:r}),signal:AbortSignal.timeout(5e3)});if(!c.ok){let E=await c.text();N(c,E,{hookName:"new",operation:"Session initialization",project:o,port:s})}let a=await c.json();if(i=a.sessionDbId,u=a.promptNumber,a.skipped&&a.reason==="private"){console.error(`[new-hook] Session ${i}, prompt #${u} (fully private - skipped)`),console.log(M("UserPromptSubmit",!0));return}console.error(`[new-hook] Session ${i}, prompt #${u}`)}catch(c){v(c)}let l=r.startsWith("/")?r.substring(1):r;try{let c=await fetch(`http://127.0.0.1:${s}/sessions/${i}/init`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({userPrompt:l,promptNumber:u}),signal:AbortSignal.timeout(5e3)});if(!c.ok){let a=await c.text();N(c,a,{hookName:"new",operation:"SDK agent start",project:o,port:s,sessionId:String(i)})}}catch(c){v(c)}console.log(M("UserPromptSubmit",!0))}var U="";q.on("data",n=>U+=n);q.on("end",async()=>{let n=U?JSON.parse(U):void 0;await vt(n)});
|