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
15 KiB
JavaScript
Executable File
17 lines
15 KiB
JavaScript
Executable File
#!/usr/bin/env bun
|
|
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 U(n,t,e={}){let r=Z(n,t,e);return JSON.stringify(r)}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 N=tt.join(","),x=et.join(",");var f=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:N,CLAUDE_MEM_CONTEXT_OBSERVATION_CONCEPTS:x,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"),a.info("SETTINGS","Migrated settings file from nested to flat schema",{settingsPath:t})}catch(i){a.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=f.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"),c=String(t.getSeconds()).padStart(2,"0"),p=String(t.getMilliseconds()).padStart(3,"0");return`${e}-${r}-${o} ${s}:${i}:${c}.${p}`}log(t,e,r,o,s){if(t<this.getLevel())return;let i=this.formatTimestamp(new Date),c=D[t].padEnd(5),p=e.padEnd(6),l="";o?.correlationId?l=`[${o.correlationId}] `:o?.sessionId&&(l=`[session-${o.sessionId}] `);let u="";s!=null&&(this.getLevel()===0&&typeof s=="object"?u=`
|
|
`+JSON.stringify(s,null,2):u=" "+this.formatData(s));let d="";if(o){let{sessionId:vt,sdkSessionId:Ut,correlationId:Nt,...v}=o;Object.keys(v).length>0&&(d=` {${Object.entries(v).map(([Q,z])=>`${Q}=${z}`).join(", ")}}`)}let S=`[${i}] [${c}] [${p}] ${l}${r}${d}${u}`;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 l=((new Error().stack||"").split(`
|
|
`)[2]||"").match(/at\s+(?:.*\s+)?\(?([^:]+):(\d+):(\d+)\)?/),u=l?`${l[1].split("/").pop()}:${l[2]}`:"unknown",d={...r,location:u};return this.warn(t,`[HAPPY-PATH] ${e}`,d,o),s}},a=new w;import M from"path";import{homedir as Dt}from"os";import{spawnSync as wt}from"child_process";import{existsSync as Lt,writeFileSync as B,readFileSync as Rt,mkdirSync as Pt}from"fs";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 mt,writeFileSync as gt,unlinkSync as Et,mkdirSync as F}from"fs";import{createWriteStream as dt}from"fs";import{join as C}from"path";import{spawn as _t,spawnSync as St}from"child_process";import{homedir as Tt}from"os";import{join as m,dirname as at,basename as qt}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 ee=pt(),g=f.get("CLAUDE_MEM_DATA_DIR"),R=process.env.CLAUDE_CONFIG_DIR||m(ct(),".claude"),re=m(g,"archives"),ne=m(g,"logs"),oe=m(g,"trash"),se=m(g,"backups"),ie=m(g,"settings.json"),ae=m(g,"claude-mem.db"),ce=m(g,"vector-db"),ue=m(R,"settings.json"),pe=m(R,"commands"),le=m(R,"CLAUDE.md");import{spawnSync as lt}from"child_process";import{existsSync as ft}from"fs";import{join as $}from"path";import{homedir as H}from"os";function P(){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?[$(H(),".bun","bin","bun.exe")]:[$(H(),".bun","bin","bun"),"/usr/local/bin/bun","/opt/homebrew/bin/bun","/home/linuxbrew/.linuxbrew/bin/bun"];for(let e of t)if(ft(e))return e;return null}function W(){return P()!==null}var O=C(g,"worker.pid"),K=C(g,"logs"),k=C(Tt(),".claude","plugins","marketplaces","thedotmack"),ht=5e3,Ot=1e4,Ct=200,At=1e3,Mt=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(K,{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),c=this.escapePowerShellString(t),p=this.escapePowerShellString(k),u=`${`$env:CLAUDE_MEM_WORKER_PORT='${r}'`}; Start-Process -FilePath '${i}' -ArgumentList '${c}' -WorkingDirectory '${p}' -WindowStyle Hidden -PassThru | Select-Object -ExpandProperty Id`,d=St("powershell",["-Command",u],{stdio:"pipe",timeout:1e4,windowsHide:!0});if(d.status!==0)return{success:!1,error:`PowerShell spawn failed: ${d.stderr?.toString()||"unknown error"}`};let S=parseInt(d.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=_t(o,[t],{detached:!0,stdio:["ignore","pipe","pipe"],env:{...process.env,CLAUDE_MEM_WORKER_PORT:String(r)},cwd:k}),c=dt(e,{flags:"a"});return i.stdout?.pipe(c),i.stderr?.pipe(c),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(!I(O))return null;let t=mt(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(g,{recursive:!0}),gt(O,JSON.stringify(t,null,2))}static removePidFile(){try{I(O)&&Et(O)}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,Mt))}throw new Error("Process did not exit within timeout")}static getLogFilePath(){let t=new Date().toISOString().slice(0,10);return C(K,`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),c=Math.floor(i/60),p=Math.floor(c/24);return p>0?`${p}d ${c%24}h`:c>0?`${c}h ${i%60}m`:i>0?`${i}m ${s%60}s`:`${s}s`}};function _(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",c=s?"Command Prompt or PowerShell":"Terminal",p=r||"Worker service connection failed.",l=t?` (port ${t})`:"",u=`${p}${l}
|
|
|
|
`;return u+=`To restart the worker:
|
|
`,u+=`1. Exit Claude Code completely
|
|
`,u+=`2. Open ${c}
|
|
`,u+=`3. Navigate to: ${i}
|
|
`,u+=`4. Run: npm run worker:restart
|
|
`,u+="5. Restart Claude Code",e&&(u+=`
|
|
|
|
If that doesn't work, try: /troubleshoot`),o&&(u=`Worker Error: ${o}
|
|
|
|
${u}`),u}var j=M.join(Dt(),".claude","plugins","marketplaces","thedotmack"),G=L(T.HEALTH_CHECK),h=null;function E(){if(h!==null)return h;try{let n=M.join(f.get("CLAUDE_MEM_DATA_DIR"),"settings.json"),t=f.loadFromFile(n);return h=parseInt(t.CLAUDE_MEM_WORKER_PORT,10),h}catch(n){return a.debug("SYSTEM","Failed to load port from settings, using default",{error:n}),h=parseInt(f.get("CLAUDE_MEM_WORKER_PORT"),10),h}}async function b(){try{let n=E();return(await fetch(`http://127.0.0.1:${n}/health`,{signal:AbortSignal.timeout(G)})).ok}catch(n){return a.debug("SYSTEM","Worker health check failed",{error:n instanceof Error?n.message:String(n),errorType:n?.constructor?.name}),!1}}function It(){try{let n=M.join(j,"package.json");return JSON.parse(Rt(n,"utf-8")).version}catch(n){return a.debug("SYSTEM","Failed to read plugin version",{error:n instanceof Error?n.message:String(n)}),null}}async function kt(){try{let n=E(),t=await fetch(`http://127.0.0.1:${n}/api/version`,{signal:AbortSignal.timeout(G)});return t.ok?(await t.json()).version:null}catch(n){return a.debug("SYSTEM","Failed to get worker version",{error:n instanceof Error?n.message:String(n)}),null}}async function V(){let n=It(),t=await kt();!n||!t||n!==t&&(a.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 b()||a.error("SYSTEM","Worker failed to restart after version mismatch",{expectedVersion:n,runningVersion:t,port:E()}))}async function bt(){let n=f.get("CLAUDE_MEM_DATA_DIR"),t=M.join(n,".pm2-migrated");if(Pt(n,{recursive:!0}),!Lt(t))try{wt("pm2",["delete","claude-mem-worker"],{stdio:"ignore"}),B(t,new Date().toISOString(),"utf-8"),a.debug("SYSTEM","PM2 cleanup completed and marked")}catch{B(t,new Date().toISOString(),"utf-8")}let e=E(),r=await A.start(e);return r.success||a.error("SYSTEM","Failed to start worker",{platform:process.platform,port:e,error:r.error,marketplaceRoot:j}),r.success}async function Y(){if(await b()){await V();return}if(!await bt()){let e=E();throw new Error(_({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 b()){await V();return}let t=E();throw a.error("SYSTEM","Worker started but not responding to health checks"),new Error(_({port:t,customPrefix:`Worker service started but is not responding on port ${t}.`}))}function X(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(_()):n}function J(n,t,e){a.error("HOOK",`${e.operation} failed`,{status:n.status,...e},t);let r=e.toolName?`Failed ${e.operation} for ${e.toolName}: ${_()}`:`${e.operation} failed: ${_()}`;throw new Error(r)}async function yt(n){if(await Y(),!n)throw new Error("saveHook requires input");let{session_id:t,cwd:e,tool_name:r,tool_input:o,tool_response:s}=n,i=E(),c=a.formatTool(r,o);a.dataIn("HOOK",`PostToolUse: ${c}`,{workerPort:i});try{let p=await fetch(`http://127.0.0.1:${i}/api/sessions/observations`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({claudeSessionId:t,tool_name:r,tool_input:o,tool_response:s,cwd:e||a.happyPathError("HOOK","Missing cwd in PostToolUse hook input",void 0,{session_id:t,tool_name:r},"")}),signal:AbortSignal.timeout(T.DEFAULT)});if(!p.ok){let l=await p.text();J(p,l,{hookName:"save",operation:"Observation storage",toolName:r,sessionId:t,port:i})}a.debug("HOOK","Observation sent successfully",{toolName:r})}catch(p){X(p)}console.log(U("PostToolUse",!0))}var y="";q.on("data",n=>y+=n);q.on("end",async()=>{let n=y?JSON.parse(y):void 0;await yt(n)});
|