borgmcp 1.0.50 → 1.0.51

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.
@@ -102,7 +102,36 @@ export interface InboxLockDeps {
102
102
  removeIfContent(path: string, expected: string): void;
103
103
  /** kill(pid,0) liveness: true if the process exists (alive), false if gone (ESRCH). */
104
104
  isAlive(pid: number): boolean;
105
+ /**
106
+ * gh#840 (optional — enables the node-WEDGE reap): read the holder heartbeat
107
+ * sidecar for this pidfile's inbox → { mtimeMs, nonce } or null if absent /
108
+ * unreadable. Absent dep ⇒ no wedge reap (legacy behavior).
109
+ */
110
+ readHeartbeat?(pidfilePath: string): {
111
+ mtimeMs: number;
112
+ nonce: string;
113
+ } | null;
114
+ /** gh#840: clock for heartbeat staleness (injected for tests; defaults to Date.now). */
115
+ now?(): number;
116
+ /** gh#840: heartbeat staleness threshold; defaults to HEARTBEAT_STALE_MS. */
117
+ heartbeatStaleMs?: number;
105
118
  }
119
+ /** gh#840: pidfile content is `<pid>` (legacy) or `<pid>:<nonce>` (identity-tagged). */
120
+ export declare function parsePidfileContent(trimmed: string): {
121
+ pid: number;
122
+ nonce: string | null;
123
+ };
124
+ /**
125
+ * gh#840: is the LIVE pidfile holder node-WEDGED (reapable)? True ONLY when BOTH
126
+ * (a) the heartbeat sidecar mtime is stale past the threshold, AND (b) the
127
+ * heartbeat's nonce MATCHES the pidfile holder's nonce (same identity wrote
128
+ * both). A nonce MISMATCH ⇒ the stale heartbeat belongs to a DIFFERENT identity
129
+ * than the currently-alive pidfile holder (PID reuse, or a young reclaimer that
130
+ * hasn't written its first heartbeat yet) ⇒ NOT wedged ⇒ NEVER reap. Err toward
131
+ * NOT reaping: no readHeartbeat dep, no heartbeat file, or a legacy no-nonce
132
+ * pidfile all return false (a false-reap is the deafness we prevent).
133
+ */
134
+ export declare function isHolderWedged(pidfilePath: string, holderNonce: string | null, deps: InboxLockDeps): boolean;
106
135
  export declare function pidfilePathFor(inboxPath: string): string;
107
136
  /** gh#822: the holder-liveness heartbeat sidecar (mtime touched each tick). */
108
137
  export declare function heartbeatPathFor(inboxPath: string): string;
@@ -122,7 +151,23 @@ export declare function tailArgsFor(inboxPath: string, fromByteOffset: number |
122
151
  * delete) — only reaps a still-present provably-dead (ESRCH) / unparseable
123
152
  * pidfile, then re-claims.
124
153
  */
125
- export declare function acquireInboxLock(pidfilePath: string, ownPid: number, deps: InboxLockDeps, maxAttempts?: number): boolean;
154
+ export declare function acquireInboxLock(pidfilePath: string, ownPid: number, deps: InboxLockDeps, maxAttempts?: number, ownNonce?: string): boolean;
155
+ /**
156
+ * gh#840: read the holder heartbeat sidecar for a pidfile's inbox.
157
+ * Freshness = file mtime; identity = file content (the holder's nonce). Returns
158
+ * null if the sidecar is absent/unreadable.
159
+ */
160
+ export declare function readHeartbeatSidecar(pidfilePath: string): {
161
+ mtimeMs: number;
162
+ nonce: string;
163
+ } | null;
164
+ /**
165
+ * gh#840: write the holder heartbeat sidecar — the per-holder identity nonce as
166
+ * content; the FILE MTIME (touched on every write) is the freshness signal the
167
+ * SLI + the wedge reaper read. Replaces the old timestamp-as-content (nothing
168
+ * read that content; mtime was always the freshness source).
169
+ */
170
+ export declare function writeHeartbeat(heartbeatPath: string, nonce: string): void;
126
171
  /**
127
172
  * Is this module being invoked as the bin entry point?
128
173
  *
@@ -1,2 +1,2 @@
1
1
  #!/usr/bin/env node
2
- import{spawn as w}from"node:child_process";import{randomBytes as y}from"node:crypto";import{linkSync as T,readFileSync as p,realpathSync as v,statSync as k,unlinkSync as d,writeFileSync as I}from"node:fs";import{createInterface as O}from"node:readline";import{fileURLToPath as N}from"node:url";const L=/^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\S*)\s+(\S+)\s+\(([^)]+)\):\s*(.*)$/,_=1024;class R{cap;seen=new Set;order=[];constructor(e=_){if(this.cap=e,!Number.isInteger(e)||e<1)throw new Error("cap must be a positive integer")}remember(e){if(this.seen.has(e))return!1;for(this.seen.add(e),this.order.push(e);this.order.length>this.cap;){const r=this.order.shift();r&&this.seen.delete(r)}return!0}}function b(t){const e=L.exec(t);if(!e)return null;const[,,r,o,i]=e,n=i.trim();return`${r} (${o}): ${n}`}function $(t,e){const r=b(t);return r===null?null:e.remember(t)?r:null}function D(t,e,r=512){if(!Number.isInteger(r)||r<1)throw new Error("maxLines must be a positive integer");let o;try{o=p(t,"utf-8")}catch(n){if(n?.code==="ENOENT")return;throw n}const i=o.split(/\r?\n/);i.at(-1)===""&&i.pop();for(const n of i.slice(-r))b(n)!==null&&e.remember(n)}function F(t,e,r,o){if(t<e.lastEmittedOffset)return{kind:"rotation",state:{lastEmittedOffset:t,grewSince:null}};if(t===e.lastEmittedOffset)return{kind:"ok",state:{lastEmittedOffset:e.lastEmittedOffset,grewSince:null}};const i=e.grewSince??r,n={lastEmittedOffset:e.lastEmittedOffset,grewSince:i};return r-i>=o?{kind:"respawn",state:n}:{kind:"ok",state:n}}function A(t){return`${t}.monitor.pid`}function C(t){return`${t}.monitor.heartbeat`}const h=3e4,M=5*h,J=5*h;function G(t,e){return e===null?["-F","-n","0",t]:["-F","-c",`+${e+1}`,t]}function E(t){try{return k(t).size}catch{return 0}}function P(t,e,r,o=3){const i=String(e);for(let n=0;n<o;n++){if(r.claim(t,i))return!0;const c=r.read(t);if(c===null)continue;const l=c.trim();if(l===""){r.removeIfContent(t,c);continue}const u=Number.parseInt(l,10);if(!Number.isNaN(u)&&r.isAlive(u))return!1;r.removeIfContent(t,c)}return!1}function H(){return{claim:(t,e)=>{const r=`${t}.tmp.${process.pid}.${y(6).toString("hex")}`;try{I(r,e,{mode:384});try{return T(r,t),!0}catch(o){if(o?.code==="EEXIST")return!1;throw o}}finally{try{d(r)}catch{}}},read:t=>{try{return p(t,"utf8")}catch{return null}},removeIfContent:(t,e)=>{try{p(t,"utf8")===e&&d(t)}catch{}},isAlive:t=>{try{return process.kill(t,0),!0}catch(e){return e?.code==="EPERM"}}}}function K(){const t=process.argv[2];t||(console.error("borg-inbox-monitor: usage: borg-inbox-monitor <inbox-path>"),process.exit(2));const e=A(t),r=H();P(e,process.pid,r)||process.exit(0);const o=()=>r.removeIfContent(e,String(process.pid)),i=new R;D(t,i);let n={lastEmittedOffset:E(t),grewSince:null},c=!1,l=null;const u=a=>{const s=w("tail",G(t,a),{stdio:["ignore","pipe","inherit"]});l=s,s.stdout||(console.error("borg-inbox-monitor: tail subprocess has no stdout"),o(),process.exit(1)),O({input:s.stdout,crlfDelay:1/0}).on("line",f=>{const m=$(f,i);m!==null&&(console.log(m),n={lastEmittedOffset:E(t),grewSince:null})}),s.on("error",f=>{s===l&&(console.error(`borg-inbox-monitor: tail failed: ${f.message}`),o(),process.exit(1))}),s.on("exit",(f,m)=>{s===l&&(o(),c&&process.exit(0),m&&process.exit(0),process.exit(f??0))})};u(null);const S=C(t),x=setInterval(()=>{try{I(S,String(Date.now()),{mode:384})}catch{}const a=F(E(t),n,Date.now(),M);if(n=a.state,a.kind==="respawn"&&!c){const s=l;u(n.lastEmittedOffset);try{s?.kill("SIGKILL")}catch{}n={lastEmittedOffset:n.lastEmittedOffset,grewSince:null}}},h);x.unref();const g=a=>{if(c)return;c=!0,clearInterval(x);try{d(S)}catch{}o();const s=l;s&&!s.killed&&!s.kill(a)&&process.exit(0),setTimeout(()=>process.exit(0),1e3).unref()};process.once("SIGTERM",()=>g("SIGTERM")),process.once("SIGINT",()=>g("SIGINT"))}function q(t,e){try{return v(t)===N(e)}catch{return!1}}q(process.argv[1],import.meta.url)&&K();export{J as HEARTBEAT_STALE_MS,_ as RECENT_EMITTED_LINE_CAP,R as RecentLineDeduper,P as acquireInboxLock,F as evaluateInboxTailStall,b as formatEventLine,$ as formatFreshEventLine,C as heartbeatPathFor,q as isEntryInvocation,A as pidfilePathFor,D as seedDeduperFromInboxTail,G as tailArgsFor};
2
+ import{spawn as O}from"node:child_process";import{randomBytes as I}from"node:crypto";import{linkSync as N,readFileSync as h,realpathSync as $,statSync as g,unlinkSync as x,writeFileSync as y}from"node:fs";import{createInterface as L}from"node:readline";import{fileURLToPath as M}from"node:url";const _=/^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\S*)\s+(\S+)\s+\(([^)]+)\):\s*(.*)$/,D=1024;class R{cap;seen=new Set;order=[];constructor(e=D){if(this.cap=e,!Number.isInteger(e)||e<1)throw new Error("cap must be a positive integer")}remember(e){if(this.seen.has(e))return!1;for(this.seen.add(e),this.order.push(e);this.order.length>this.cap;){const r=this.order.shift();r&&this.seen.delete(r)}return!0}}function T(t){const e=_.exec(t);if(!e)return null;const[,,r,n,o]=e,i=o.trim();return`${r} (${n}): ${i}`}function C(t,e){const r=T(t);return r===null?null:e.remember(t)?r:null}function F(t,e,r=512){if(!Number.isInteger(r)||r<1)throw new Error("maxLines must be a positive integer");let n;try{n=h(t,"utf-8")}catch(i){if(i?.code==="ENOENT")return;throw i}const o=n.split(/\r?\n/);o.at(-1)===""&&o.pop();for(const i of o.slice(-r))T(i)!==null&&e.remember(i)}function H(t,e,r,n){if(t<e.lastEmittedOffset)return{kind:"rotation",state:{lastEmittedOffset:t,grewSince:null}};if(t===e.lastEmittedOffset)return{kind:"ok",state:{lastEmittedOffset:e.lastEmittedOffset,grewSince:null}};const o=e.grewSince??r,i={lastEmittedOffset:e.lastEmittedOffset,grewSince:o};return r-o>=n?{kind:"respawn",state:i}:{kind:"ok",state:i}}function A(t){const e=t.indexOf(":");return e===-1?{pid:Number.parseInt(t,10),nonce:null}:{pid:Number.parseInt(t.slice(0,e),10),nonce:t.slice(e+1)||null}}function G(t,e,r){if(!e||!r.readHeartbeat)return!1;const n=r.readHeartbeat(t);if(n===null)return!1;const o=r.now?r.now():Date.now(),i=r.heartbeatStaleMs??k;return o-n.mtimeMs>=i&&n.nonce===e}function P(t){return`${t}.monitor.pid`}function v(t){return`${t}.monitor.heartbeat`}const b=3e4,K=5*b,k=5*b;function q(t,e){return e===null?["-F","-n","0",t]:["-F","-c",`+${e+1}`,t]}function E(t){try{return g(t).size}catch{return 0}}function B(t,e,r,n=3,o){const i=o?`${e}:${o}`:String(e);for(let c=0;c<n;c++){if(r.claim(t,i))return!0;const l=r.read(t);if(l===null)continue;const a=l.trim();if(a===""){r.removeIfContent(t,l);continue}const{pid:f,nonce:p}=A(a);if(!Number.isNaN(f)&&r.isAlive(f)){if(G(t,p,r)){r.removeIfContent(t,l);continue}return!1}r.removeIfContent(t,l)}return!1}function U(){return{claim:(t,e)=>{const r=`${t}.tmp.${process.pid}.${I(6).toString("hex")}`;try{y(r,e,{mode:384});try{return N(r,t),!0}catch(n){if(n?.code==="EEXIST")return!1;throw n}}finally{try{x(r)}catch{}}},read:t=>{try{return h(t,"utf8")}catch{return null}},removeIfContent:(t,e)=>{try{h(t,"utf8")===e&&x(t)}catch{}},isAlive:t=>{try{return process.kill(t,0),!0}catch(e){return e?.code==="EPERM"}},readHeartbeat:W,now:()=>Date.now(),heartbeatStaleMs:k}}function W(t){const e=t.replace(/\.monitor\.pid$/,""),r=v(e);try{return{mtimeMs:g(r).mtimeMs,nonce:h(r,"utf8").trim()}}catch{return null}}function X(t,e){y(t,e,{mode:384})}function Y(){const t=process.argv[2];t||(console.error("borg-inbox-monitor: usage: borg-inbox-monitor <inbox-path>"),process.exit(2));const e=P(t),r=U(),n=I(16).toString("hex");B(e,process.pid,r,3,n)||process.exit(0);const o=()=>r.removeIfContent(e,`${process.pid}:${n}`),i=new R;F(t,i);let c={lastEmittedOffset:E(t),grewSince:null},l=!1,a=null;const f=u=>{const s=O("tail",q(t,u),{stdio:["ignore","pipe","inherit"]});a=s,s.stdout||(console.error("borg-inbox-monitor: tail subprocess has no stdout"),o(),process.exit(1)),L({input:s.stdout,crlfDelay:1/0}).on("line",m=>{const d=C(m,i);d!==null&&(console.log(d),c={lastEmittedOffset:E(t),grewSince:null})}),s.on("error",m=>{s===a&&(console.error(`borg-inbox-monitor: tail failed: ${m.message}`),o(),process.exit(1))}),s.on("exit",(m,d)=>{s===a&&(o(),l&&process.exit(0),d&&process.exit(0),process.exit(m??0))})};f(null);const p=v(t),S=setInterval(()=>{try{X(p,n)}catch{}const u=H(E(t),c,Date.now(),K);if(c=u.state,u.kind==="respawn"&&!l){const s=a;f(c.lastEmittedOffset);try{s?.kill("SIGKILL")}catch{}c={lastEmittedOffset:c.lastEmittedOffset,grewSince:null}}},b);S.unref();const w=u=>{if(l)return;l=!0,clearInterval(S);try{x(p)}catch{}o();const s=a;s&&!s.killed&&!s.kill(u)&&process.exit(0),setTimeout(()=>process.exit(0),1e3).unref()};process.once("SIGTERM",()=>w("SIGTERM")),process.once("SIGINT",()=>w("SIGINT"))}function j(t,e){try{return $(t)===M(e)}catch{return!1}}j(process.argv[1],import.meta.url)&&Y();export{k as HEARTBEAT_STALE_MS,D as RECENT_EMITTED_LINE_CAP,R as RecentLineDeduper,B as acquireInboxLock,H as evaluateInboxTailStall,T as formatEventLine,C as formatFreshEventLine,v as heartbeatPathFor,j as isEntryInvocation,G as isHolderWedged,A as parsePidfileContent,P as pidfilePathFor,W as readHeartbeatSidecar,F as seedDeduperFromInboxTail,q as tailArgsFor,X as writeHeartbeat};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "borgmcp",
3
- "version": "1.0.50",
3
+ "version": "1.0.51",
4
4
  "description": "Coordinate AI coding agents in shared cubes. Works with Claude Code and Codex. Create projects, assign roles, and share a live activity log.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",