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>
21 lines
16 KiB
JavaScript
Executable File
21 lines
16 KiB
JavaScript
Executable File
#!/usr/bin/env bun
|
|
import{stdin as z}from"process";function tt(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 N(n,t,e={}){let r=tt(n,t,e);return JSON.stringify(r)}import{readFileSync as nt,writeFileSync as ot,existsSync as st}from"fs";import{join as it}from"path";import{homedir as at}from"os";var et=["bugfix","feature","refactor","discovery","decision","change"],rt=["how-it-works","why-it-exists","what-changed","problem-solution","gotcha","pattern","trade-off"];var x=et.join(","),H=rt.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:it(at(),".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:x,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(!st(t))return this.getAllDefaults();let e=nt(t,"utf-8"),r=JSON.parse(e),o=r;if(r.env&&typeof r.env=="object"){o=r.env;try{ot(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 y=(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))(y||{}),R=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=y[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=y[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:xt,sdkSessionId:Ht,correlationId:$t,...U}=o;Object.keys(U).length>0&&(d=` {${Object.entries(U).map(([Q,Z])=>`${Q}=${Z}`).join(", ")}}`)}let _=`[${i}] [${c}] [${p}] ${l}${r}${d}${u}`;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 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 R;import M from"path";import{homedir as Rt}from"os";import{spawnSync as Dt}from"child_process";import{existsSync as Lt,writeFileSync as V,readFileSync as wt,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 D(n){return process.platform==="win32"?Math.round(n*T.WINDOWS_MULTIPLIER):n}import{existsSync as P,readFileSync as gt,writeFileSync as Et,unlinkSync as dt,mkdirSync as K}from"fs";import{createWriteStream as St}from"fs";import{join as A}from"path";import{spawn as _t,spawnSync as Tt}from"child_process";import{homedir as ht}from"os";import{join as m,dirname as ct,basename as Zt}from"path";import{homedir as ut}from"os";import{fileURLToPath as pt}from"url";function lt(){return typeof __dirname<"u"?__dirname:ct(pt(import.meta.url))}var oe=lt(),g=f.get("CLAUDE_MEM_DATA_DIR"),L=process.env.CLAUDE_CONFIG_DIR||m(ut(),".claude"),se=m(g,"archives"),ie=m(g,"logs"),ae=m(g,"trash"),ce=m(g,"backups"),ue=m(g,"settings.json"),pe=m(g,"claude-mem.db"),le=m(g,"vector-db"),fe=m(L,"settings.json"),me=m(L,"commands"),ge=m(L,"CLAUDE.md");import{spawnSync as ft}from"child_process";import{existsSync as mt}from"fs";import{join as $}from"path";import{homedir as W}from"os";function w(){let n=process.platform==="win32";try{if(ft("bun",["--version"],{encoding:"utf-8",stdio:["pipe","pipe","pipe"],shell:!1}).status===0)return"bun"}catch{}let t=n?[$(W(),".bun","bin","bun.exe")]:[$(W(),".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 F(){return w()!==null}var O=A(g,"worker.pid"),B=A(g,"logs"),b=A(ht(),".claude","plugins","marketplaces","thedotmack"),Ot=5e3,At=1e4,Ct=200,Mt=1e3,yt=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};K(B,{recursive:!0});let e=process.platform==="win32"?"worker-wrapper.cjs":"worker-service.cjs",r=A(b,"plugin","scripts",e);if(!P(r))return{success:!1,error:`Worker script not found at ${r}`};let o=this.getLogFilePath();return this.startWithBun(r,o,t)}static isBunAvailable(){return F()}static escapePowerShellString(t){return t.replace(/'/g,"''")}static async startWithBun(t,e,r){let o=w();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(b),u=`${`$env:CLAUDE_MEM_WORKER_PORT='${r}'`}; Start-Process -FilePath '${i}' -ArgumentList '${c}' -WorkingDirectory '${p}' -WindowStyle Hidden -PassThru | Select-Object -ExpandProperty Id`,d=Tt("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 _=parseInt(d.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=_t(o,[t],{detached:!0,stdio:["ignore","pipe","pipe"],env:{...process.env,CLAUDE_MEM_WORKER_PORT:String(r)},cwd:b}),c=St(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=Ot){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(!P(O))return null;let t=gt(O,"utf-8"),e=JSON.parse(t);return typeof e.pid!="number"||typeof e.port!="number"?null:e}catch{return null}}static writePidFile(t){K(g,{recursive:!0}),Et(O,JSON.stringify(t,null,2))}static removePidFile(){try{P(O)&&dt(O)}catch{}}static isProcessAlive(t){try{return process.kill(t,0),!0}catch{return!1}}static async waitForHealth(t,e,r=At){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(Mt)})).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,yt))}throw new Error("Process did not exit within timeout")}static getLogFilePath(){let t=new Date().toISOString().slice(0,10);return A(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),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 S(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 G=M.join(Rt(),".claude","plugins","marketplaces","thedotmack"),Y=D(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 k(){try{let n=E();return(await fetch(`http://127.0.0.1:${n}/health`,{signal:AbortSignal.timeout(Y)})).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 bt(){try{let n=M.join(G,"package.json");return JSON.parse(wt(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(Y)});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 j(){let n=bt(),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,D(T.PRE_RESTART_SETTLE_DELAY))),await C.restart(E()),await new Promise(e=>setTimeout(e,1e3)),await k()||a.error("SYSTEM","Worker failed to restart after version mismatch",{expectedVersion:n,runningVersion:t,port:E()}))}async function It(){let n=f.get("CLAUDE_MEM_DATA_DIR"),t=M.join(n,".pm2-migrated");if(Pt(n,{recursive:!0}),!Lt(t))try{Dt("pm2",["delete","claude-mem-worker"],{stdio:"ignore"}),V(t,new Date().toISOString(),"utf-8"),a.debug("SYSTEM","PM2 cleanup completed and marked")}catch{V(t,new Date().toISOString(),"utf-8")}let e=E(),r=await C.start(e);return r.success||a.error("SYSTEM","Failed to start worker",{platform:process.platform,port:e,error:r.error,marketplaceRoot:G}),r.success}async function X(){if(await k()){await j();return}if(!await It()){let e=E();throw new Error(S({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 k()){await j();return}let t=E();throw a.error("SYSTEM","Worker started but not responding to health checks"),new Error(S({port:t,customPrefix:`Worker service started but is not responding on port ${t}.`}))}function J(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(S()):n}function q(n,t,e){a.error("HOOK",`${e.operation} failed`,{status:n.status,...e},t);let r=e.toolName?`Failed ${e.operation} for ${e.toolName}: ${S()}`:`${e.operation} failed: ${S()}`;throw new Error(r)}import{readFileSync as vt,existsSync as Ut}from"fs";function I(n,t,e=!1){if(!n||!Ut(n))return a.happyPathError("PARSER","Transcript path missing or file does not exist",void 0,{transcriptPath:n,role:t},""),"";try{let r=vt(n,"utf-8").trim();if(!r)return a.happyPathError("PARSER","Transcript file exists but is empty",void 0,{transcriptPath:n,role:t},""),"";let o=r.split(`
|
|
`),s=!1;for(let i=o.length-1;i>=0;i--)try{let c=JSON.parse(o[i]);if(c.type===t&&(s=!0,c.message?.content)){let p="",l=c.message.content;return typeof l=="string"?p=l:Array.isArray(l)&&(p=l.filter(u=>u.type==="text").map(u=>u.text).join(`
|
|
`)),e&&(p=p.replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g,""),p=p.replace(/\n{3,}/g,`
|
|
|
|
`).trim()),(!p||p.trim()==="")&&a.happyPathError("PARSER","Found message but content is empty after processing",void 0,{role:t,transcriptPath:n,msgContentType:typeof l,stripSystemReminders:e},""),p}}catch{continue}s||a.happyPathError("PARSER","No message found for role in transcript",void 0,{role:t,transcriptPath:n,totalLines:o.length},"")}catch(r){a.error("HOOK","Failed to read transcript",{transcriptPath:n},r)}return""}async function Nt(n){if(await X(),!n)throw new Error("summaryHook requires input");let{session_id:t}=n,e=E(),r=n.transcript_path||a.happyPathError("HOOK","Missing transcript_path in Stop hook input",void 0,{session_id:t},""),o=I(r,"user"),s=I(r,"assistant",!0);a.dataIn("HOOK","Stop: Requesting summary",{workerPort:e,hasLastUserMessage:!!o,hasLastAssistantMessage:!!s});try{let i=await fetch(`http://127.0.0.1:${e}/api/sessions/summarize`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({claudeSessionId:t,last_user_message:o,last_assistant_message:s}),signal:AbortSignal.timeout(T.DEFAULT)});if(!i.ok){let c=await i.text();q(i,c,{hookName:"summary",operation:"Summary generation",sessionId:t,port:e})}a.debug("HOOK","Summary request sent successfully")}catch(i){J(i)}finally{try{let i=await fetch(`http://127.0.0.1:${e}/api/processing`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({isProcessing:!1}),signal:AbortSignal.timeout(2e3)});i.ok||a.warn("HOOK","Failed to stop spinner",{status:i.status})}catch(i){a.warn("HOOK","Could not stop spinner",{error:i.message})}}console.log(N("Stop",!0))}var v="";z.on("data",n=>v+=n);z.on("end",async()=>{let n=v?JSON.parse(v):void 0;await Nt(n)});
|