pluriply 0.1.0
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.
- package/LICENSE-HUB.md +38 -0
- package/LICENSE.md +32 -0
- package/README.md +76 -0
- package/bin/pluriply.js +189 -0
- package/package.json +26 -0
- package/src/connector/hub-client.js +327 -0
- package/src/connector/mcp-server.js +51 -0
- package/src/connector/tools.js +727 -0
- package/src/hub/index.js +11 -0
- package/src/setup/clients.js +202 -0
- package/src/setup/run-setup.js +82 -0
- package/src/shared/agy.js +17 -0
- package/src/shared/identity.js +55 -0
- package/src/shared/ids.js +27 -0
- package/src/shared/mcp-register.js +79 -0
- package/src/shared/paths.js +7 -0
- package/src/shared/probe.js +54 -0
- package/src/shared/version.js +18 -0
package/src/hub/index.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// Copyright (c) 2026 TQSoft. All rights reserved.
|
|
2
|
+
// Licensed under LICENSE-HUB.md — not open source.
|
|
3
|
+
import{WebSocketServer as $e}from"ws";import{mkdirSync as xe,writeFileSync as be,rmSync as Et,readFileSync as Ae}from"node:fs";import{join as vt,isAbsolute as Re}from"node:path";import{mkdirSync as Ot,readFileSync as at,writeFileSync as ct,renameSync as lt,existsSync as ut,readdirSync as F,rmSync as ht}from"node:fs";import{join as _}from"node:path";import{pluriplyHome as Lt}from"../shared/paths.js";var J=/^plp-[a-z0-9]{4}-[a-z0-9]{4}$/,P=class{constructor(e=Lt()){this.root=e,this.dir=_(e,"channels"),Ot(this.dir,{recursive:!0}),this.#t()}#t(){for(let e of F(this.dir))e.endsWith(".tmp")&&ht(_(this.dir,e),{force:!0});for(let e of F(this.root))e.startsWith("agents.json.")&&e.endsWith(".tmp")&&ht(_(this.root,e),{force:!0})}loadChannel(e){if(!J.test(e))throw new Error(`invalid channel code: ${e}`);let t=_(this.dir,`${e}.json`);return ut(t)?JSON.parse(at(t,"utf8")):null}saveChannel(e,t){if(!J.test(e))throw new Error(`invalid channel code: ${e}`);let n=_(this.dir,`${e}.json`),r=`${n}.${process.pid}.tmp`;ct(r,JSON.stringify(t,null,2)),lt(r,n)}listChannels(){return F(this.dir).filter(e=>e.endsWith(".json")).map(e=>e.slice(0,-5)).filter(e=>J.test(e))}loadAgents(){let e=_(this.root,"agents.json");if(!ut(e))return{};try{let t=JSON.parse(at(e,"utf8"));return t&&typeof t=="object"&&!Array.isArray(t)?t:{}}catch{return{}}}saveAgents(e){let t=_(this.root,"agents.json"),n=`${t}.${process.pid}.tmp`;ct(n,JSON.stringify(e,null,2)),lt(n,t)}};import{channelCode as Dt}from"../shared/ids.js";var O=class{constructor(e){this.store=e}touch(e,t,n=new Date){try{let r=this.store.loadAgents();r[e]={lastChannel:t,lastSeenAt:n.toISOString()},this.store.saveAgents(r)}catch{}}resume(e,t=new Date){let n=this.store.loadAgents()[e];if(!n)return null;let r=t.getTime()-Date.parse(n.lastSeenAt);if(!(r>=0&&r<=432e5))return null;try{if(!this.store.loadChannel(n.lastChannel))return null}catch{return null}return n.lastChannel}};var B=class extends Error{constructor(e){super(`channel not found: ${e}`),this.code=e}},L=class{constructor(e){this.store=e}create(){let e={channel:{code:Dt(),createdAt:new Date().toISOString(),peers:[]},tasks:[],context:[]};return this.store.saveChannel(e.channel.code,e),e}get(e){let t=this.store.loadChannel(e);if(!t)throw new B(e);return t}save(e,t){this.store.saveChannel(e,t)}join(e,{instanceId:t,tool:n,worker:r=!1},{online:s=new Set,now:i=new Date}={}){let a=this.get(e);this.#t(a,s,i);let c=i.toISOString(),l=a.channel.peers.find(u=>u.instanceId===t);return l?l.lastSeenAt=c:a.channel.peers.push({instanceId:t,tool:n,worker:!!r,joinedAt:c,lastSeenAt:c}),this.save(e,a),{channel:a.channel,peers:a.channel.peers}}peers(e,{online:t=new Set,now:n=new Date}={}){let r=this.get(e);return this.#t(r,t,n)&&this.save(e,r),r.channel.peers}#t(e,t,n){let r=e.channel.peers.length;return e.channel.peers=e.channel.peers.filter(s=>{if(!s.instanceId)return!1;if(t.has(s.instanceId))return!0;let i=n.getTime()-Date.parse(s.lastSeenAt);return i>=0&&i<=432e5}),e.channel.peers.length!==r}};import{statSync as Mt,realpathSync as C}from"node:fs";import{sep as Nt,join as Wt,isAbsolute as qt}from"node:path";import{taskId as Kt}from"../shared/ids.js";import{parseTarget as dt,toolOf as wt,isInstanceId as Ut}from"../shared/identity.js";var Y=new Set(["completed","failed","cancelled"]),j=class extends Error{constructor(e){super(`task not found: ${e}`)}},x=class extends Error{constructor(e,t){super(`invalid task transition: ${e} -> ${t}`)}},D=class extends Error{constructor(e,t,n){super(`task ${e} is addressed to "${t}", not "${n}"`)}},G=class extends Error{constructor(e,t,n){super(`only the sender "${t}" can cancel task ${e}, not "${n}"`)}},E=class extends Error{constructor(e){super(e)}},z=class extends Error{constructor(e){super(`delegation depth limit (${e}) exceeded`)}},Ht=["task","review"],ft=["approve","request_changes","comment"],mt=["critical","important","minor"],y=class extends Error{constructor(e){super(e)}},m=class extends Error{constructor(e){super(e)}},V=class extends Error{constructor(e){super(`use submit_review for review tasks (task ${e})`)}},Q=class extends Error{constructor(e){super(`task ${e} is not a review`)}},X=class extends Error{constructor(e){super(`cwd is not an existing directory: ${e}`)}},Z=class extends Error{constructor(e){super(`cwd outside allowed roots: ${e}`)}},Ft=["auto","spawn","interactive"];function gt(o,e){return o===e||o.startsWith(e+Nt)}function Jt(o,e){if(!o||typeof o!="object"||Array.isArray(o))throw new y("review must be an object");let t={};if(o.gitRange!==void 0){if(typeof o.gitRange!="string"||o.gitRange.length===0||o.gitRange.length>200||/[\r\n]/.test(o.gitRange))throw new y("gitRange must be a single line of at most 200 characters");if(o.gitRange.startsWith("-"))throw new y("gitRange must not start with '-'");t.gitRange=o.gitRange}if(o.paths!==void 0){if(!Array.isArray(o.paths)||o.paths.some(n=>typeof n!="string"||n.length===0))throw new y("paths must be an array of strings");if(o.paths.length>0){if(e===void 0)throw new y("paths need a cwd to resolve against");let n=e;try{n=C(e)}catch{}t.paths=o.paths.map(r=>{let s=qt(r)?r:Wt(e,r),i;try{i=C(s)}catch{throw new y(`path does not exist: ${r}`)}if(!gt(i,n))throw new y(`path outside cwd: ${r}`);return i})}}if(o.focus!==void 0){if(typeof o.focus!="string"||o.focus.length>500)throw new y("focus must be a string of at most 500 characters");o.focus.length>0&&(t.focus=o.focus)}return t}function Bt(o){return`Review ${o.gitRange?`git range ${o.gitRange}`:o.paths?.length?`files ${o.paths.join(", ")}`:"the uncommitted changes (git diff HEAD)"}${o.focus?`, focusing on ${o.focus}`:""}.`}function Yt(o){if(!o||typeof o!="object"||Array.isArray(o))throw new m("review result must be an object");if(!ft.includes(o.verdict))throw new m(`verdict must be one of ${ft.join(", ")}`);if(typeof o.summary!="string"||o.summary.trim().length===0)throw new m("summary is required");let e=o.findings??[];if(!Array.isArray(e))throw new m("findings must be an array");if(e.length>200)throw new m("findings must have at most 200 items");let t=e.map((n,r)=>{if(!n||typeof n!="object"||Array.isArray(n))throw new m(`findings[${r}] must be an object`);if(!mt.includes(n.severity))throw new m(`findings[${r}].severity must be one of ${mt.join(", ")}`);if(typeof n.message!="string"||n.message.length===0)throw new m(`findings[${r}].message is required`);let s={severity:n.severity,message:n.message};if(n.file!==void 0){if(typeof n.file!="string")throw new m(`findings[${r}].file must be a string`);s.file=n.file}if(n.line!==void 0){if(!Number.isInteger(n.line)||n.line<1)throw new m(`findings[${r}].line must be a positive integer`);s.line=n.line}if(n.suggestion!==void 0){if(typeof n.suggestion!="string")throw new m(`findings[${r}].suggestion must be a string`);s.suggestion=n.suggestion}return s});return{verdict:o.verdict,findings:t,summary:o.summary}}function pt(o,e){return o.toInstance?e===o.toInstance:wt(e)===(o.toTool??o.to)}function Gt(o,e){return o.from===e?!0:!o.from.includes("#")&&wt(e)===o.from}var M=class{constructor(e){this.registry=e,this.waiters=new Map}create(e,{from:t,to:n,request:r,attachments:s=[],depth:i=0,cwd:a,origin:c,allowedRoots:l=[],mode:u="auto",maxDepth:h=2,online:p=new Set,kind:f="task",review:b}){if(typeof n!="string"||n.length===0)throw new E("target agent name is required");if(n.includes("#")&&!Ut(n))throw new E(`no peer "${n}" on this channel`);let d=dt(n);if(d.instance!==null&&d.instance===t)throw new E("cannot delegate a task to yourself");if((!Number.isInteger(i)||i<0)&&(i=0),i>h)throw new z(h);if(!Ft.includes(u))throw new Error(`invalid mode: ${u}`);if(a!==void 0){let w=!1;try{w=Mt(a).isDirectory()}catch{w=!1}if(!w)throw new X(a);let $=C(a),U=[];if(c!==void 0)try{U.push(C(c))}catch{}for(let H of l)try{U.push(C(H))}catch{}if(!U.some(H=>gt($,H)))throw new Z(a);a=$}if(!Ht.includes(f))throw new Error(`invalid kind: ${f}`);let A;if(f==="review")A=Jt(b??{},a??c),(typeof r!="string"||r.length===0)&&(r=Bt(A));else if(b!==void 0)throw new y('review is only valid for kind "review"');let v=this.registry.get(e),g=v.channel.peers,k=g.filter(w=>w.tool===d.tool),I;if(d.instance===null){if(I=k.length>0,!I){let w=g.find($=>$.tool?.toLowerCase()===d.tool.toLowerCase());if(w)throw new E(`no peer named "${n}" on this channel; did you mean "${w.tool}"?`)}}else if(I=k.some(w=>w.instanceId===d.instance),!I&&k.length>0){let w=k.find($=>p.has($.instanceId))??k[0];throw new E(`no peer "${n}" on this channel; did you mean "${w.instanceId}"?`)}let S=new Date().toISOString(),R={taskId:Kt(),from:t,to:n,toTool:d.tool,request:r,attachments:s,depth:i,mode:u,kind:f,status:"submitted",result:null,createdAt:S,updatedAt:S};d.instance!==null&&(R.toInstance=d.instance),a!==void 0&&(R.cwd=a),f==="review"&&(R.review=A),v.tasks.push(R),this.registry.save(e,v);let T={task:R,targetJoined:I};return d.instance!==null&&(T.targetOnline=p.has(d.instance),I&&!T.targetOnline&&(T.warning=`"${n}" is not online; the task will wait until it reconnects.`)),I||(T.warning=`"${n}" has not joined this channel yet; the task will wait until it joins.`),T}list(e,{to:t,from:n,status:r,kind:s}={}){let i=this.registry.get(e).tasks;if(t){let a=dt(t);i=i.filter(c=>a.instance===null?(c.toTool??c.to)===a.tool:c.toInstance===a.instance||!c.toInstance&&(c.toTool??c.to)===a.tool)}return n&&(i=i.filter(a=>a.from===n)),r&&(i=i.filter(a=>a.status===r)),s&&(i=i.filter(a=>(a.kind??"task")===s)),i}get(e,t){let n=this.registry.get(e).tasks.find(r=>r.taskId===t);if(!n)throw new j(t);return n}#t(e,t,n){let r=this.registry.get(e),s=r.tasks.find(a=>a.taskId===t);if(!s)throw new j(t);let i=s.status;return n(s),s.updatedAt=new Date().toISOString(),this.registry.save(e,r),s.status!==i&&this.#n(e,t,s),s}#n(e,t,n){let r=this.waiters.get(`${e}/${t}`);if(r){this.waiters.delete(`${e}/${t}`);for(let s of r)s(n)}}waitFor(e,t,n,{signal:r}={}){let s=this.get(e,t);if(Y.has(s.status))return Promise.resolve(s);if(r?.aborted)return Promise.resolve(null);let i=`${e}/${t}`;return new Promise(a=>{let c,l=this.waiters.get(i)??new Set;this.waiters.set(i,l);let u=p=>{clearTimeout(c),r?.removeEventListener("abort",h),l.delete(u),l.size===0&&this.waiters.get(i)===l&&this.waiters.delete(i),a(p)},h=()=>u(null);l.add(u),r?.addEventListener("abort",h,{once:!0}),c=setTimeout(()=>{let p=s;try{p=this.get(e,t)}catch{}u(p)},n)})}claim(e,t,n){return this.#t(e,t,r=>{if(!pt(r,n))throw new D(t,r.to,n);if(r.status!=="submitted")throw new x(r.status,"working");r.status="working"})}complete(e,t,{from:n,result:r,status:s="completed",worker:i=!1,review:a}){if(s!=="completed"&&s!=="failed")throw new x("?",s);return this.#t(e,t,c=>{if(!pt(c,n))throw new D(t,c.to,n);if(Y.has(c.status))throw new x(c.status,s);let l=(c.kind??"task")==="review";if(!l&&a!==void 0)throw new Q(t);if(l&&s==="completed"){if(a===void 0)throw new V(t);r=Yt(a)}c.status=s,c.result=r,c.completedBy=n,i?c.completedByWorker=!0:delete c.completedByWorker})}cancel(e,t,{agent:n,reason:r}){return this.#t(e,t,s=>{if(!Gt(s,n))throw new G(t,s.from,n);if(s.status!=="submitted"&&s.status!=="working")throw new x(s.status,"cancelled");s.status="cancelled",s.result=r??null,s.cancelledBy=n})}setWorker(e,t,n){return this.#t(e,t,r=>{r.worker={...r.worker??{},...n}})}failIfOpen(e,t,{result:n,by:r}){return this.#t(e,t,s=>{Y.has(s.status)||(s.status="failed",s.result=n,s.completedBy=r)})}};import{entryId as zt}from"../shared/ids.js";var N=class{constructor(e){this.registry=e}add(e,{from:t,summary:n,artifacts:r=[]}){let s=this.registry.get(e),i={entryId:zt(),from:t,summary:n,artifacts:r,at:new Date().toISOString()};return s.context.push(i),this.registry.save(e,s),i}list(e,{limit:t}={}){let n=this.registry.get(e).context;return Number.isInteger(t)&&t>0?n.slice(-t):n}};import{spawn as de}from"node:child_process";import{mkdirSync as _t,openSync as fe,closeSync as me,readFileSync as pe,appendFileSync as we}from"node:fs";import{join as q}from"node:path";import{readFileSync as Vt,writeFileSync as Qt,renameSync as Xt,existsSync as Zt}from"node:fs";import{join as yt,isAbsolute as te}from"node:path";var W=Object.freeze({maxDepth:2,timeoutMs:1200*1e3,maxConcurrentPerAgent:1,maxQueuedPerAgent:10});function tt(o){let e=yt(o,"config.json"),t={};if(Zt(e))try{t=JSON.parse(Vt(e,"utf8"))}catch{t={}}(!t||typeof t!="object"||Array.isArray(t))&&(t={});let n=t.workers&&typeof t.workers=="object"&&!Array.isArray(t.workers)?t.workers:{},r={...W};for(let i of Object.keys(W)){let a=t.limits?.[i];Number.isInteger(a)&&a>0&&(r[i]=a)}let s=Array.isArray(t.allowedRoots)?t.allowedRoots.filter(i=>typeof i=="string"&&te(i)):[];return{workers:n,limits:r,allowedRoots:s}}function ee(o,e){let t=yt(o,"config.json"),n=`${t}.${process.pid}.tmp`;Qt(n,JSON.stringify(e,null,2)),Xt(n,t)}function kt(o,e){return o.workers[e]?.enabled===!0}import{join as ne}from"node:path";import{fileURLToPath as re}from"node:url";import{agyCommand as se}from"../shared/agy.js";var oe=re(new URL("../../bin/pluriply.js",import.meta.url)),et=["codex","claude-code","antigravity"],ie=["acceptEdits","bypassPermissions"],ae=["mcp__pluriply__join_channel","mcp__pluriply__channel_status","mcp__pluriply__list_peers","mcp__pluriply__list_tasks","mcp__pluriply__get_task_result","mcp__pluriply__submit_review","mcp__pluriply__submit_result","mcp__pluriply__share_update","mcp__pluriply__get_channel_context"];function nt(){if(process.env.PLURIPLY_ALLOW_TEMPLATE_OVERRIDE!=="1")return null;let o=process.env.PLURIPLY_WORKER_TEMPLATE_OVERRIDE;if(!o)return null;try{return JSON.parse(o)}catch(e){throw new Error(`PLURIPLY_WORKER_TEMPLATE_OVERRIDE is not valid JSON: ${e.message}`)}}function ce(o,e){let t=nt()?.[o];if(!t)return;let n=r=>r.replace(/\{(taskId|channelCode|home|cwd|prompt|readOnly)\}/g,(s,i)=>String(e[i]));return{command:t.command,args:t.args.map(n)}}function It(o,{home:e,cwd:t,prompt:n,logDir:r,taskId:s,channelCode:i,permissionMode:a="acceptEdits",timeoutMs:c=W.timeoutMs,readOnly:l=!1}){let u=ce(o,{taskId:s,channelCode:i,home:e,cwd:t,prompt:n,readOnly:l});if(u)return u;switch(o){case"codex":return{command:"codex",args:["exec","-C",t,"--skip-git-repo-check","-s",l?"read-only":"workspace-write","-c",'approval_policy="never"',"-o",ne(r,`${s}.last.md`),n]};case"claude-code":{if(!ie.includes(a))throw new Error(`invalid permissionMode "${a}" for claude-code worker`);let h=JSON.stringify({mcpServers:{pluriply:{command:process.execPath,args:[oe,"connector","--agent","claude-code"],env:{PLURIPLY_HOME:e}}}});return{command:"claude",args:["-p",...l?["--allowedTools",...ae,"--permission-mode","default","--add-dir",r]:["--permission-mode",a],"--mcp-config",h,"--strict-mcp-config","--output-format","json",n]}}case"antigravity":return{command:se(),args:["-p",n,...l?["--mode","plan"]:[],"--dangerously-skip-permissions","--output-format","text","--print-timeout",`${Math.ceil(c/1e3)}s`]};default:return null}}import{pidAlive as ge}from"../shared/probe.js";import{execFileSync as le}from"node:child_process";import{writeFileSync as ue}from"node:fs";var he=20*1024*1024;function St({cwd:o,review:e,outFile:t}){let n=["-c","core.fsmonitor=false","diff","--no-color","--no-ext-diff","--no-textconv"],r;e.gitRange?(n.push(e.gitRange,"--"),e.paths?.length&&n.push(...e.paths),r=`git diff ${e.gitRange}`):e.paths?.length?(n.push("HEAD","--",...e.paths),r=`git diff HEAD -- ${e.paths.join(" ")}`):(n.push("HEAD"),r="git diff HEAD");try{let s=le("git",n,{cwd:o,encoding:"utf8",maxBuffer:he,timeout:2e4,stdio:["ignore","pipe","pipe"]});return ue(t,s),{file:t,bytes:Buffer.byteLength(s),target:r}}catch(s){return{error:(s.stderr?String(s.stderr).trim().split(`
|
|
4
|
+
`).at(-1):"")||s.message,target:r}}}var ye=o=>`run \`pluriply worker enable ${o}\` to let the hub process this automatically`,ke=o=>`no worker template is configured for "${o}"; enable it via config or set PLURIPLY_WORKER_TEMPLATE_OVERRIDE`,Ie=new Set(["completed","failed","cancelled"]);function Se(o){return et.includes(o)?!0:!!nt()?.[o]}function _e(o){try{return pe(o,"utf8").trimEnd().split(`
|
|
5
|
+
`).slice(-20).join(`
|
|
6
|
+
`)}catch{return""}}function Ee({agent:o,channelCode:e,taskId:t,cwd:n,from:r}){return[`\uB2F9\uC2E0\uC740 Pluriply \uCC44\uB110 ${e}\uC5D0\uC11C \uC704\uC784\uBC1B\uC740 \uC791\uC5C5\uC744 \uCC98\uB9AC\uD558\uB294 ${o} \uC6CC\uCEE4\uC785\uB2C8\uB2E4.`,`\uC694\uCCAD\uC790\uB294 ${r} \uC785\uB2C8\uB2E4. join_channel \uC751\uB2F5\uC758 me \uAC00 \uB2F9\uC2E0\uC758 \uC778\uC2A4\uD134\uC2A4 ID\uC785\uB2C8\uB2E4.`,`1. pluriply MCP \uB3C4\uAD6C join_channel \uB85C \uCC44\uB110 ${e} \uC5D0 \uCC38\uC5EC\uD558\uC138\uC694.`,`2. get_task_result \uB85C \uD0DC\uC2A4\uD06C ${t} \uB97C \uC77D\uC73C\uC138\uC694. \uC694\uCCAD \uBCF8\uBB38\uACFC \uCCA8\uBD80 \uACBD\uB85C\uAC00 \uC788\uC2B5\uB2C8\uB2E4.`,`3. \uC791\uC5C5\uC744 \uC218\uD589\uD558\uC138\uC694. \uC791\uC5C5 \uD3F4\uB354\uB294 ${n} \uC785\uB2C8\uB2E4. \uADF8 \uBC16\uC758 \uD30C\uC77C\uC740 \uC218\uC815\uD558\uC9C0 \uB9C8\uC138\uC694.`,"4. \uC911\uAC04\uC5D0 share_update \uB85C \uC9C4\uD589 \uC0C1\uD669\uC744 \uD55C \uBC88 \uC774\uC0C1 \uB0A8\uAE30\uC138\uC694.","5. \uB05D\uB098\uBA74 submit_result \uB85C \uACB0\uACFC\uB97C \uC81C\uCD9C\uD558\uC138\uC694. \uD560 \uC218 \uC5C6\uC73C\uBA74 failed: true \uB85C \uC774\uC720\uB97C \uC81C\uCD9C\uD558\uC138\uC694.","6. \uB2E4\uB978 \uB3C4\uAD6C\uC5D0 \uC704\uC784\uC774 \uAF2D \uD544\uC694\uD560 \uB54C\uB9CC send_task \uB97C \uC4F0\uC138\uC694. \uC704\uC784 \uAE4A\uC774 \uC81C\uD55C\uC774 \uC788\uC2B5\uB2C8\uB2E4."].join(`
|
|
7
|
+
`)}function ve({agent:o,channelCode:e,taskId:t,cwd:n,from:r,diff:s=null}){let i=s?.file?`3. \uD5C8\uBE0C\uAC00 \uB9CC\uB4E0 unified diff \uD30C\uC77C ${s.file} (${s.target}) \uC744 \uC77D\uACE0 \uAC80\uD1A0\uD558\uC138\uC694. git \uC744 \uC9C1\uC811 \uC2E4\uD589\uD560 \uD544\uC694\uAC00 \uC5C6\uC2B5\uB2C8\uB2E4. \uC791\uC5C5 \uD3F4\uB354\uB294 ${n} \uC785\uB2C8\uB2E4.`:`3. \uD5C8\uBE0C\uAC00 diff \uD30C\uC77C\uC744 \uB9CC\uB4E4\uC9C0 \uBABB\uD588\uC2B5\uB2C8\uB2E4(${s?.error??"unknown"}; ${s?.target??"git diff"}). \uC791\uC5C5 \uD3F4\uB354 ${n} \uC758 \uD30C\uC77C\uC744 \uC9C1\uC811 \uC77D\uC5B4 \uAC80\uD1A0\uD558\uC138\uC694.`;return[`\uB2F9\uC2E0\uC740 Pluriply \uCC44\uB110 ${e}\uC5D0\uC11C \uCF54\uB4DC \uB9AC\uBDF0\uB97C \uC704\uC784\uBC1B\uC740 ${o} \uB9AC\uBDF0\uC5B4\uC785\uB2C8\uB2E4.`,`\uC694\uCCAD\uC790\uB294 ${r} \uC785\uB2C8\uB2E4. join_channel \uC751\uB2F5\uC758 me \uAC00 \uB2F9\uC2E0\uC758 \uC778\uC2A4\uD134\uC2A4 ID\uC785\uB2C8\uB2E4.`,`1. pluriply MCP \uB3C4\uAD6C join_channel \uB85C \uCC44\uB110 ${e} \uC5D0 \uCC38\uC5EC\uD558\uC138\uC694.`,`2. get_task_result \uB85C \uD0DC\uC2A4\uD06C ${t} \uB97C \uC77D\uC73C\uC138\uC694. review.gitRange / review.paths \uAC00 \uB300\uC0C1, review.focus \uAC00 \uAD00\uC810, request \uC5D0 \uCD94\uAC00 \uC124\uBA85\uC774 \uC788\uC2B5\uB2C8\uB2E4.`,`${i} \uD30C\uC77C\uC744 \uC218\uC815\uD558\uAC70\uB098 \uC0C1\uD0DC\uB97C \uBC14\uAFB8\uB294 \uBA85\uB839\uC744 \uC2E4\uD589\uD558\uC9C0 \uB9C8\uC138\uC694.`,"4. \uBC1C\uACAC\uC744 \uC2EC\uAC01\uB3C4(critical/important/minor)\uC640 file/line \uC73C\uB85C \uC815\uB9AC\uD574 submit_review \uB85C \uC81C\uCD9C\uD558\uC138\uC694. critical \uC774\uB098 important \uAC00 \uD558\uB098\uB77C\uB3C4 \uC788\uC73C\uBA74 verdict \uB294 request_changes, \uC5C6\uC73C\uBA74 approve, \uD310\uB2E8\uC744 \uC720\uBCF4\uD558\uBA74 comment \uC785\uB2C8\uB2E4.","5. \uAC80\uD1A0\uAC00 \uBD88\uAC00\uB2A5\uD558\uBA74(\uBC94\uC704\uAC00 \uC5C6\uAC70\uB098 git \uC800\uC7A5\uC18C\uAC00 \uC544\uB2C8\uBA74) submit_result \uC5D0 failed: true \uB85C \uC0AC\uC720\uB97C \uC81C\uCD9C\uD558\uC138\uC694.","6. \uB2E4\uB978 \uB3C4\uAD6C\uC5D0 \uC704\uC784\uD558\uC9C0 \uB9C8\uC138\uC694."].join(`
|
|
8
|
+
`)}var K=class{constructor({home:e,tasks:t}){this.home=e,this.tasks=t,this.running=new Map,this.queue=[],this.stopping=!1}dispatch(e,t,{interactive:n,config:r}){if(t.toInstance)return{kind:"pinned",hint:"pinned tasks are never handed to a worker"};let s=t.toTool??t.to;if(t.mode==="interactive")return{kind:"interactive"};if(t.mode==="auto"&&n)return{kind:"interactive"};if(this.stopping)return{kind:"none",hint:"hub is stopping"};if(!kt(r,s))return{kind:"none",hint:ye(s)};try{if(!Se(s))return{kind:"none",hint:ke(s)};if(this.#t(s)>=r.limits.maxConcurrentPerAgent)return this.#n(s)>=r.limits.maxQueuedPerAgent?{kind:"none",hint:`worker queue full (${r.limits.maxQueuedPerAgent}) for ${s}`}:(this.queue.push({code:e,task:t,config:r}),{kind:"queued"});this.#r(e,t,r)}catch(i){return{kind:"none",hint:`worker spawn failed: ${i.message}`}}return{kind:"spawned"}}runningCount(e){let t=0;for(let n of this.running.values())for(let r of n)(e===void 0||r.code===e)&&t++;return t}onCancelled(e,t){for(let r of this.running.values())for(let s of r)s.code===e&&s.taskId===t&&s.child.kill("SIGTERM");let n=this.queue.findIndex(r=>r.code===e&&r.task.taskId===t);n!==-1&&this.queue.splice(n,1)}reconcile(e){for(let t of this.tasks.list(e)){let n=t.worker;!n||n.endedAt||!Number.isInteger(n.pid)||this.#s(e,t.taskId)||ge(n.pid)||(this.tasks.setWorker(e,t.taskId,{endedAt:new Date().toISOString()}),this.tasks.failIfOpen(e,t.taskId,{result:"hub restarted while worker was running",by:`${n.agent} worker`}))}}async stopAll(){this.stopping=!0;let e=[];for(let r of this.running.values())for(let s of r)s.child.kill("SIGTERM"),e.push(s.child);for(let r of this.queue)try{this.tasks.failIfOpen(r.code,r.task.taskId,{result:"hub stopped while the task was queued",by:`${r.task.toTool??r.task.to} worker`})}catch{}this.queue.length=0;let t=r=>r.exitCode===null&&r.signalCode===null,n=Date.now()+2e3;for(;e.some(t)&&Date.now()<n;)await new Promise(r=>setTimeout(r,100));for(let r of e)if(t(r))try{r.kill("SIGKILL")}catch{}}#t(e){return this.running.get(e)?.size??0}#n(e){let t=0;for(let n of this.queue)(n.task.toTool??n.task.to)===e&&t++;return t}#s(e,t){for(let n of this.running.values())for(let r of n)if(r.code===e&&r.taskId===t)return!0;return!1}#r(e,t,n){let r=t.toTool??t.to,s=q(this.home,"workers");_t(s,{recursive:!0});let i=t.cwd??q(this.home,"workspaces",t.taskId);_t(i,{recursive:!0});let a=q(s,`${t.taskId}.log`),c=(t.kind??"task")==="review",l={agent:r,channelCode:e,taskId:t.taskId,cwd:i,from:t.from},u;if(c){let g=St({cwd:i,review:t.review??{},outFile:q(s,`${t.taskId}.diff`)});u=ve({...l,diff:g})}else u=Ee(l);let h=It(r,{home:this.home,cwd:i,prompt:u,logDir:s,taskId:t.taskId,channelCode:e,permissionMode:n.workers[r]?.permissionMode,timeoutMs:n.limits.timeoutMs,readOnly:c}),p=fe(a,"a"),f;try{f=de(h.command,h.args,{cwd:i,shell:!1,stdio:["ignore",p,p],env:{...process.env,PLURIPLY_HOME:this.home,PLURIPLY_WORKER_TASK:t.taskId,PLURIPLY_WORKER_AGENT:r,PLURIPLY_DEPTH:String(t.depth??0)}})}finally{me(p)}let b={code:e,taskId:t.taskId,child:f};this.running.has(r)||this.running.set(r,new Set),this.running.get(r).add(b),this.tasks.setWorker(e,t.taskId,{agent:r,pid:f.pid,startedAt:new Date().toISOString(),log:a});let d=!1,A=setTimeout(()=>{d=!0,f.kill("SIGTERM"),setTimeout(()=>f.kill("SIGKILL"),5e3).unref()},n.limits.timeoutMs),v=(g,k)=>{clearTimeout(A),this.running.get(r)?.delete(b);let I={endedAt:new Date().toISOString(),exitCode:g};d&&(I.timedOut=!0);try{this.tasks.setWorker(e,t.taskId,I);let S;if(k){S=`worker failed to start: ${k.message}`;try{we(a,`${S}
|
|
9
|
+
`)}catch{}}else d?S=`worker timed out after ${n.limits.timeoutMs/1e3}s`:S=`worker exited without submitting a result (exit ${g})
|
|
10
|
+
--- last log lines ---
|
|
11
|
+
${_e(a)}`;this.tasks.failIfOpen(e,t.taskId,{result:S,by:`${r} worker`})}catch{}this.#e(r)};f.on("exit",(g,k)=>v(g??(k?-1:0))),f.on("error",g=>v(-1,g))}#e(e){if(!this.stopping)for(let t=0;t<this.queue.length;t++){let n=this.queue[t];if((n.task.toTool??n.task.to)!==e)continue;let r;try{r=this.tasks.get(n.code,n.task.taskId).status}catch{this.queue.splice(t,1),t--;continue}if(Ie.has(r)){this.queue.splice(t,1),t--;continue}if(this.#t(e)>=n.config.limits.maxConcurrentPerAgent)return;this.queue.splice(t,1);try{this.#r(n.code,n.task,n.config)}catch(s){try{this.tasks.failIfOpen(n.code,n.task.taskId,{result:`worker spawn failed: ${s.message}`,by:`${n.task.toTool??n.task.to} worker`})}catch{}}return}}};import{pluriplyHome as Te}from"../shared/paths.js";import{shortId as Ce}from"../shared/ids.js";import{isValidAgentName as Pe,isInstanceId as Oe,makeInstanceId as Le,toolOf as je,cwdKey as De}from"../shared/identity.js";import{PACKAGE_VERSION as $t,PROTOCOL_VERSION as xt}from"../shared/version.js";import{pingHub as Me,pidAlive as bt,homeId as At}from"../shared/probe.js";function rt(o){try{let e=JSON.parse(Ae(o,"utf8"));return Number.isInteger(e?.pid)&&Number.isInteger(e?.port)?e:null}catch{return null}}var st=class{constructor({home:e=Te(),port:t=0,verifyDelayMs:n=100}={}){this.home=e,this.requestedPort=t,this.verifyDelayMs=n;let r=new P(e);this.channels=new L(r),this.tasks=new M(this.channels),this.context=new N(this.channels),this.agents=new O(r),this.workers=new K({home:e,tasks:this.tasks}),this.wss=null,this.redundant=!1,this.redundantPort=null,this.connections=new Map,this.issued=new Set}get port(){return this.redundant?this.redundantPort:this.wss?.address()?.port}async start(){xe(this.home,{recursive:!0}),await new Promise((t,n)=>{this.wss=new $e({host:"127.0.0.1",port:this.requestedPort,verifyClient:(r,s)=>{"origin"in r.req.headers?s(!1,403,"Forbidden"):s(!0)}}),this.wss.on("listening",t),this.wss.on("error",n)}),this.wss.on("connection",t=>{this.connections.set(t,{instanceId:null,tool:null,worker:!1,cwdKey:null,channels:new Set,abort:new AbortController}),t.on("message",n=>this.#s(t,n)),t.on("close",()=>{this.connections.get(t)?.abort.abort(),this.connections.delete(t)})});let e=vt(this.home,"hub.json");for(let t=0;t<2;t++){if(this.#n(e)){await new Promise(s=>setTimeout(s,this.verifyDelayMs));let r=rt(e);return r&&r.pid!==process.pid?(await new Promise(s=>this.wss.close(s)),this.wss=null,this.redundant=!0,this.redundantPort=r.port,{port:r.port,redundant:!0}):{port:this.port}}let n=rt(e);if(n&&await this.#t(n))return await new Promise(r=>this.wss.close(r)),this.wss=null,this.redundant=!0,this.redundantPort=n.port,{port:n.port,redundant:!0};Et(e,{force:!0})}throw await new Promise(t=>this.wss.close(t)),this.wss=null,new Error("could not acquire hub lock")}async#t(e){if(!bt(e.pid))return!1;let t=Date.now()+2e3;for(;;){let n=await Me(e.port,300);if(n)return!n.home||n.home===At(this.home);if(Date.now()>=t||!bt(e.pid))return!1;await new Promise(r=>setTimeout(r,200))}}#n(e){let t=JSON.stringify({pid:process.pid,port:this.wss.address().port,version:$t,protocol:xt,startedAt:new Date().toISOString()},null,2);try{return be(e,t,{flag:"wx"}),!0}catch(n){if(n.code==="EEXIST")return!1;throw n}}async stop(){if(this.redundant||!this.wss)return;await this.workers.stopAll();let e=vt(this.home,"hub.json");rt(e)?.pid===process.pid&&Et(e,{force:!0});for(let t of this.connections.keys())t.terminate();await new Promise(t=>this.wss.close(t)),this.wss=null}async#s(e,t){let n;try{n=JSON.parse(t.toString())}catch{return}try{let r=await this.#l(n.type,n.payload??{},e);this.#r(e,{id:n.id,ok:!0,payload:r})}catch(r){this.#r(e,{id:n.id,ok:!1,error:{message:r.message}})}}#r(e,t){e.readyState===e.OPEN&&e.send(JSON.stringify(t))}#e(e){let t=this.connections.get(e);if(!t||!t.instanceId)throw new Error("say hello first");return t}onlineInstances(e){let t=new Set;for(let n of this.connections.values())n.instanceId&&n.channels.has(e)&&t.add(n.instanceId);return t}isInteractive(e,t){for(let n of this.connections.values())if(n.tool===t&&!n.worker&&n.channels.has(e))return!0;return!1}#a(e){let t=[],n=[];for(let r of this.connections.values())!r.instanceId||!r.channels.has(e)||(r.worker?n:t).push(r.instanceId);return{interactive:t,workers:n}}#o(e,t){let n=this.onlineInstances(e);return t.map(r=>({...r,online:n.has(r.instanceId)}))}#c(e){for(;;){let t=Le(e,Ce(4));if(!(this.issued.has(t)||[...this.connections.values()].some(r=>r.instanceId===t)))return this.issued.add(t),t}}#i(e,t,n){let r=this.onlineInstances(n);r.add(t.instanceId);let{peers:s}=this.channels.join(n,{instanceId:t.instanceId,tool:t.tool,worker:t.worker},{online:r});return this.agents.touch(t.cwdKey,n),t.channels.add(n),this.#o(n,s)}#l(e,t,n){switch(e){case"ping":return{pong:!0,version:$t,protocol:xt,pid:process.pid,home:At(this.home)};case"channel.create":return{channelCode:this.channels.create().channel.code};case"agent.hello":{if(!Pe(t.tool))throw new Error(`invalid agent name: ${t.tool}`);if(typeof t.cwd!="string"||t.cwd.length===0||!Re(t.cwd))throw new Error("cwd is required");let r=this.connections.get(n);if(r.instanceId)return{instanceId:r.instanceId};let s=t.instanceId;if(s!==void 0){if(!Oe(s)||je(s)!==t.tool)throw new Error(`invalid instanceId: ${s}`)}else s=this.#c(t.tool);return r.instanceId=s,r.tool=t.tool,r.worker=!!t.worker,r.cwdKey=De(t.tool,t.cwd),{instanceId:s}}case"channel.join":{let r=this.#e(n);this.channels.get(t.channelCode);let s=this.#i(n,r,t.channelCode);return{channelCode:t.channelCode,peers:s}}case"channel.peers":return{peers:this.#o(t.channelCode,this.channels.peers(t.channelCode,{online:this.onlineInstances(t.channelCode)}))};case"channel.presence":return this.channels.get(t.channelCode),this.#a(t.channelCode);case"agent.resume":{let r=this.#e(n);if(r.channels.size>0)return{channelCode:null,alreadyJoined:!0};let s=this.agents.resume(r.cwdKey);if(!s)return{channelCode:null};let i=this.#i(n,r,s);return{channelCode:s,peers:i}}case"task.create":{let r=this.#e(n),s=tt(this.home),{task:i,targetJoined:a,targetOnline:c,warning:l}=this.tasks.create(t.channelCode,{...t,from:r.instanceId,depth:t.depth??0,mode:t.mode??"auto",maxDepth:s.limits.maxDepth,allowedRoots:s.allowedRoots,online:this.onlineInstances(t.channelCode)});this.agents.touch(r.cwdKey,t.channelCode);let u=this.workers.dispatch(t.channelCode,i,{interactive:this.isInteractive(t.channelCode,i.toTool),config:s}),h={taskId:i.taskId,targetJoined:a,dispatch:u.kind};return c!==void 0&&(h.targetOnline=c),l&&(h.warning=l),u.hint&&(h.hint=u.hint),h}case"task.list":return this.workers.reconcile(t.channelCode),{tasks:this.tasks.list(t.channelCode,{to:t.to,from:t.from,status:t.status,kind:t.kind})};case"task.get":return this.workers.reconcile(t.channelCode),{task:this.tasks.get(t.channelCode,t.taskId)};case"task.wait":{this.workers.reconcile(t.channelCode);let r=Math.min(Math.max(Number(t.timeoutMs)||0,1),15e3),s=this.connections.get(n)?.abort.signal;return this.tasks.waitFor(t.channelCode,t.taskId,r,{signal:s}).then(i=>{if(i===null)throw new Error("connection closed while waiting");return{task:i}})}case"task.claim":{let r=this.#e(n),s=this.tasks.claim(t.channelCode,t.taskId,r.instanceId);return this.agents.touch(r.cwdKey,t.channelCode),{task:s}}case"task.complete":{let r=this.#e(n),s=this.tasks.complete(t.channelCode,t.taskId,{from:r.instanceId,result:t.result,status:t.status,worker:r.worker,review:t.review});if(this.agents.touch(r.cwdKey,t.channelCode),(s.kind??"task")==="review"&&s.status==="completed")try{this.context.add(t.channelCode,{from:r.instanceId,summary:`[review] ${s.result.verdict} by ${r.instanceId}: ${s.result.summary}`,artifacts:[]})}catch{}return{task:s}}case"task.cancel":{let r=this.#e(n),s=this.tasks.cancel(t.channelCode,t.taskId,{agent:r.instanceId,reason:t.reason});return this.agents.touch(r.cwdKey,t.channelCode),this.workers.onCancelled(t.channelCode,t.taskId),{task:s}}case"worker.status":return t.channelCode!==void 0&&this.channels.get(t.channelCode),{running:this.workers.runningCount(t.channelCode)};case"context.add":{let r=this.#e(n),s=this.context.add(t.channelCode,{...t,from:r.instanceId});return this.agents.touch(r.cwdKey,t.channelCode),{entryId:s.entryId}}case"context.list":return{entries:this.context.list(t.channelCode,{limit:t.limit})};default:throw new Error(`unknown message type: ${e}`)}}};import{spawn as Ne}from"node:child_process";import{readFileSync as We,existsSync as Rt,rmSync as ot}from"node:fs";import{join as Tt}from"node:path";import{fileURLToPath as qe}from"node:url";import{pingHub as Ct,homeId as Ke,pidAlive as Ue}from"../shared/probe.js";var He=qe(new URL("../../bin/pluriply.js",import.meta.url));function it(o){let e=Tt(o,"hub.json");if(!Rt(e))return null;try{let t=JSON.parse(We(e,"utf8"));return Number.isInteger(t?.pid)&&Number.isInteger(t?.port)?t:null}catch{return null}}async function Pt(o){let e=it(o);if(!e)return null;let t=await Ct(e.port);return!t||t.home&&t.home!==Ke(o)?null:{...t,port:e.port,lockPid:e.pid}}async function Fe({home:o,timeoutMs:e=5e3}){Ne(process.execPath,[He,"hub","start"],{detached:!0,stdio:"ignore",env:{...process.env,PLURIPLY_HOME:o}}).unref();let n=Date.now()+e;for(;Date.now()<n;){let r=await Pt(o);if(r)return r;await new Promise(s=>setTimeout(s,100))}throw new Error(`failed to start pluriply hub within ${e/1e3}s`)}async function Je({home:o,timeoutMs:e=5e3}){let t=it(o),n=Tt(o,"hub.json");if(!t)return"not-running";let r=await Ct(t.port,1e3),s=r&&Number.isInteger(r.pid)&&Number.isInteger(t.pid)?r.pid!==t.pid:!1;if(!r||s)return ot(n,{force:!0}),"not-running";try{process.kill(t.pid,"SIGTERM")}catch{return ot(n,{force:!0}),"stopped"}let i=Date.now()+e;for(;Date.now()<i;){if(!Rt(n))return"stopped";if(!Ue(t.pid))return ot(n,{force:!0}),"stopped";await new Promise(a=>setTimeout(a,100))}return"timeout"}export{st as Hub,et as TEMPLATE_AGENTS,Pt as liveHub,tt as loadConfig,it as readLock,ee as saveConfig,Fe as spawnHub,Je as stopHub};
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import {
|
|
3
|
+
existsSync,
|
|
4
|
+
readFileSync,
|
|
5
|
+
writeFileSync,
|
|
6
|
+
copyFileSync,
|
|
7
|
+
renameSync,
|
|
8
|
+
} from "node:fs";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { homedir } from "node:os";
|
|
11
|
+
import { agyCommand } from "../shared/agy.js";
|
|
12
|
+
import { MCP_REGISTRARS } from "../shared/mcp-register.js";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* 어댑터가 쓰는 환경. 테스트는 exec/fs/homeDir/platform 을 주입한다.
|
|
16
|
+
* @param {object} [o]
|
|
17
|
+
*/
|
|
18
|
+
export function makeEnv(o = {}) {
|
|
19
|
+
return {
|
|
20
|
+
exec: execFileSync,
|
|
21
|
+
log: console.log,
|
|
22
|
+
binPath: o.binPath,
|
|
23
|
+
homeDir: homedir(),
|
|
24
|
+
platform: process.platform,
|
|
25
|
+
node: process.execPath,
|
|
26
|
+
processEnv: process.env,
|
|
27
|
+
fs: { existsSync, readFileSync, writeFileSync, copyFileSync, renameSync },
|
|
28
|
+
...o,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** 테스트용 스킵 변수(구형 PLURIPLY_SKIP_CODEX_MCP 도 인정) */
|
|
33
|
+
function skipped(env) {
|
|
34
|
+
return Boolean(
|
|
35
|
+
env.processEnv.PLURIPLY_SKIP_MCP_REGISTER ||
|
|
36
|
+
env.processEnv.PLURIPLY_SKIP_CODEX_MCP,
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* @param {"claude-desktop"|"antigravity-ide"} id
|
|
42
|
+
* Antigravity 는 2.x 부터 IDE(`~/.gemini/antigravity-ide`) 와 허브(`~/.gemini/antigravity`) 로
|
|
43
|
+
* 데이터 폴더가 갈라졌다. IDE 폴더가 있으면 그쪽, 없으면(구버전) 옛 경로.
|
|
44
|
+
*/
|
|
45
|
+
export function configPath(id, { homeDir, platform, processEnv = {}, fs = { existsSync } }) {
|
|
46
|
+
if (id === "antigravity-ide") {
|
|
47
|
+
const ideDir = join(homeDir, ".gemini", "antigravity-ide");
|
|
48
|
+
const dir = fs.existsSync(ideDir) ? ideDir : join(homeDir, ".gemini", "antigravity");
|
|
49
|
+
return join(dir, "mcp_config.json");
|
|
50
|
+
}
|
|
51
|
+
if (platform === "darwin")
|
|
52
|
+
return join(
|
|
53
|
+
homeDir,
|
|
54
|
+
"Library",
|
|
55
|
+
"Application Support",
|
|
56
|
+
"Claude",
|
|
57
|
+
"claude_desktop_config.json",
|
|
58
|
+
);
|
|
59
|
+
if (platform === "win32")
|
|
60
|
+
return join(
|
|
61
|
+
processEnv.APPDATA ?? join(homeDir, "AppData", "Roaming"),
|
|
62
|
+
"Claude",
|
|
63
|
+
"claude_desktop_config.json",
|
|
64
|
+
);
|
|
65
|
+
return join(homeDir, ".config", "Claude", "claude_desktop_config.json");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** CLI 가 자체 `mcp list/add` 를 제공하는 클라이언트 */
|
|
69
|
+
function cliClient({ id, label, agent, command, list, add, hint }) {
|
|
70
|
+
const cmd = () => (typeof command === "function" ? command() : command);
|
|
71
|
+
return {
|
|
72
|
+
id,
|
|
73
|
+
label,
|
|
74
|
+
agent,
|
|
75
|
+
kind: "cli",
|
|
76
|
+
detect(env) {
|
|
77
|
+
try {
|
|
78
|
+
const out = env.exec(cmd(), ["--version"], { stdio: "pipe" })
|
|
79
|
+
.toString()
|
|
80
|
+
.trim();
|
|
81
|
+
return { installed: true, detail: out.split("\n")[0] };
|
|
82
|
+
} catch {
|
|
83
|
+
return { installed: false };
|
|
84
|
+
}
|
|
85
|
+
},
|
|
86
|
+
status(env) {
|
|
87
|
+
try {
|
|
88
|
+
const out = env.exec(cmd(), list, { stdio: "pipe" }).toString();
|
|
89
|
+
return out.includes("pluriply") ? "present" : "missing";
|
|
90
|
+
} catch (err) {
|
|
91
|
+
return { error: err.message };
|
|
92
|
+
}
|
|
93
|
+
},
|
|
94
|
+
register(env) {
|
|
95
|
+
if (skipped(env)) return "skipped";
|
|
96
|
+
const st = this.status(env);
|
|
97
|
+
if (st === "present") return "present";
|
|
98
|
+
if (typeof st === "object") {
|
|
99
|
+
env.log(`hint: make sure ${label} has pluriply registered: ${hint(env.binPath)}`);
|
|
100
|
+
return "failed";
|
|
101
|
+
}
|
|
102
|
+
try {
|
|
103
|
+
env.exec(cmd(), add(env.node, env.binPath), { stdio: "pipe" });
|
|
104
|
+
env.log(`registered pluriply MCP server in ${label}`);
|
|
105
|
+
return "registered";
|
|
106
|
+
} catch {
|
|
107
|
+
env.log(`hint: make sure ${label} has pluriply registered: ${hint(env.binPath)}`);
|
|
108
|
+
return "failed";
|
|
109
|
+
}
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** `mcpServers` JSON 설정 파일을 쓰는 데스크톱 앱 */
|
|
115
|
+
function jsonClient({ id, label, agent }) {
|
|
116
|
+
const file = (env) => configPath(id, env);
|
|
117
|
+
const read = (env) => {
|
|
118
|
+
const raw = env.fs.readFileSync(file(env), "utf8");
|
|
119
|
+
if (raw.trim() === "") return { mcpServers: {} };
|
|
120
|
+
const doc = JSON.parse(raw);
|
|
121
|
+
if (!doc || typeof doc !== "object" || Array.isArray(doc))
|
|
122
|
+
throw new Error("config root is not an object");
|
|
123
|
+
return doc;
|
|
124
|
+
};
|
|
125
|
+
return {
|
|
126
|
+
id,
|
|
127
|
+
label,
|
|
128
|
+
agent,
|
|
129
|
+
kind: "config",
|
|
130
|
+
detect(env) {
|
|
131
|
+
return env.fs.existsSync(file(env))
|
|
132
|
+
? { installed: true, detail: file(env) }
|
|
133
|
+
: { installed: false };
|
|
134
|
+
},
|
|
135
|
+
status(env) {
|
|
136
|
+
try {
|
|
137
|
+
return read(env).mcpServers?.pluriply ? "present" : "missing";
|
|
138
|
+
} catch (err) {
|
|
139
|
+
return { error: `${file(env)}: ${err.message}` };
|
|
140
|
+
}
|
|
141
|
+
},
|
|
142
|
+
register(env) {
|
|
143
|
+
if (skipped(env)) return "skipped";
|
|
144
|
+
const st = this.status(env);
|
|
145
|
+
if (st === "present") return "present";
|
|
146
|
+
if (typeof st === "object") {
|
|
147
|
+
// 손상된 설정은 덮어쓰지 않는다
|
|
148
|
+
env.log(`cannot update ${label} config: ${st.error}`);
|
|
149
|
+
return "failed";
|
|
150
|
+
}
|
|
151
|
+
const path = file(env);
|
|
152
|
+
const doc = read(env);
|
|
153
|
+
doc.mcpServers = {
|
|
154
|
+
...(doc.mcpServers ?? {}),
|
|
155
|
+
pluriply: {
|
|
156
|
+
command: env.node,
|
|
157
|
+
args: [env.binPath, "connector", "--agent", agent],
|
|
158
|
+
},
|
|
159
|
+
};
|
|
160
|
+
env.fs.copyFileSync(path, `${path}.bak`);
|
|
161
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
162
|
+
env.fs.writeFileSync(tmp, JSON.stringify(doc, null, 2) + "\n");
|
|
163
|
+
env.fs.renameSync(tmp, path);
|
|
164
|
+
env.log(`registered pluriply MCP server in ${label} (${path}, backup ${path}.bak)`);
|
|
165
|
+
return "registered";
|
|
166
|
+
},
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export const CLIENTS = [
|
|
171
|
+
cliClient({
|
|
172
|
+
id: "claude-code",
|
|
173
|
+
label: "Claude Code",
|
|
174
|
+
agent: "claude-code",
|
|
175
|
+
command: "claude",
|
|
176
|
+
list: ["mcp", "list"],
|
|
177
|
+
add: (node, bin) => [
|
|
178
|
+
"mcp", "add", "-s", "user", "pluriply", "--", node, bin, "connector", "--agent", "claude-code",
|
|
179
|
+
],
|
|
180
|
+
hint: (bin) => `claude mcp add -s user pluriply -- node "${bin}" connector --agent claude-code`,
|
|
181
|
+
}),
|
|
182
|
+
cliClient({
|
|
183
|
+
id: "codex",
|
|
184
|
+
label: MCP_REGISTRARS.codex.label,
|
|
185
|
+
agent: "codex",
|
|
186
|
+
command: "codex",
|
|
187
|
+
list: MCP_REGISTRARS.codex.list.slice(1),
|
|
188
|
+
add: (node, bin) => MCP_REGISTRARS.codex.add(node, bin).slice(1),
|
|
189
|
+
hint: MCP_REGISTRARS.codex.hint,
|
|
190
|
+
}),
|
|
191
|
+
cliClient({
|
|
192
|
+
id: "antigravity",
|
|
193
|
+
label: MCP_REGISTRARS.antigravity.label,
|
|
194
|
+
agent: "antigravity",
|
|
195
|
+
command: agyCommand,
|
|
196
|
+
list: MCP_REGISTRARS.antigravity.list.slice(1),
|
|
197
|
+
add: (node, bin) => MCP_REGISTRARS.antigravity.add(node, bin).slice(1),
|
|
198
|
+
hint: MCP_REGISTRARS.antigravity.hint,
|
|
199
|
+
}),
|
|
200
|
+
jsonClient({ id: "claude-desktop", label: "Claude Desktop", agent: "claude-desktop" }),
|
|
201
|
+
jsonClient({ id: "antigravity-ide", label: "Antigravity IDE", agent: "antigravity-ide" }),
|
|
202
|
+
];
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { CLIENTS, makeEnv } from "./clients.js";
|
|
4
|
+
import { saveConfig, TEMPLATE_AGENTS } from "../hub/index.js";
|
|
5
|
+
import { ensureHub } from "../connector/hub-client.js";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* `pluriply setup`: 설치된 클라이언트를 감지해 pluriply 커넥터를 멱등 등록한다.
|
|
9
|
+
* @param {{only?: string[], workers?: boolean, dryRun?: boolean, env?: object, home: string}} opts
|
|
10
|
+
* @returns {Promise<{rows: Array<{id: string, label: string, installed: boolean, result: string}>, failed: number, hub?: {port: number}}>}
|
|
11
|
+
*/
|
|
12
|
+
export async function runSetup({ only, workers = false, dryRun = false, env, home }) {
|
|
13
|
+
const e = env ?? makeEnv();
|
|
14
|
+
const targets = only
|
|
15
|
+
? only.map((id) => {
|
|
16
|
+
const c = CLIENTS.find((x) => x.id === id);
|
|
17
|
+
if (!c) throw new Error(`unknown client: ${id} (known: ${CLIENTS.map((x) => x.id).join(", ")})`);
|
|
18
|
+
return c;
|
|
19
|
+
})
|
|
20
|
+
: CLIENTS;
|
|
21
|
+
const rows = [];
|
|
22
|
+
let failed = 0;
|
|
23
|
+
const enabledAgents = [];
|
|
24
|
+
for (const c of targets) {
|
|
25
|
+
const det = c.detect(e);
|
|
26
|
+
if (!det.installed) {
|
|
27
|
+
rows.push({ id: c.id, label: c.label, installed: false, result: "not installed" });
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
let result;
|
|
31
|
+
if (dryRun) {
|
|
32
|
+
const st = c.status(e);
|
|
33
|
+
result = st === "present" ? "present" : typeof st === "object" ? `failed: ${st.error}` : "planned";
|
|
34
|
+
} else {
|
|
35
|
+
result = c.register(e);
|
|
36
|
+
if (result === "failed") result = "failed: see hint above";
|
|
37
|
+
}
|
|
38
|
+
if (result.startsWith("failed")) failed++;
|
|
39
|
+
else if (c.kind === "cli" && TEMPLATE_AGENTS.includes(c.agent)) enabledAgents.push(c.agent);
|
|
40
|
+
rows.push({ id: c.id, label: c.label, installed: true, result });
|
|
41
|
+
}
|
|
42
|
+
if (workers && !dryRun && enabledAgents.length > 0) enableWorkers(home, enabledAgents);
|
|
43
|
+
const out = { rows, failed, workers: workers ? enabledAgents : [] };
|
|
44
|
+
if (!dryRun) {
|
|
45
|
+
try {
|
|
46
|
+
out.hub = { port: (await ensureHub({ home })).port };
|
|
47
|
+
} catch (err) {
|
|
48
|
+
out.hubError = err.message;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return out;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** bin/pluriply.js `worker enable` 과 같은 병합 규칙: 원본 문서를 보존하고 workers 만 갱신 */
|
|
55
|
+
function enableWorkers(home, agents) {
|
|
56
|
+
const file = join(home, "config.json");
|
|
57
|
+
let rawDoc = {};
|
|
58
|
+
if (existsSync(file)) {
|
|
59
|
+
try {
|
|
60
|
+
rawDoc = JSON.parse(readFileSync(file, "utf8"));
|
|
61
|
+
} catch {
|
|
62
|
+
rawDoc = {};
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
if (!rawDoc || typeof rawDoc !== "object" || Array.isArray(rawDoc)) rawDoc = {};
|
|
66
|
+
const workers = { ...(rawDoc.workers ?? {}) };
|
|
67
|
+
for (const a of agents) workers[a] = { ...(workers[a] ?? {}), enabled: true };
|
|
68
|
+
saveConfig(home, { ...rawDoc, workers });
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** @param {Awaited<ReturnType<typeof runSetup>>} r @returns {string[]} 사람이 읽는 표 */
|
|
72
|
+
export function formatSetup(r, { workers = false } = {}) {
|
|
73
|
+
const lines = r.rows.map((row) => `${row.id.padEnd(16)} ${row.installed ? "installed " : "not installed"} ${row.result}`);
|
|
74
|
+
if (r.hub) lines.push(`hub: running on port ${r.hub.port}`);
|
|
75
|
+
if (r.hubError) lines.push(`hub: could not start (${r.hubError})`);
|
|
76
|
+
if (r.workers.length) lines.push(`workers enabled: ${r.workers.join(", ")}`);
|
|
77
|
+
else if (!workers) {
|
|
78
|
+
const cli = r.rows.filter((x) => x.installed && TEMPLATE_AGENTS.includes(x.id)).map((x) => x.id);
|
|
79
|
+
if (cli.length) lines.push(`hint: run \`pluriply worker enable <${cli.join("|")}>\` to let the hub run that tool headlessly (or re-run setup --workers)`);
|
|
80
|
+
}
|
|
81
|
+
return lines;
|
|
82
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Antigravity CLI(`agy`) 실행 파일 경로.
|
|
7
|
+
* 같은 이름의 IDE 런처(~/.antigravity/antigravity/bin/agy, VS Code 계열 셸 스크립트)가 PATH 앞에
|
|
8
|
+
* 올 수 있어 이름만으로는 잘못된 것이 잡힌다. 설치 스크립트가 두는 ~/.local/bin/agy(Go 바이너리)를
|
|
9
|
+
* 우선하고, 없으면 PATH 탐색에 맡긴다. PLURIPLY_AGY_PATH 로 명시할 수 있다.
|
|
10
|
+
* @returns {string}
|
|
11
|
+
*/
|
|
12
|
+
export function agyCommand() {
|
|
13
|
+
const explicit = process.env.PLURIPLY_AGY_PATH;
|
|
14
|
+
if (explicit) return explicit;
|
|
15
|
+
const local = join(homedir(), ".local", "bin", "agy");
|
|
16
|
+
return existsSync(local) ? local : "agy";
|
|
17
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
|
|
4
|
+
/** ids.js의 shortId 알파벳(i, l, o, 0, 1 제외)과 같은 문자 집합 */
|
|
5
|
+
const INSTANCE_RE = /^[^#/\s]+#[a-hj-km-np-z2-9]{4}$/;
|
|
6
|
+
|
|
7
|
+
/** @param {unknown} name @returns {boolean} 도구 이름: 비어 있지 않고 '#', '/', 공백이 없다 */
|
|
8
|
+
export function isValidAgentName(name) {
|
|
9
|
+
return typeof name === "string" && name.length > 0 && !/[#/\s]/.test(name);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* 실행 환경으로 도구 이름을 보정한다. Antigravity IDE 는 agy CLI 가 쓴
|
|
14
|
+
* `~/.gemini/config/mcp_config.json`(`--agent antigravity`) 도 읽어 같은 키 `pluriply` 가
|
|
15
|
+
* 이기므로, IDE 언어 서버가 자식에게 주는 ANTIGRAVITY_EDITOR_APP_ROOT 로 IDE 를 구분한다.
|
|
16
|
+
* @param {string} agent @param {NodeJS.ProcessEnv} env @returns {string}
|
|
17
|
+
*/
|
|
18
|
+
export function resolveAgentName(agent, env) {
|
|
19
|
+
if (agent === "antigravity" && env.ANTIGRAVITY_EDITOR_APP_ROOT) return "antigravity-ide";
|
|
20
|
+
return agent;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** @param {string} tool @param {string} suffix @returns {string} 짧은 표기 `<tool>#<suffix>` */
|
|
24
|
+
export function makeInstanceId(tool, suffix) {
|
|
25
|
+
return `${tool}#${suffix}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** @param {unknown} s @returns {boolean} */
|
|
29
|
+
export function isInstanceId(s) {
|
|
30
|
+
return typeof s === "string" && INSTANCE_RE.test(s);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* send_task의 `to`를 도구 수준/인스턴스 수준으로 나눈다.
|
|
35
|
+
* @param {string} to @returns {{tool: string, instance: string|null}}
|
|
36
|
+
*/
|
|
37
|
+
export function parseTarget(to) {
|
|
38
|
+
const i = to.indexOf("#");
|
|
39
|
+
if (i === -1) return { tool: to, instance: null };
|
|
40
|
+
return { tool: to.slice(0, i), instance: to };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** @param {string} id 인스턴스 ID 또는 도구 이름 @returns {string} 도구 이름 */
|
|
44
|
+
export function toolOf(id) {
|
|
45
|
+
return parseTarget(id).tool;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* 채널 복귀 키. 같은 도구가 같은 작업 폴더에서 뜨면 같은 키다(서브에이전트 포함).
|
|
50
|
+
* @param {string} tool @param {string} cwd @returns {string} `<tool>@<sha256 앞 8자>`
|
|
51
|
+
*/
|
|
52
|
+
export function cwdKey(tool, cwd) {
|
|
53
|
+
const hash = createHash("sha256").update(resolve(cwd)).digest("hex");
|
|
54
|
+
return `${tool}@${hash.slice(0, 8)}`;
|
|
55
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
// 혼동되기 쉬운 문자(i, l, o, 0, 1) 제외
|
|
4
|
+
const ALPHABET = "abcdefghjkmnpqrstuvwxyz23456789";
|
|
5
|
+
|
|
6
|
+
/** @param {number} [len] @returns {string} */
|
|
7
|
+
export function shortId(len = 8) {
|
|
8
|
+
const bytes = randomBytes(len);
|
|
9
|
+
let out = "";
|
|
10
|
+
for (let i = 0; i < len; i++) out += ALPHABET[bytes[i] % ALPHABET.length];
|
|
11
|
+
return out;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** @returns {string} 사용자에게 보여줄 채널 코드 */
|
|
15
|
+
export function channelCode() {
|
|
16
|
+
return `plp-${shortId(4)}-${shortId(4)}`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** @returns {string} */
|
|
20
|
+
export function taskId() {
|
|
21
|
+
return `task_${shortId(10)}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** @returns {string} */
|
|
25
|
+
export function entryId() {
|
|
26
|
+
return `ctx_${shortId(10)}`;
|
|
27
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { agyCommand } from "./agy.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* 도구별 pluriply MCP 서버 등록 명령. `list`로 이미 등록됐는지 보고 없으면 `add`한다.
|
|
6
|
+
* claude-code 는 워커 템플릿이 --mcp-config 로 인라인 등록하므로 여기 없다.
|
|
7
|
+
* @type {Record<string, {label: string, list: string[], add: (node: string, bin: string) => string[], hint: (bin: string) => string}>}
|
|
8
|
+
*/
|
|
9
|
+
export const MCP_REGISTRARS = {
|
|
10
|
+
codex: {
|
|
11
|
+
label: "Codex",
|
|
12
|
+
list: ["codex", "mcp", "list"],
|
|
13
|
+
add: (node, bin) => [
|
|
14
|
+
"codex",
|
|
15
|
+
"mcp",
|
|
16
|
+
"add",
|
|
17
|
+
"pluriply",
|
|
18
|
+
"--",
|
|
19
|
+
node,
|
|
20
|
+
bin,
|
|
21
|
+
"connector",
|
|
22
|
+
"--agent",
|
|
23
|
+
"codex",
|
|
24
|
+
],
|
|
25
|
+
hint: (bin) =>
|
|
26
|
+
`codex mcp add pluriply -- node "${bin}" connector --agent codex`,
|
|
27
|
+
},
|
|
28
|
+
antigravity: {
|
|
29
|
+
label: "Antigravity CLI",
|
|
30
|
+
// agy mcp add 는 user 스코프가 기본이다. `--` 는 필수: 뒤의 명령·인자가 `-` 로 시작할 수
|
|
31
|
+
// 있고(--agent), 플래그는 <name> 앞에만 올 수 있다(agy mcp add --help).
|
|
32
|
+
list: [agyCommand(), "mcp", "list"],
|
|
33
|
+
add: (node, bin) => [
|
|
34
|
+
agyCommand(),
|
|
35
|
+
"mcp",
|
|
36
|
+
"add",
|
|
37
|
+
"pluriply",
|
|
38
|
+
"--",
|
|
39
|
+
node,
|
|
40
|
+
bin,
|
|
41
|
+
"connector",
|
|
42
|
+
"--agent",
|
|
43
|
+
"antigravity",
|
|
44
|
+
],
|
|
45
|
+
hint: (bin) =>
|
|
46
|
+
`agy mcp add pluriply -- node "${bin}" connector --agent antigravity`,
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* `pluriply worker enable <agent>` 가 부른다. 실패해도 던지지 않는다(enable 자체는 성공).
|
|
52
|
+
* @param {string} agent
|
|
53
|
+
* @param {{binPath: string, exec?: typeof execFileSync, log?: (msg: string) => void, env?: NodeJS.ProcessEnv}} deps
|
|
54
|
+
* @returns {"registered"|"present"|"skipped"|"failed"|"none"}
|
|
55
|
+
*/
|
|
56
|
+
export function registerMcpServer(
|
|
57
|
+
agent,
|
|
58
|
+
{ binPath, exec = execFileSync, log = console.log, env = process.env },
|
|
59
|
+
) {
|
|
60
|
+
const r = MCP_REGISTRARS[agent];
|
|
61
|
+
if (!r) return "none";
|
|
62
|
+
// PLURIPLY_SKIP_CODEX_MCP 는 Plan 2c 때 이름이라 호환으로 남긴다.
|
|
63
|
+
if (env.PLURIPLY_SKIP_MCP_REGISTER || env.PLURIPLY_SKIP_CODEX_MCP)
|
|
64
|
+
return "skipped";
|
|
65
|
+
try {
|
|
66
|
+
const [cmd, ...args] = r.list;
|
|
67
|
+
const list = exec(cmd, args, { stdio: "pipe" }).toString();
|
|
68
|
+
if (list.includes("pluriply")) return "present";
|
|
69
|
+
const [addCmd, ...addArgs] = r.add(process.execPath, binPath);
|
|
70
|
+
exec(addCmd, addArgs, { stdio: "pipe" });
|
|
71
|
+
log(`registered pluriply MCP server in ${r.label}`);
|
|
72
|
+
return "registered";
|
|
73
|
+
} catch {
|
|
74
|
+
log(
|
|
75
|
+
`hint: make sure ${r.label} has pluriply registered: ${r.hint(binPath)}`,
|
|
76
|
+
);
|
|
77
|
+
return "failed";
|
|
78
|
+
}
|
|
79
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import WebSocket from "ws";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* 포트의 허브에 ping을 보내 응답 payload를 돌려준다.
|
|
7
|
+
* 연결 실패, 오류 응답, 시간 초과는 모두 null.
|
|
8
|
+
* @param {number} port @param {number} [timeoutMs]
|
|
9
|
+
* @returns {Promise<object|null>}
|
|
10
|
+
*/
|
|
11
|
+
export function pingHub(port, timeoutMs = 1000) {
|
|
12
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535)
|
|
13
|
+
return Promise.resolve(null);
|
|
14
|
+
return new Promise((resolve) => {
|
|
15
|
+
const ws = new WebSocket(`ws://127.0.0.1:${port}`);
|
|
16
|
+
let settled = false;
|
|
17
|
+
const done = (value) => {
|
|
18
|
+
if (settled) return;
|
|
19
|
+
settled = true;
|
|
20
|
+
clearTimeout(timer);
|
|
21
|
+
ws.terminate();
|
|
22
|
+
resolve(value);
|
|
23
|
+
};
|
|
24
|
+
const timer = setTimeout(() => done(null), timeoutMs);
|
|
25
|
+
ws.on("open", () =>
|
|
26
|
+
ws.send(JSON.stringify({ id: "probe", type: "ping", payload: {} })),
|
|
27
|
+
);
|
|
28
|
+
ws.on("message", (raw) => {
|
|
29
|
+
try {
|
|
30
|
+
const msg = JSON.parse(raw.toString());
|
|
31
|
+
if (msg.id === "probe") done(msg.ok ? msg.payload : null);
|
|
32
|
+
} catch {
|
|
33
|
+
done(null);
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
ws.on("error", () => done(null));
|
|
37
|
+
ws.on("close", () => done(null));
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** @param {number} pid @returns {boolean} 프로세스가 존재하는지 (권한 없음도 존재로 본다) */
|
|
42
|
+
export function pidAlive(pid) {
|
|
43
|
+
try {
|
|
44
|
+
process.kill(pid, 0);
|
|
45
|
+
return true;
|
|
46
|
+
} catch (err) {
|
|
47
|
+
return err.code === "EPERM";
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** @param {string} home @returns {string} home 절대경로의 sha256 앞 12자 */
|
|
52
|
+
export function homeId(home) {
|
|
53
|
+
return createHash("sha256").update(resolve(home)).digest("hex").slice(0, 12);
|
|
54
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
|
|
3
|
+
const pkg = JSON.parse(
|
|
4
|
+
readFileSync(new URL("../../package.json", import.meta.url), "utf8"),
|
|
5
|
+
);
|
|
6
|
+
|
|
7
|
+
/** 패키지 버전 (package.json) */
|
|
8
|
+
export const PACKAGE_VERSION = pkg.version;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* 허브 프로토콜 버전. 허브 연산이 추가·변경될 때 올린다.
|
|
12
|
+
* 1: Plan 1 (암묵). 2: Plan 2a (agent.resume, task.cancel).
|
|
13
|
+
* 3: Plan 2c (worker.status, channel.presence, task.create의 mode/cwd/depth).
|
|
14
|
+
* 4: Plan 2e (agent.hello, 인스턴스 단위 peers, 행위자는 연결에서, dispatch pinned).
|
|
15
|
+
* 5: Plan 3a (task.wait 보류 응답).
|
|
16
|
+
* 6: Plan 3b (task.kind/review, task.complete의 review, task.list의 kind).
|
|
17
|
+
*/
|
|
18
|
+
export const PROTOCOL_VERSION = 6;
|