pluriply 0.3.0 → 0.4.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/README.md CHANGED
@@ -31,9 +31,10 @@ Pluriply MCP connector with each of them (idempotent — run it again any time).
31
31
  - `npx pluriply setup --workers` — also let the hub run Claude Code / Codex / Antigravity headlessly for `send_task` and `ask_agent`.
32
32
  - `npx pluriply setup --only claude-code,codex` — limit to specific tools.
33
33
  - `npx pluriply setup --remove` — unregister Pluriply from every tool, disable headless workers and stop the hub. Your channels and task history under `~/.pluriply` stay; add `--purge` to delete them too.
34
+ - `npx pluriply setup --remove --hooks-only` — take out only the Stop hooks; MCP registration, headless workers and the hub stay as they are.
34
35
  - Codex and Antigravity get a 600 s MCP tool timeout written into their config at registration (their default is 60 s, too short for `ask_agent`/`request_review` waits). If you registered with an earlier version, run `setup --remove` then `setup` again to pick it up.
35
36
  - `setup` also installs a Stop hook for Claude Code, Codex and Antigravity CLI so a live session notices new tasks and finished results at the end of its turn (see _Warm reception_). `--no-hooks` skips it; `setup --remove` takes it out again.
36
- - Re-run `setup` after upgrading or cleaning the npx cache — the hook command embeds the installed path.
37
+ - After upgrading or cleaning the npx cache, run `npx pluriply@latest setup --hooks-only` — the hook command embeds the installed path, and this refreshes it without rewriting your MCP configuration.
37
38
 
38
39
  Restart your AI tools afterwards so they pick up the new MCP server.
39
40
 
@@ -73,6 +74,13 @@ it runs. Antigravity's hook lives in `~/.gemini/config/hooks.json`.
73
74
  Everything stays on your machine under `~/.pluriply/` (channels, task history,
74
75
  results). There is no server and no account. Delete the folder to reset.
75
76
 
77
+ The hub only listens on `127.0.0.1`, and since 0.4.0 it also issues a random
78
+ token every time it starts, kept in `~/.pluriply/hub.json` (mode 600). Only
79
+ connectors and hooks that read that file can talk to it; anything else can
80
+ only ping it (version and pid) and gets `unauthorized` for everything else.
81
+ On Windows the file mode is not enforced — the profile folder's ACL is what
82
+ keeps other users out.
83
+
76
84
  ## License
77
85
 
78
86
  The CLI, connector, shared utilities and setup code in this repository are
package/bin/pluriply.js CHANGED
@@ -44,7 +44,14 @@ function flag(name) {
44
44
  }
45
45
 
46
46
  /** setup 이 아는 플래그. 오타 하나가 파괴적인 명령의 범위를 넓히지 못하게 한다. */
47
- const SETUP_BOOL_FLAGS = ["workers", "dry-run", "remove", "purge", "no-hooks"];
47
+ const SETUP_BOOL_FLAGS = [
48
+ "workers",
49
+ "dry-run",
50
+ "remove",
51
+ "purge",
52
+ "no-hooks",
53
+ "hooks-only",
54
+ ];
48
55
  const SETUP_VALUE_FLAGS = ["only"];
49
56
 
50
57
  if (cmd === "hub" && sub === "start") {
@@ -166,10 +173,17 @@ if (cmd === "hub" && sub === "start") {
166
173
  const remove = rest.includes("--remove");
167
174
  const purge = rest.includes("--purge");
168
175
  const hooks = !rest.includes("--no-hooks");
176
+ const hooksOnly = rest.includes("--hooks-only");
169
177
  if (remove && workers) usage("--remove cannot be combined with --workers");
170
178
  if (purge && !remove) usage("--purge requires --remove");
171
179
  if (purge && onlyArg) usage("--purge cannot be combined with --only");
172
180
  if (remove && !hooks) usage("--no-hooks has no effect with --remove");
181
+ // --hooks-only 는 MCP 등록·워커·허브를 건드리지 않는다(스펙 §4.1). 그것들을 겨냥한 플래그와는 모순.
182
+ if (hooksOnly && workers)
183
+ usage("--hooks-only cannot be combined with --workers");
184
+ if (hooksOnly && !hooks)
185
+ usage("--hooks-only cannot be combined with --no-hooks");
186
+ if (hooksOnly && purge) usage("--hooks-only cannot be combined with --purge");
173
187
  try {
174
188
  const r = await runSetup({
175
189
  only: onlyArg
@@ -183,6 +197,7 @@ if (cmd === "hub" && sub === "start") {
183
197
  remove,
184
198
  purge,
185
199
  hooks,
200
+ hooksOnly,
186
201
  env: makeEnv({ binPath: BIN_PATH }),
187
202
  home: pluriplyHome(),
188
203
  });
@@ -271,7 +286,7 @@ if (cmd === "hub" && sub === "start") {
271
286
  await startConnector({ agent });
272
287
  } else {
273
288
  console.error(
274
- "usage: pluriply <setup [--workers] [--dry-run] [--only a,b] [--no-hooks]|setup --remove [--purge] [--dry-run] [--only a,b]|hub start|hub stop|hub restart|hook stop --agent <claude-code|codex|antigravity>|connector --agent <name>|status|worker enable|disable <codex|claude-code|antigravity>|worker list>",
289
+ "usage: pluriply <setup [--workers] [--dry-run] [--only a,b] [--no-hooks|--hooks-only]|setup --remove [--purge] [--dry-run] [--only a,b] [--hooks-only]|hub start|hub stop|hub restart|hook stop --agent <claude-code|codex|antigravity>|connector --agent <name>|status|worker enable|disable <codex|claude-code|antigravity>|worker list>",
275
290
  );
276
291
  process.exit(1);
277
292
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pluriply",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Connect your AI coding tools into one collaboration channel",
5
5
  "type": "module",
6
6
  "license": "SEE LICENSE IN LICENSE.md",
@@ -44,10 +44,18 @@ const RETRYABLE = new Set([
44
44
 
45
45
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
46
46
 
47
- /** @returns {Promise<WebSocket|null>} 연결 실패 시 null */
48
- function tryConnect(port, timeoutMs = 1000) {
47
+ /**
48
+ * @param {number} port @param {number} [timeoutMs] @param {string} [token] 허브 락의 연결 토큰(Plan 4f).
49
+ * 있으면 업그레이드 헤더 `Authorization: Bearer <token>` 으로 보낸다. 없으면(구버전 허브) 헤더 없이 붙는다.
50
+ * @returns {Promise<WebSocket|null>} 연결 실패 시 null
51
+ */
52
+ function tryConnect(port, timeoutMs = 1000, token) {
49
53
  return new Promise((resolve) => {
50
- const ws = new WebSocket(`ws://127.0.0.1:${port}`);
54
+ const ws = token
55
+ ? new WebSocket(`ws://127.0.0.1:${port}`, {
56
+ headers: { authorization: `Bearer ${token}` },
57
+ })
58
+ : new WebSocket(`ws://127.0.0.1:${port}`);
51
59
  const timer = setTimeout(() => {
52
60
  ws.terminate();
53
61
  resolve(null);
@@ -165,7 +173,7 @@ export class HubClient extends EventEmitter {
165
173
  try {
166
174
  const { port, info } = await ensureHub({ home: this.home });
167
175
  if (this.closed) return; // close()가 ensureHub 대기 중에 호출됨
168
- const ws = await tryConnect(port, 3000);
176
+ const ws = await tryConnect(port, 3000, info.token);
169
177
  if (this.closed) {
170
178
  ws?.terminate(); // close()가 tryConnect 대기 중에 호출됨: 새 소켓을 붙이지 않는다
171
179
  return;
@@ -216,7 +224,7 @@ export class HubClient extends EventEmitter {
216
224
  */
217
225
  static async connect({ home = pluriplyHome(), reconnectTotalMs } = {}) {
218
226
  const { port, info } = await ensureHub({ home });
219
- const ws = await tryConnect(port, 3000);
227
+ const ws = await tryConnect(port, 3000, info.token);
220
228
  if (!ws) throw new Error("could not connect to pluriply hub");
221
229
  const client = new HubClient(ws, { home, reconnectTotalMs });
222
230
  client.stale = staleFrom(info, port);
@@ -319,7 +327,7 @@ export class HubClient extends EventEmitter {
319
327
  export async function connectIfLive({ home = pluriplyHome() } = {}) {
320
328
  const live = await liveHub(home);
321
329
  if (!live) return null;
322
- const ws = await tryConnect(live.port, 3000);
330
+ const ws = await tryConnect(live.port, 3000, live.token);
323
331
  if (!ws) return null;
324
332
  const client = new HubClient(ws, {});
325
333
  client.stale = staleFrom(live, live.port);
package/src/hub/index.js CHANGED
@@ -1,14 +1,16 @@
1
1
  // Copyright (c) 2026 TQSoft. All rights reserved.
2
2
  // Licensed under LICENSE-HUB.md — not open source.
3
- import{WebSocketServer as He}from"ws";import{mkdirSync as Fe,writeFileSync as Ue,rmSync as xt,readFileSync as Be}from"node:fs";import{join as At,isAbsolute as Ot}from"node:path";import{mkdirSync as qt,readFileSync as ct,writeFileSync as lt,renameSync as ut,existsSync as ht,readdirSync as U,rmSync as dt}from"node:fs";import{join as T}from"node:path";import{pluriplyHome as Kt}from"../shared/paths.js";var B=/^plp-[a-z0-9]{4}-[a-z0-9]{4}$/,D=class{constructor(e=Kt()){this.root=e,this.dir=T(e,"channels"),qt(this.dir,{recursive:!0}),this.#t()}#t(){for(let e of U(this.dir))e.endsWith(".tmp")&&dt(T(this.dir,e),{force:!0});for(let e of U(this.root))e.startsWith("agents.json.")&&e.endsWith(".tmp")&&dt(T(this.root,e),{force:!0})}loadChannel(e){if(!B.test(e))throw new Error(`invalid channel code: ${e}`);let t=T(this.dir,`${e}.json`);return ht(t)?JSON.parse(ct(t,"utf8")):null}saveChannel(e,t){if(!B.test(e))throw new Error(`invalid channel code: ${e}`);let n=T(this.dir,`${e}.json`),r=`${n}.${process.pid}.tmp`;lt(r,JSON.stringify(t,null,2)),ut(r,n)}listChannels(){return U(this.dir).filter(e=>e.endsWith(".json")).map(e=>e.slice(0,-5)).filter(e=>B.test(e))}loadAgents(){let e=T(this.root,"agents.json");if(!ht(e))return{};try{let t=JSON.parse(ct(e,"utf8"));return t&&typeof t=="object"&&!Array.isArray(t)?t:{}}catch{return{}}}saveAgents(e){let t=T(this.root,"agents.json"),n=`${t}.${process.pid}.tmp`;lt(n,JSON.stringify(e,null,2)),ut(n,t)}};import{channelCode as Ft}from"../shared/ids.js";var M=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 Y=class extends Error{constructor(e){super(`channel not found: ${e}`),this.code=e}},N=class{constructor(e){this.store=e}create(){let e={channel:{code:Ft(),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 Y(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:o=new Date}={}){let c=this.get(e);this.#t(c,s,o);let l=o.toISOString(),u=c.channel.peers.find(h=>h.instanceId===t);return u?u.lastSeenAt=l:c.channel.peers.push({instanceId:t,tool:n,worker:!!r,joinedAt:l,lastSeenAt:l}),this.save(e,c),{channel:c.channel,peers:c.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 o=n.getTime()-Date.parse(s.lastSeenAt);return o>=0&&o<=432e5}),e.channel.peers.length!==r}};import{statSync as Ut,realpathSync as P}from"node:fs";import{sep as Bt,join as Yt,isAbsolute as Jt}from"node:path";import{taskId as zt}from"../shared/ids.js";import{parseTarget as ft,toolOf as gt,isInstanceId as Vt}from"../shared/identity.js";var J=new Set(["completed","failed","cancelled"]),C=class extends Error{constructor(e){super(`task not found: ${e}`)}},A=class extends Error{constructor(e,t){super(`invalid task transition: ${e} -> ${t}`)}},j=class extends Error{constructor(e,t,n){super(`task ${e} is addressed to "${t}", not "${n}"`)}},z=class extends Error{constructor(e,t,n){super(`only the sender "${t}" can cancel task ${e}, not "${n}"`)}},b=class extends Error{constructor(e){super(e)}},V=class extends Error{constructor(e){super(`delegation depth limit (${e}) exceeded`)}},Xt=["task","review"],mt=["approve","request_changes","comment"],pt=["critical","important","minor"],$=class extends Error{constructor(e){super(e)}},_=class extends Error{constructor(e){super(e)}},X=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`)}},Z=class extends Error{constructor(e){super(`cwd is not an existing directory: ${e}`)}},tt=class extends Error{constructor(e){super(`cwd outside allowed roots: ${e}`)}},Qt=["auto","spawn","interactive"];function yt(i,e){return i===e||i.startsWith(e+Bt)}function Zt(i,e){if(!i||typeof i!="object"||Array.isArray(i))throw new $("review must be an object");let t={};if(i.gitRange!==void 0){if(typeof i.gitRange!="string"||i.gitRange.length===0||i.gitRange.length>200||/[\r\n]/.test(i.gitRange))throw new $("gitRange must be a single line of at most 200 characters");if(i.gitRange.startsWith("-"))throw new $("gitRange must not start with '-'");t.gitRange=i.gitRange}if(i.paths!==void 0){if(!Array.isArray(i.paths)||i.paths.some(n=>typeof n!="string"||n.length===0))throw new $("paths must be an array of strings");if(i.paths.length>0){if(e===void 0)throw new $("paths need a cwd to resolve against");let n=e;try{n=P(e)}catch{}t.paths=i.paths.map(r=>{let s=Jt(r)?r:Yt(e,r),o;try{o=P(s)}catch{throw new $(`path does not exist: ${r}`)}if(!yt(o,n))throw new $(`path outside cwd: ${r}`);return o})}}if(i.focus!==void 0){if(typeof i.focus!="string"||i.focus.length>500)throw new $("focus must be a string of at most 500 characters");i.focus.length>0&&(t.focus=i.focus)}return t}function te(i){return`Review ${i.gitRange?`git range ${i.gitRange}`:i.paths?.length?`files ${i.paths.join(", ")}`:"the uncommitted changes (git diff HEAD)"}${i.focus?`, focusing on ${i.focus}`:""}.`}function ee(i){if(!i||typeof i!="object"||Array.isArray(i))throw new _("review result must be an object");if(!mt.includes(i.verdict))throw new _(`verdict must be one of ${mt.join(", ")}`);if(typeof i.summary!="string"||i.summary.trim().length===0)throw new _("summary is required");let e=i.findings??[];if(!Array.isArray(e))throw new _("findings must be an array");if(e.length>200)throw new _("findings must have at most 200 items");let t=e.map((n,r)=>{if(!n||typeof n!="object"||Array.isArray(n))throw new _(`findings[${r}] must be an object`);if(!pt.includes(n.severity))throw new _(`findings[${r}].severity must be one of ${pt.join(", ")}`);if(typeof n.message!="string"||n.message.length===0)throw new _(`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 _(`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 _(`findings[${r}].line must be a positive integer`);s.line=n.line}if(n.suggestion!==void 0){if(typeof n.suggestion!="string")throw new _(`findings[${r}].suggestion must be a string`);s.suggestion=n.suggestion}return s});return{verdict:i.verdict,findings:t,summary:i.summary}}function wt(i,e){return i.toInstance?e===i.toInstance:gt(e)===(i.toTool??i.to)}function ne(i,e){return i.from===e?!0:!i.from.includes("#")&&gt(e)===i.from}var G=class{constructor(e){this.registry=e,this.waiters=new Map}create(e,{from:t,to:n,request:r,attachments:s=[],depth:o=0,cwd:c,origin:l,allowedRoots:u=[],mode:h="auto",maxDepth:d=2,online:p=new Set,kind:w="task",review:y,fromCwdKey:k}){if(typeof n!="string"||n.length===0)throw new b("target agent name is required");if(n.includes("#")&&!Vt(n))throw new b(`no peer "${n}" on this channel`);let g=ft(n);if(g.instance!==null&&g.instance===t)throw new b("cannot delegate a task to yourself");if((!Number.isInteger(o)||o<0)&&(o=0),o>d)throw new V(d);if(!Qt.includes(h))throw new Error(`invalid mode: ${h}`);if(c!==void 0){let S=!1;try{S=Ut(c).isDirectory()}catch{S=!1}if(!S)throw new Z(c);let x=P(c),H=[];if(l!==void 0)try{H.push(P(l))}catch{}for(let F of u)try{H.push(P(F))}catch{}if(!H.some(F=>yt(x,F)))throw new tt(c);c=x}if(!Xt.includes(w))throw new Error(`invalid kind: ${w}`);let v;if(w==="review")v=Zt(y??{},c??l),(typeof r!="string"||r.length===0)&&(r=te(v));else if(y!==void 0)throw new $('review is only valid for kind "review"');let f=this.registry.get(e),a=f.channel.peers,m=a.filter(S=>S.tool===g.tool),I;if(g.instance===null){if(I=m.length>0,!I){let S=a.find(x=>x.tool?.toLowerCase()===g.tool.toLowerCase());if(S)throw new b(`no peer named "${n}" on this channel; did you mean "${S.tool}"?`)}}else if(I=m.some(S=>S.instanceId===g.instance),!I&&m.length>0){let S=m.find(x=>p.has(x.instanceId))??m[0];throw new b(`no peer "${n}" on this channel; did you mean "${S.instanceId}"?`)}let at=new Date().toISOString(),R={taskId:zt(),from:t,to:n,toTool:g.tool,request:r,attachments:s,depth:o,mode:h,kind:w,status:"submitted",result:null,createdAt:at,updatedAt:at};g.instance!==null&&(R.toInstance=g.instance),c!==void 0&&(R.cwd=c),typeof k=="string"&&k.length>0&&(R.fromCwdKey=k),w==="review"&&(R.review=v),f.tasks.push(R),this.registry.save(e,f);let O={task:R,targetJoined:I};return g.instance!==null&&(O.targetOnline=p.has(g.instance),I&&!O.targetOnline&&(O.warning=`"${n}" is not online; the task will wait until it reconnects.`)),I||(O.warning=`"${n}" has not joined this channel yet; the task will wait until it joins.`),O}list(e,{to:t,from:n,status:r,kind:s}={}){let o=this.registry.get(e).tasks;if(t){let c=ft(t);o=o.filter(l=>c.instance===null?(l.toTool??l.to)===c.tool:l.toInstance===c.instance||!l.toInstance&&(l.toTool??l.to)===c.tool)}return n&&(o=o.filter(c=>c.from===n)),r&&(o=o.filter(c=>c.status===r)),s&&(o=o.filter(c=>(c.kind??"task")===s)),o}get(e,t){let n=this.registry.get(e).tasks.find(r=>r.taskId===t);if(!n)throw new C(t);return n}#t(e,t,n){let r=this.registry.get(e),s=r.tasks.find(c=>c.taskId===t);if(!s)throw new C(t);let o=s.status;return n(s),s.updatedAt=new Date().toISOString(),this.registry.save(e,r),s.status!==o&&this.#r(e,t,s),s}#r(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(J.has(s.status))return Promise.resolve(s);if(r?.aborted)return Promise.resolve(null);let o=`${e}/${t}`;return new Promise(c=>{let l,u=this.waiters.get(o)??new Set;this.waiters.set(o,u);let h=p=>{clearTimeout(l),r?.removeEventListener("abort",d),u.delete(h),u.size===0&&this.waiters.get(o)===u&&this.waiters.delete(o),c(p)},d=()=>h(null);u.add(h),r?.addEventListener("abort",d,{once:!0}),l=setTimeout(()=>{let p=s;try{p=this.get(e,t)}catch{}h(p)},n)})}claim(e,t,n){return this.#t(e,t,r=>{if(!wt(r,n))throw new j(t,r.to,n);if(r.status!=="submitted")throw new A(r.status,"working");r.status="working"})}complete(e,t,{from:n,result:r,status:s="completed",worker:o=!1,review:c}){if(s!=="completed"&&s!=="failed")throw new A("?",s);return this.#t(e,t,l=>{if(!wt(l,n))throw new j(t,l.to,n);if(J.has(l.status))throw new A(l.status,s);let u=(l.kind??"task")==="review";if(!u&&c!==void 0)throw new Q(t);if(u&&s==="completed"){if(c===void 0)throw new X(t);r=ee(c)}l.status=s,l.result=r,l.completedBy=n,o?l.completedByWorker=!0:delete l.completedByWorker})}cancel(e,t,{agent:n,reason:r}){return this.#t(e,t,s=>{if(!ne(s,n))throw new z(t,s.from,n);if(s.status!=="submitted"&&s.status!=="working")throw new A(s.status,"cancelled");s.status="cancelled",s.result=r??null,s.cancelledBy=n})}markHookDelivered(e,t,n,r=new Date){let s=this.registry.get(e),o=s.tasks.find(c=>c.taskId===t);if(!o)throw new C(t);return o.hookDelivered={...o.hookDelivered??{},[n]:r.toISOString()},this.registry.save(e,s),o}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=>{J.has(s.status)||(s.status="failed",s.result=n,s.completedBy=r)})}};import{entryId as re}from"../shared/ids.js";var W=class{constructor(e){this.registry=e}add(e,{from:t,summary:n,artifacts:r=[]}){let s=this.registry.get(e),o={entryId:re(),from:t,summary:n,artifacts:r,at:new Date().toISOString()};return s.context.push(o),this.registry.save(e,s),o}list(e,{limit:t}={}){let n=this.registry.get(e).context;return Number.isInteger(t)&&t>0?n.slice(-t):n}};import{spawn as Te}from"node:child_process";import{mkdirSync as Rt,openSync as be,closeSync as Re,readFileSync as xe,appendFileSync as nt,readdirSync as Ae,rmSync as Oe}from"node:fs";import{mkdir as Pe,rm as rt}from"node:fs/promises";import{join as E}from"node:path";import{workerEnabled as Ce,TEMPLATE_AGENTS as Le}from"../shared/config.js";import{join as kt}from"node:path";import{fileURLToPath as se}from"node:url";import{agyCommand as ie}from"../shared/agy.js";import{DEFAULT_LIMITS as oe}from"../shared/config.js";var ae=se(new URL("../../bin/pluriply.js",import.meta.url)),ce=["acceptEdits","bypassPermissions"],le=["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 et(){if(process.env.PLURIPLY_ALLOW_TEMPLATE_OVERRIDE!=="1")return null;let i=process.env.PLURIPLY_WORKER_TEMPLATE_OVERRIDE;if(!i)return null;try{return JSON.parse(i)}catch(e){throw new Error(`PLURIPLY_WORKER_TEMPLATE_OVERRIDE is not valid JSON: ${e.message}`)}}function ue(i,e){let t=et()?.[i];if(!t)return;let n=r=>r.replace(/\{(taskId|channelCode|home|cwd|prompt|readOnly)\}/g,(s,o)=>String(e[o]));return{command:t.command,args:t.args.map(n)}}function It(i,{home:e,cwd:t,prompt:n,logDir:r,taskId:s,channelCode:o,taskDir:c=kt(r,s),permissionMode:l="acceptEdits",timeoutMs:u=oe.timeoutMs,readOnly:h=!1}){let d=ue(i,{taskId:s,channelCode:o,home:e,cwd:t,prompt:n,readOnly:h});if(d)return d;switch(i){case"codex":return{command:"codex",args:["exec","-C",t,"--skip-git-repo-check","-s",h?"read-only":"workspace-write","-c",'approval_policy="never"',"-o",kt(r,`${s}.last.md`),n]};case"claude-code":{if(!ce.includes(l))throw new Error(`invalid permissionMode "${l}" for claude-code worker`);let p=JSON.stringify({mcpServers:{pluriply:{command:process.execPath,args:[ae,"connector","--agent","claude-code"],env:{PLURIPLY_HOME:e}}}});return{command:"claude",args:["-p",...h?["--allowedTools",...le,"--permission-mode","default","--add-dir",c]:["--permission-mode",l],"--mcp-config",p,"--strict-mcp-config","--output-format","json",n]}}case"antigravity":return{command:ie(),args:["-p",n,...h?["--mode","plan"]:[],"--dangerously-skip-permissions","--output-format","text","--print-timeout",`${Math.ceil(u/1e3)}s`]};default:return null}}import{pidAlive as De}from"../shared/probe.js";import{writeFile as ye}from"node:fs/promises";import{execFile as he}from"node:child_process";import{promisify as de}from"node:util";var fe=de(he),me=[/^filter\..+\.(clean|smudge|process|required)$/,/^diff\..+\.(command|textconv)$/,/^merge\..+\.driver$/,/^core\.(hookspath|fsmonitor|sshcommand|pager|editor|askpass|gitproxy)$/,/^credential\.(.+\.)?helper$/,/^alias\..+$/,/^sequence\.editor$/,/^gpg\.(.+\.)?program$/],pe=["GIT_DIR","GIT_WORK_TREE","GIT_INDEX_FILE","GIT_CONFIG_PARAMETERS","GIT_CONFIG_COUNT","GIT_EXTERNAL_DIFF","GIT_SSH","GIT_SSH_COMMAND","GIT_ASKPASS","GIT_EDITOR","GIT_PAGER"];function we(){let i={...process.env};for(let e of pe)delete i[e];return i.GIT_CONFIG_GLOBAL=process.platform==="win32"?"NUL":"/dev/null",i.GIT_CONFIG_NOSYSTEM="1",i.GIT_ATTR_NOSYSTEM="1",i.GIT_TERMINAL_PROMPT="0",i}function ge(i){if(!i)return"";let e=String(i).trim().split(`
4
- `).map(t=>t.trim()).filter(Boolean);return e.length===0?"":e.find(t=>/^(fatal|error):/i.test(t))??e.at(-1)}async function L(i,{cwd:e,env:t,maxBuffer:n=4*1024*1024,timeout:r=2e4}){try{let{stdout:s}=await fe("git",i,{cwd:e,env:t,encoding:"utf8",maxBuffer:n,timeout:r,windowsHide:!0});return s}catch(s){throw new Error(ge(s.stderr)||s.message)}}async function _t(i){let e=we(),t=o=>L(["config","--list",o,"--includes","-z"],{cwd:i,env:e,timeout:1e4}),n=[await t("--local")];try{n.push(await t("--worktree"))}catch(o){if(!/cannot be used with multiple working trees|unable to read config file/i.test(o.message))throw o}let r=["-c","core.fsmonitor=false"],s=new Set(["core.fsmonitor"]);for(let o of n)for(let c of o.split("\0")){if(!c)continue;let l=c.split(`
5
- `,1)[0];if(s.has(l))continue;let u=l.toLowerCase();me.some(h=>h.test(u))&&(s.add(l),r.push("-c",`${l}=`))}return{args:r,env:e}}var ke=20*1024*1024;async function St({cwd:i,review:e,outFile:t,git:n}){if(e.gitRange?.startsWith("-"))throw new Error('gitRange must not start with "-"');let r=[...n.args,"diff","--no-color","--no-ext-diff","--no-textconv"],s;e.gitRange?(r.push(e.gitRange,"--"),e.paths?.length&&r.push(...e.paths),s=`git diff ${e.gitRange}`):e.paths?.length?(r.push("HEAD","--",...e.paths),s=`git diff HEAD -- ${e.paths.join(" ")}`):(r.push("HEAD"),s="git diff HEAD");let o;try{o=await L(r,{cwd:i,env:n.env,maxBuffer:ke,timeout:2e4})}catch(c){throw new Error(`${s} failed: ${c.message}`)}return await ye(t,o),{file:t,bytes:Buffer.byteLength(o),target:s}}import{copyFile as Ie,lstat as _e,mkdir as Et,rm as vt}from"node:fs/promises";import{dirname as Se,isAbsolute as Ee,join as $t}from"node:path";var ve=512*1024*1024,$e=16,Tt=Object.freeze({recursive:!0,force:!0,maxRetries:5,retryDelay:100});async function bt({repoDir:i,destDir:e,git:t,maxBytes:n=ve}){let s=(await L([...t.args,"ls-files","-z","-co","--exclude-standard"],{cwd:i,env:t.env,maxBuffer:67108864})).split("\0").filter(Boolean);await vt(e,Tt),await Et(e,{recursive:!0});let o=0,c=0,l=0,u=0,h=async()=>{for(;u<s.length;){let w=s[u++];if(Ee(w)||w.split("/").includes(".."))continue;let y=$t(i,w),k;try{k=await _e(y)}catch{continue}if(k.isSymbolicLink()){l++;continue}if(!k.isFile())continue;if(c+=k.size,c>n)throw new Error(`snapshot exceeds ${Math.floor(n/1024/1024)}MB`);let g=$t(e,w);await Et(Se(g),{recursive:!0}),await Ie(y,g),o++}},p=(await Promise.allSettled(Array.from({length:Math.min($e,s.length)},h))).find(w=>w.status==="rejected");if(p)throw await vt(e,Tt),p.reason;return{files:o,bytes:c,skippedSymlinks:l}}var q=Object.freeze({recursive:!0,force:!0,maxRetries:5,retryDelay:100}),Me=i=>`run \`pluriply worker enable ${i}\` to let the hub process this automatically`,Ne=i=>`no worker template is configured for "${i}"; enable it via config or set PLURIPLY_WORKER_TEMPLATE_OVERRIDE`,je=new Set(["completed","failed","cancelled"]);function Ge(i){return Le.includes(i)?!0:!!et()?.[i]}function We(i){try{return xe(i,"utf8").trimEnd().split(`
3
+ import{WebSocketServer as Ke}from"ws";import{mkdirSync as Ue,writeFileSync as Fe,rmSync as Rt,readFileSync as Be,chmodSync as Ye}from"node:fs";import{randomBytes as ze,timingSafeEqual as Je}from"node:crypto";import{join as At,isAbsolute as Ot}from"node:path";import{mkdirSync as qt,readFileSync as ct,writeFileSync as lt,renameSync as ut,existsSync as ht,readdirSync as F,rmSync as dt}from"node:fs";import{join as T}from"node:path";import{pluriplyHome as Ht}from"../shared/paths.js";var B=/^plp-[a-z0-9]{4}-[a-z0-9]{4}$/,D=class{constructor(e=Ht()){this.root=e,this.dir=T(e,"channels"),qt(this.dir,{recursive:!0}),this.#t()}#t(){for(let e of F(this.dir))e.endsWith(".tmp")&&dt(T(this.dir,e),{force:!0});for(let e of F(this.root))e.startsWith("agents.json.")&&e.endsWith(".tmp")&&dt(T(this.root,e),{force:!0})}loadChannel(e){if(!B.test(e))throw new Error(`invalid channel code: ${e}`);let t=T(this.dir,`${e}.json`);return ht(t)?JSON.parse(ct(t,"utf8")):null}saveChannel(e,t){if(!B.test(e))throw new Error(`invalid channel code: ${e}`);let n=T(this.dir,`${e}.json`),r=`${n}.${process.pid}.tmp`;lt(r,JSON.stringify(t,null,2)),ut(r,n)}listChannels(){return F(this.dir).filter(e=>e.endsWith(".json")).map(e=>e.slice(0,-5)).filter(e=>B.test(e))}loadAgents(){let e=T(this.root,"agents.json");if(!ht(e))return{};try{let t=JSON.parse(ct(e,"utf8"));return t&&typeof t=="object"&&!Array.isArray(t)?t:{}}catch{return{}}}saveAgents(e){let t=T(this.root,"agents.json"),n=`${t}.${process.pid}.tmp`;lt(n,JSON.stringify(e,null,2)),ut(n,t)}};import{channelCode as Ut}from"../shared/ids.js";var M=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 Y=class extends Error{constructor(e){super(`channel not found: ${e}`),this.code=e}},N=class{constructor(e){this.store=e}create(){let e={channel:{code:Ut(),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 Y(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:o=new Date}={}){let c=this.get(e);this.#t(c,s,o);let l=o.toISOString(),u=c.channel.peers.find(h=>h.instanceId===t);return u?u.lastSeenAt=l:c.channel.peers.push({instanceId:t,tool:n,worker:!!r,joinedAt:l,lastSeenAt:l}),this.save(e,c),{channel:c.channel,peers:c.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 o=n.getTime()-Date.parse(s.lastSeenAt);return o>=0&&o<=432e5}),e.channel.peers.length!==r}};import{statSync as Ft,realpathSync as P}from"node:fs";import{sep as Bt,join as Yt,isAbsolute as zt}from"node:path";import{taskId as Jt}from"../shared/ids.js";import{parseTarget as ft,toolOf as gt,isInstanceId as Vt}from"../shared/identity.js";var z=new Set(["completed","failed","cancelled"]),C=class extends Error{constructor(e){super(`task not found: ${e}`)}},A=class extends Error{constructor(e,t){super(`invalid task transition: ${e} -> ${t}`)}},j=class extends Error{constructor(e,t,n){super(`task ${e} is addressed to "${t}", not "${n}"`)}},J=class extends Error{constructor(e,t,n){super(`only the sender "${t}" can cancel task ${e}, not "${n}"`)}},b=class extends Error{constructor(e){super(e)}},V=class extends Error{constructor(e){super(`delegation depth limit (${e}) exceeded`)}},Xt=["task","review"],mt=["approve","request_changes","comment"],pt=["critical","important","minor"],$=class extends Error{constructor(e){super(e)}},S=class extends Error{constructor(e){super(e)}},X=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`)}},Z=class extends Error{constructor(e){super(`cwd is not an existing directory: ${e}`)}},tt=class extends Error{constructor(e){super(`cwd outside allowed roots: ${e}`)}},Qt=["auto","spawn","interactive"];function yt(i,e){return i===e||i.startsWith(e+Bt)}function Zt(i,e){if(!i||typeof i!="object"||Array.isArray(i))throw new $("review must be an object");let t={};if(i.gitRange!==void 0){if(typeof i.gitRange!="string"||i.gitRange.length===0||i.gitRange.length>200||/[\r\n]/.test(i.gitRange))throw new $("gitRange must be a single line of at most 200 characters");if(i.gitRange.startsWith("-"))throw new $("gitRange must not start with '-'");t.gitRange=i.gitRange}if(i.paths!==void 0){if(!Array.isArray(i.paths)||i.paths.some(n=>typeof n!="string"||n.length===0))throw new $("paths must be an array of strings");if(i.paths.length>0){if(e===void 0)throw new $("paths need a cwd to resolve against");let n=e;try{n=P(e)}catch{}t.paths=i.paths.map(r=>{let s=zt(r)?r:Yt(e,r),o;try{o=P(s)}catch{throw new $(`path does not exist: ${r}`)}if(!yt(o,n))throw new $(`path outside cwd: ${r}`);return o})}}if(i.focus!==void 0){if(typeof i.focus!="string"||i.focus.length>500)throw new $("focus must be a string of at most 500 characters");i.focus.length>0&&(t.focus=i.focus)}return t}function te(i){return`Review ${i.gitRange?`git range ${i.gitRange}`:i.paths?.length?`files ${i.paths.join(", ")}`:"the uncommitted changes (git diff HEAD)"}${i.focus?`, focusing on ${i.focus}`:""}.`}function ee(i){if(!i||typeof i!="object"||Array.isArray(i))throw new S("review result must be an object");if(!mt.includes(i.verdict))throw new S(`verdict must be one of ${mt.join(", ")}`);if(typeof i.summary!="string"||i.summary.trim().length===0)throw new S("summary is required");let e=i.findings??[];if(!Array.isArray(e))throw new S("findings must be an array");if(e.length>200)throw new S("findings must have at most 200 items");let t=e.map((n,r)=>{if(!n||typeof n!="object"||Array.isArray(n))throw new S(`findings[${r}] must be an object`);if(!pt.includes(n.severity))throw new S(`findings[${r}].severity must be one of ${pt.join(", ")}`);if(typeof n.message!="string"||n.message.length===0)throw new S(`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 S(`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 S(`findings[${r}].line must be a positive integer`);s.line=n.line}if(n.suggestion!==void 0){if(typeof n.suggestion!="string")throw new S(`findings[${r}].suggestion must be a string`);s.suggestion=n.suggestion}return s});return{verdict:i.verdict,findings:t,summary:i.summary}}function wt(i,e){return i.toInstance?e===i.toInstance:gt(e)===(i.toTool??i.to)}function ne(i,e){return i.from===e?!0:!i.from.includes("#")&&gt(e)===i.from}var G=class{constructor(e){this.registry=e,this.waiters=new Map}create(e,{from:t,to:n,request:r,attachments:s=[],depth:o=0,cwd:c,origin:l,allowedRoots:u=[],mode:h="auto",maxDepth:d=2,online:p=new Set,kind:w="task",review:y,fromCwdKey:k}){if(typeof n!="string"||n.length===0)throw new b("target agent name is required");if(n.includes("#")&&!Vt(n))throw new b(`no peer "${n}" on this channel`);let g=ft(n);if(g.instance!==null&&g.instance===t)throw new b("cannot delegate a task to yourself");if((!Number.isInteger(o)||o<0)&&(o=0),o>d)throw new V(d);if(!Qt.includes(h))throw new Error(`invalid mode: ${h}`);if(c!==void 0){let _=!1;try{_=Ft(c).isDirectory()}catch{_=!1}if(!_)throw new Z(c);let R=P(c),K=[];if(l!==void 0)try{K.push(P(l))}catch{}for(let U of u)try{K.push(P(U))}catch{}if(!K.some(U=>yt(R,U)))throw new tt(c);c=R}if(!Xt.includes(w))throw new Error(`invalid kind: ${w}`);let v;if(w==="review")v=Zt(y??{},c??l),(typeof r!="string"||r.length===0)&&(r=te(v));else if(y!==void 0)throw new $('review is only valid for kind "review"');let f=this.registry.get(e),a=f.channel.peers,m=a.filter(_=>_.tool===g.tool),I;if(g.instance===null){if(I=m.length>0,!I){let _=a.find(R=>R.tool?.toLowerCase()===g.tool.toLowerCase());if(_)throw new b(`no peer named "${n}" on this channel; did you mean "${_.tool}"?`)}}else if(I=m.some(_=>_.instanceId===g.instance),!I&&m.length>0){let _=m.find(R=>p.has(R.instanceId))??m[0];throw new b(`no peer "${n}" on this channel; did you mean "${_.instanceId}"?`)}let at=new Date().toISOString(),x={taskId:Jt(),from:t,to:n,toTool:g.tool,request:r,attachments:s,depth:o,mode:h,kind:w,status:"submitted",result:null,createdAt:at,updatedAt:at};g.instance!==null&&(x.toInstance=g.instance),c!==void 0&&(x.cwd=c),typeof k=="string"&&k.length>0&&(x.fromCwdKey=k),w==="review"&&(x.review=v),f.tasks.push(x),this.registry.save(e,f);let O={task:x,targetJoined:I};return g.instance!==null&&(O.targetOnline=p.has(g.instance),I&&!O.targetOnline&&(O.warning=`"${n}" is not online; the task will wait until it reconnects.`)),I||(O.warning=`"${n}" has not joined this channel yet; the task will wait until it joins.`),O}list(e,{to:t,from:n,status:r,kind:s}={}){let o=this.registry.get(e).tasks;if(t){let c=ft(t);o=o.filter(l=>c.instance===null?(l.toTool??l.to)===c.tool:l.toInstance===c.instance||!l.toInstance&&(l.toTool??l.to)===c.tool)}return n&&(o=o.filter(c=>c.from===n)),r&&(o=o.filter(c=>c.status===r)),s&&(o=o.filter(c=>(c.kind??"task")===s)),o}get(e,t){let n=this.registry.get(e).tasks.find(r=>r.taskId===t);if(!n)throw new C(t);return n}#t(e,t,n){let r=this.registry.get(e),s=r.tasks.find(c=>c.taskId===t);if(!s)throw new C(t);let o=s.status;return n(s),s.updatedAt=new Date().toISOString(),this.registry.save(e,r),s.status!==o&&this.#r(e,t,s),s}#r(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(z.has(s.status))return Promise.resolve(s);if(r?.aborted)return Promise.resolve(null);let o=`${e}/${t}`;return new Promise(c=>{let l,u=this.waiters.get(o)??new Set;this.waiters.set(o,u);let h=p=>{clearTimeout(l),r?.removeEventListener("abort",d),u.delete(h),u.size===0&&this.waiters.get(o)===u&&this.waiters.delete(o),c(p)},d=()=>h(null);u.add(h),r?.addEventListener("abort",d,{once:!0}),l=setTimeout(()=>{let p=s;try{p=this.get(e,t)}catch{}h(p)},n)})}claim(e,t,n){return this.#t(e,t,r=>{if(!wt(r,n))throw new j(t,r.to,n);if(r.status!=="submitted")throw new A(r.status,"working");r.status="working"})}complete(e,t,{from:n,result:r,status:s="completed",worker:o=!1,review:c}){if(s!=="completed"&&s!=="failed")throw new A("?",s);return this.#t(e,t,l=>{if(!wt(l,n))throw new j(t,l.to,n);if(z.has(l.status))throw new A(l.status,s);let u=(l.kind??"task")==="review";if(!u&&c!==void 0)throw new Q(t);if(u&&s==="completed"){if(c===void 0)throw new X(t);r=ee(c)}l.status=s,l.result=r,l.completedBy=n,o?l.completedByWorker=!0:delete l.completedByWorker})}cancel(e,t,{agent:n,reason:r}){return this.#t(e,t,s=>{if(!ne(s,n))throw new J(t,s.from,n);if(s.status!=="submitted"&&s.status!=="working")throw new A(s.status,"cancelled");s.status="cancelled",s.result=r??null,s.cancelledBy=n})}markHookDelivered(e,t,n,r=new Date){let s=this.registry.get(e),o=s.tasks.find(c=>c.taskId===t);if(!o)throw new C(t);return o.hookDelivered={...o.hookDelivered??{},[n]:r.toISOString()},this.registry.save(e,s),o}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=>{z.has(s.status)||(s.status="failed",s.result=n,s.completedBy=r)})}};import{entryId as re}from"../shared/ids.js";var W=class{constructor(e){this.registry=e}add(e,{from:t,summary:n,artifacts:r=[]}){let s=this.registry.get(e),o={entryId:re(),from:t,summary:n,artifacts:r,at:new Date().toISOString()};return s.context.push(o),this.registry.save(e,s),o}list(e,{limit:t}={}){let n=this.registry.get(e).context;return Number.isInteger(t)&&t>0?n.slice(-t):n}};import{spawn as Te}from"node:child_process";import{mkdirSync as xt,openSync as be,closeSync as xe,readFileSync as Re,appendFileSync as nt,readdirSync as Ae,rmSync as Oe}from"node:fs";import{mkdir as Pe,rm as rt}from"node:fs/promises";import{join as E}from"node:path";import{workerEnabled as Ce,TEMPLATE_AGENTS as Le}from"../shared/config.js";import{join as kt}from"node:path";import{fileURLToPath as se}from"node:url";import{agyCommand as ie}from"../shared/agy.js";import{DEFAULT_LIMITS as oe}from"../shared/config.js";var ae=se(new URL("../../bin/pluriply.js",import.meta.url)),ce=["acceptEdits","bypassPermissions"],le=["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 et(){if(process.env.PLURIPLY_ALLOW_TEMPLATE_OVERRIDE!=="1")return null;let i=process.env.PLURIPLY_WORKER_TEMPLATE_OVERRIDE;if(!i)return null;try{return JSON.parse(i)}catch(e){throw new Error(`PLURIPLY_WORKER_TEMPLATE_OVERRIDE is not valid JSON: ${e.message}`)}}function ue(i,e){let t=et()?.[i];if(!t)return;let n=r=>r.replace(/\{(taskId|channelCode|home|cwd|prompt|readOnly)\}/g,(s,o)=>String(e[o]));return{command:t.command,args:t.args.map(n)}}function It(i,{home:e,cwd:t,prompt:n,logDir:r,taskId:s,channelCode:o,taskDir:c=kt(r,s),permissionMode:l="acceptEdits",timeoutMs:u=oe.timeoutMs,readOnly:h=!1}){let d=ue(i,{taskId:s,channelCode:o,home:e,cwd:t,prompt:n,readOnly:h});if(d)return d;switch(i){case"codex":return{command:"codex",args:["exec","-C",t,"--skip-git-repo-check","-s",h?"read-only":"workspace-write","-c",'approval_policy="never"',"-o",kt(r,`${s}.last.md`),n]};case"claude-code":{if(!ce.includes(l))throw new Error(`invalid permissionMode "${l}" for claude-code worker`);let p=JSON.stringify({mcpServers:{pluriply:{command:process.execPath,args:[ae,"connector","--agent","claude-code"],env:{PLURIPLY_HOME:e}}}});return{command:"claude",args:["-p",...h?["--allowedTools",...le,"--permission-mode","default","--add-dir",c]:["--permission-mode",l],"--mcp-config",p,"--strict-mcp-config","--output-format","json",n]}}case"antigravity":return{command:ie(),args:["-p",n,...h?["--mode","plan"]:[],"--dangerously-skip-permissions","--output-format","text","--print-timeout",`${Math.ceil(u/1e3)}s`]};default:return null}}import{pidAlive as De}from"../shared/probe.js";import{writeFile as ye}from"node:fs/promises";import{execFile as he}from"node:child_process";import{promisify as de}from"node:util";var fe=de(he),me=[/^filter\..+\.(clean|smudge|process|required)$/,/^diff\..+\.(command|textconv)$/,/^merge\..+\.driver$/,/^core\.(hookspath|fsmonitor|sshcommand|pager|editor|askpass|gitproxy)$/,/^credential\.(.+\.)?helper$/,/^alias\..+$/,/^sequence\.editor$/,/^gpg\.(.+\.)?program$/],pe=["GIT_DIR","GIT_WORK_TREE","GIT_INDEX_FILE","GIT_CONFIG_PARAMETERS","GIT_CONFIG_COUNT","GIT_EXTERNAL_DIFF","GIT_SSH","GIT_SSH_COMMAND","GIT_ASKPASS","GIT_EDITOR","GIT_PAGER"];function we(){let i={...process.env};for(let e of pe)delete i[e];return i.GIT_CONFIG_GLOBAL=process.platform==="win32"?"NUL":"/dev/null",i.GIT_CONFIG_NOSYSTEM="1",i.GIT_ATTR_NOSYSTEM="1",i.GIT_TERMINAL_PROMPT="0",i}function ge(i){if(!i)return"";let e=String(i).trim().split(`
4
+ `).map(t=>t.trim()).filter(Boolean);return e.length===0?"":e.find(t=>/^(fatal|error):/i.test(t))??e.at(-1)}async function L(i,{cwd:e,env:t,maxBuffer:n=4*1024*1024,timeout:r=2e4}){try{let{stdout:s}=await fe("git",i,{cwd:e,env:t,encoding:"utf8",maxBuffer:n,timeout:r,windowsHide:!0});return s}catch(s){throw new Error(ge(s.stderr)||s.message)}}async function St(i){let e=we(),t=o=>L(["config","--list",o,"--includes","-z"],{cwd:i,env:e,timeout:1e4}),n=[await t("--local")];try{n.push(await t("--worktree"))}catch(o){if(!/cannot be used with multiple working trees|unable to read config file/i.test(o.message))throw o}let r=["-c","core.fsmonitor=false"],s=new Set(["core.fsmonitor"]);for(let o of n)for(let c of o.split("\0")){if(!c)continue;let l=c.split(`
5
+ `,1)[0];if(s.has(l))continue;let u=l.toLowerCase();me.some(h=>h.test(u))&&(s.add(l),r.push("-c",`${l}=`))}return{args:r,env:e}}var ke=20*1024*1024;async function _t({cwd:i,review:e,outFile:t,git:n}){if(e.gitRange?.startsWith("-"))throw new Error('gitRange must not start with "-"');let r=[...n.args,"diff","--no-color","--no-ext-diff","--no-textconv"],s;e.gitRange?(r.push(e.gitRange,"--"),e.paths?.length&&r.push(...e.paths),s=`git diff ${e.gitRange}`):e.paths?.length?(r.push("HEAD","--",...e.paths),s=`git diff HEAD -- ${e.paths.join(" ")}`):(r.push("HEAD"),s="git diff HEAD");let o;try{o=await L(r,{cwd:i,env:n.env,maxBuffer:ke,timeout:2e4})}catch(c){throw new Error(`${s} failed: ${c.message}`)}return await ye(t,o),{file:t,bytes:Buffer.byteLength(o),target:s}}import{copyFile as Ie,lstat as Se,mkdir as Et,rm as vt}from"node:fs/promises";import{dirname as _e,isAbsolute as Ee,join as $t}from"node:path";var ve=512*1024*1024,$e=16,Tt=Object.freeze({recursive:!0,force:!0,maxRetries:5,retryDelay:100});async function bt({repoDir:i,destDir:e,git:t,maxBytes:n=ve}){let s=(await L([...t.args,"ls-files","-z","-co","--exclude-standard"],{cwd:i,env:t.env,maxBuffer:67108864})).split("\0").filter(Boolean);await vt(e,Tt),await Et(e,{recursive:!0});let o=0,c=0,l=0,u=0,h=async()=>{for(;u<s.length;){let w=s[u++];if(Ee(w)||w.split("/").includes(".."))continue;let y=$t(i,w),k;try{k=await Se(y)}catch{continue}if(k.isSymbolicLink()){l++;continue}if(!k.isFile())continue;if(c+=k.size,c>n)throw new Error(`snapshot exceeds ${Math.floor(n/1024/1024)}MB`);let g=$t(e,w);await Et(_e(g),{recursive:!0}),await Ie(y,g),o++}},p=(await Promise.allSettled(Array.from({length:Math.min($e,s.length)},h))).find(w=>w.status==="rejected");if(p)throw await vt(e,Tt),p.reason;return{files:o,bytes:c,skippedSymlinks:l}}var q=Object.freeze({recursive:!0,force:!0,maxRetries:5,retryDelay:100}),Me=i=>`run \`pluriply worker enable ${i}\` to let the hub process this automatically`,Ne=i=>`no worker template is configured for "${i}"; enable it via config or set PLURIPLY_WORKER_TEMPLATE_OVERRIDE`,je=new Set(["completed","failed","cancelled"]);function Ge(i){return Le.includes(i)?!0:!!et()?.[i]}function We(i){try{return Re(i,"utf8").trimEnd().split(`
6
6
  `).slice(-20).join(`
7
7
  `)}catch{return""}}function qe({agent:i,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 ${i} \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(`
8
- `)}function Ke({agent:i,channelCode:e,taskId:t,cwd:n,origin:r,from:s,diff:o}){return[`\uB2F9\uC2E0\uC740 Pluriply \uCC44\uB110 ${e}\uC5D0\uC11C \uCF54\uB4DC \uB9AC\uBDF0\uB97C \uC704\uC784\uBC1B\uC740 ${i} \uB9AC\uBDF0\uC5B4\uC785\uB2C8\uB2E4.`,`\uC694\uCCAD\uC790\uB294 ${s} \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.`,`3. \uC791\uC5C5 \uD3F4\uB354 ${n} \uB294 \uC6D0\uBCF8 \uC800\uC7A5\uC18C ${r} \uC758 \uC2A4\uB0C5\uC0F7 \uBCF5\uC0AC\uBCF8\uC774\uBA70 git \uC800\uC7A5\uC18C\uAC00 \uC544\uB2D9\uB2C8\uB2E4. \uD5C8\uBE0C\uAC00 \uB9CC\uB4E0 unified diff \uD30C\uC77C ${o.file} (${o.target}) \uC744 \uC77D\uACE0, \uD544\uC694\uD558\uBA74 \uC791\uC5C5 \uD3F4\uB354\uC758 \uD30C\uC77C\uC744 \uD568\uAED8 \uC77D\uC5B4 \uAC80\uD1A0\uD558\uC138\uC694. git \uC744 \uC2E4\uD589\uD558\uC9C0 \uB9D0\uACE0, \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. file \uC740 \uC800\uC7A5\uC18C \uB8E8\uD2B8 \uAE30\uC900 \uC0C1\uB300\uACBD\uB85C\uB85C \uC801\uC73C\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(diff \uAC00 \uBE44\uC5B4 \uC788\uAC70\uB098 \uAC80\uD1A0 \uBC94\uC704\uB97C \uD310\uB2E8\uD560 \uC218 \uC5C6\uC73C\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(`
9
- `)}var K=class{constructor({home:e,tasks:t}){this.home=e,this.tasks=t,this.running=new Map,this.swept=!1,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(!Ce(r,s))return{kind:"none",hint:Me(s)};try{if(!Ge(s))return{kind:"none",hint:Ne(s)};if(this.#t(s)>=r.limits.maxConcurrentPerAgent)return this.#r(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.#i(e,t,r)}catch(o){return{kind:"none",hint:`worker spawn failed: ${o.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?s.child.kill("SIGTERM"):s.cancelled=!0);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.#c(e,t.taskId)||De(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`}))}this.swept||(this.swept=!0,this.#e())}async stopAll(){this.stopping=!0;let e=[];for(let[r,s]of this.running)for(let o of s){if(!o.child){o.cancelled=!0,s.delete(o);try{this.tasks.failIfOpen(o.code,o.taskId,{result:"hub stopped while the review was being prepared",by:`${r} worker`})}catch{}rt(E(this.home,"workers",o.taskId,"tree"),q).catch(()=>{});continue}o.child.kill("SIGTERM"),e.push(o.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}#r(e){let t=0;for(let n of this.queue)(n.task.toTool??n.task.to)===e&&t++;return t}#c(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}#s(e){for(let t of this.running.values())for(let n of t)if(n.taskId===e)return!0;return!1}#e(){let e=E(this.home,"workers"),t;try{t=Ae(e,{withFileTypes:!0})}catch{return}for(let n of t)if(!(!n.isDirectory()||this.#s(n.name)))try{Oe(E(e,n.name,"tree"),q)}catch{}}#i(e,t,n){let r=t.toTool??t.to,s={code:e,taskId:t.taskId,child:null,cancelled:!1};this.running.has(r)||this.running.set(r,new Set),this.running.get(r).add(s),this.#l(e,t,n,s).catch(o=>{this.#n(s,r,t,`worker spawn failed: ${o.message}`)})}#n(e,t,n,r){this.running.get(t)?.delete(e);let s=E(this.home,"workers",`${n.taskId}.log`);try{nt(s,`${r}
10
- `)}catch{}try{this.tasks.failIfOpen(e.code,n.taskId,{result:r,by:`${t} worker`})}catch{}rt(E(this.home,"workers",n.taskId,"tree"),q).catch(()=>{}),this.#a(t)}async#l(e,t,n,r){let s=t.toTool??t.to,o=E(this.home,"workers");Rt(o,{recursive:!0});let c=E(o,`${t.taskId}.log`),l=(t.kind??"task")==="review",u=E(o,t.taskId),h={agent:s,channelCode:e,taskId:t.taskId,from:t.from},d,p,w="";try{if(l){if(!t.cwd)throw new Error("review task has no cwd");await Pe(u,{recursive:!0});let f=await _t(t.cwd);d=E(u,"tree");let a=await bt({repoDir:t.cwd,destDir:d,git:f}),m=await St({cwd:t.cwd,review:t.review??{},outFile:E(u,"review.diff"),git:f});w=`snapshot: ${a.files} files, ${a.bytes} bytes, ${a.skippedSymlinks} symlinks skipped
11
- `,p=Ke({...h,cwd:d,origin:t.cwd,diff:m}),await this.#o()}else d=t.cwd??E(this.home,"workspaces",t.taskId),Rt(d,{recursive:!0}),p=qe({...h,cwd:d})}catch(f){this.#n(r,s,t,`${l?"review preparation":"worker spawn"} failed: ${f.message}`);return}if(r.cancelled||this.stopping){this.#n(r,s,t,this.stopping?"hub stopped while the worker was being prepared":"worker cancelled while it was being prepared");return}let y;try{let f=It(s,{home:this.home,cwd:d,prompt:p,logDir:o,taskDir:u,taskId:t.taskId,channelCode:e,permissionMode:n.workers[s]?.permissionMode,timeoutMs:n.limits.timeoutMs,readOnly:l});w&&nt(c,w);let a=be(c,"a");try{y=Te(f.command,f.args,{cwd:d,shell:!1,stdio:["ignore",a,a],env:{...process.env,PLURIPLY_HOME:this.home,PLURIPLY_WORKER_TASK:t.taskId,PLURIPLY_WORKER_AGENT:s,PLURIPLY_DEPTH:String(t.depth??0)}})}finally{Re(a)}}catch(f){this.#n(r,s,t,`worker spawn failed: ${f.message}`);return}r.child=y;try{this.tasks.setWorker(e,t.taskId,{agent:s,pid:y.pid,startedAt:new Date().toISOString(),log:c})}catch(f){try{y.kill("SIGKILL")}catch{}this.#n(r,s,t,`worker spawn failed: ${f.message}`);return}let k=!1,g=setTimeout(()=>{k=!0,y.kill("SIGTERM"),setTimeout(()=>y.kill("SIGKILL"),5e3).unref()},n.limits.timeoutMs),v=(f,a)=>{clearTimeout(g),this.running.get(s)?.delete(r);let m={endedAt:new Date().toISOString(),exitCode:f};k&&(m.timedOut=!0);try{this.tasks.setWorker(e,t.taskId,m);let I;if(a){I=`worker failed to start: ${a.message}`;try{nt(c,`${I}
8
+ `)}function He({agent:i,channelCode:e,taskId:t,cwd:n,origin:r,from:s,diff:o}){return[`\uB2F9\uC2E0\uC740 Pluriply \uCC44\uB110 ${e}\uC5D0\uC11C \uCF54\uB4DC \uB9AC\uBDF0\uB97C \uC704\uC784\uBC1B\uC740 ${i} \uB9AC\uBDF0\uC5B4\uC785\uB2C8\uB2E4.`,`\uC694\uCCAD\uC790\uB294 ${s} \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.`,`3. \uC791\uC5C5 \uD3F4\uB354 ${n} \uB294 \uC6D0\uBCF8 \uC800\uC7A5\uC18C ${r} \uC758 \uC2A4\uB0C5\uC0F7 \uBCF5\uC0AC\uBCF8\uC774\uBA70 git \uC800\uC7A5\uC18C\uAC00 \uC544\uB2D9\uB2C8\uB2E4. \uD5C8\uBE0C\uAC00 \uB9CC\uB4E0 unified diff \uD30C\uC77C ${o.file} (${o.target}) \uC744 \uC77D\uACE0, \uD544\uC694\uD558\uBA74 \uC791\uC5C5 \uD3F4\uB354\uC758 \uD30C\uC77C\uC744 \uD568\uAED8 \uC77D\uC5B4 \uAC80\uD1A0\uD558\uC138\uC694. git \uC744 \uC2E4\uD589\uD558\uC9C0 \uB9D0\uACE0, \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. file \uC740 \uC800\uC7A5\uC18C \uB8E8\uD2B8 \uAE30\uC900 \uC0C1\uB300\uACBD\uB85C\uB85C \uC801\uC73C\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(diff \uAC00 \uBE44\uC5B4 \uC788\uAC70\uB098 \uAC80\uD1A0 \uBC94\uC704\uB97C \uD310\uB2E8\uD560 \uC218 \uC5C6\uC73C\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(`
9
+ `)}var H=class{constructor({home:e,tasks:t}){this.home=e,this.tasks=t,this.running=new Map,this.swept=!1,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(!Ce(r,s))return{kind:"none",hint:Me(s)};try{if(!Ge(s))return{kind:"none",hint:Ne(s)};if(this.#t(s)>=r.limits.maxConcurrentPerAgent)return this.#r(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.#o(e,t,r)}catch(o){return{kind:"none",hint:`worker spawn failed: ${o.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?s.child.kill("SIGTERM"):s.cancelled=!0);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.#c(e,t.taskId)||De(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`}))}this.swept||(this.swept=!0,this.#e())}async stopAll(){this.stopping=!0;let e=[];for(let[r,s]of this.running)for(let o of s){if(!o.child){o.cancelled=!0,s.delete(o);try{this.tasks.failIfOpen(o.code,o.taskId,{result:"hub stopped while the review was being prepared",by:`${r} worker`})}catch{}rt(E(this.home,"workers",o.taskId,"tree"),q).catch(()=>{});continue}o.child.kill("SIGTERM"),e.push(o.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}#r(e){let t=0;for(let n of this.queue)(n.task.toTool??n.task.to)===e&&t++;return t}#c(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}#s(e){for(let t of this.running.values())for(let n of t)if(n.taskId===e)return!0;return!1}#e(){let e=E(this.home,"workers"),t;try{t=Ae(e,{withFileTypes:!0})}catch{return}for(let n of t)if(!(!n.isDirectory()||this.#s(n.name)))try{Oe(E(e,n.name,"tree"),q)}catch{}}#o(e,t,n){let r=t.toTool??t.to,s={code:e,taskId:t.taskId,child:null,cancelled:!1};this.running.has(r)||this.running.set(r,new Set),this.running.get(r).add(s),this.#a(e,t,n,s).catch(o=>{this.#n(s,r,t,`worker spawn failed: ${o.message}`)})}#n(e,t,n,r){this.running.get(t)?.delete(e);let s=E(this.home,"workers",`${n.taskId}.log`);try{nt(s,`${r}
10
+ `)}catch{}try{this.tasks.failIfOpen(e.code,n.taskId,{result:r,by:`${t} worker`})}catch{}rt(E(this.home,"workers",n.taskId,"tree"),q).catch(()=>{}),this.#i(t)}async#a(e,t,n,r){let s=t.toTool??t.to,o=E(this.home,"workers");xt(o,{recursive:!0});let c=E(o,`${t.taskId}.log`),l=(t.kind??"task")==="review",u=E(o,t.taskId),h={agent:s,channelCode:e,taskId:t.taskId,from:t.from},d,p,w="";try{if(l){if(!t.cwd)throw new Error("review task has no cwd");await Pe(u,{recursive:!0});let f=await St(t.cwd);d=E(u,"tree");let a=await bt({repoDir:t.cwd,destDir:d,git:f}),m=await _t({cwd:t.cwd,review:t.review??{},outFile:E(u,"review.diff"),git:f});w=`snapshot: ${a.files} files, ${a.bytes} bytes, ${a.skippedSymlinks} symlinks skipped
11
+ `,p=He({...h,cwd:d,origin:t.cwd,diff:m}),await this.#l()}else d=t.cwd??E(this.home,"workspaces",t.taskId),xt(d,{recursive:!0}),p=qe({...h,cwd:d})}catch(f){this.#n(r,s,t,`${l?"review preparation":"worker spawn"} failed: ${f.message}`);return}if(r.cancelled||this.stopping){this.#n(r,s,t,this.stopping?"hub stopped while the worker was being prepared":"worker cancelled while it was being prepared");return}let y;try{let f=It(s,{home:this.home,cwd:d,prompt:p,logDir:o,taskDir:u,taskId:t.taskId,channelCode:e,permissionMode:n.workers[s]?.permissionMode,timeoutMs:n.limits.timeoutMs,readOnly:l});w&&nt(c,w);let a=be(c,"a");try{y=Te(f.command,f.args,{cwd:d,shell:!1,stdio:["ignore",a,a],env:{...process.env,PLURIPLY_HOME:this.home,PLURIPLY_WORKER_TASK:t.taskId,PLURIPLY_WORKER_AGENT:s,PLURIPLY_DEPTH:String(t.depth??0)}})}finally{xe(a)}}catch(f){this.#n(r,s,t,`worker spawn failed: ${f.message}`);return}r.child=y;try{this.tasks.setWorker(e,t.taskId,{agent:s,pid:y.pid,startedAt:new Date().toISOString(),log:c})}catch(f){try{y.kill("SIGKILL")}catch{}this.#n(r,s,t,`worker spawn failed: ${f.message}`);return}let k=!1,g=setTimeout(()=>{k=!0,y.kill("SIGTERM"),setTimeout(()=>y.kill("SIGKILL"),5e3).unref()},n.limits.timeoutMs),v=(f,a)=>{clearTimeout(g),this.running.get(s)?.delete(r);let m={endedAt:new Date().toISOString(),exitCode:f};k&&(m.timedOut=!0);try{this.tasks.setWorker(e,t.taskId,m);let I;if(a){I=`worker failed to start: ${a.message}`;try{nt(c,`${I}
12
12
  `)}catch{}}else k?I=`worker timed out after ${n.limits.timeoutMs/1e3}s`:I=`worker exited without submitting a result (exit ${f})
13
13
  --- last log lines ---
14
- ${We(c)}`;this.tasks.failIfOpen(e,t.taskId,{result:I,by:`${s} worker`})}catch{}l&&rt(E(u,"tree"),q).catch(()=>{}),this.#a(s)};y.on("exit",(f,a)=>v(f??(a?-1:0))),y.on("error",f=>v(-1,f))}async#o(){if(process.env.PLURIPLY_ALLOW_TEMPLATE_OVERRIDE!=="1")return;let e=Number(process.env.PLURIPLY_TEST_PREPARE_DELAY_MS);e>0&&await new Promise(t=>setTimeout(t,e))}#a(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(je.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.#i(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{loadConfig as Ye}from"../shared/config.js";import{pluriplyHome as Je}from"../shared/paths.js";import{shortId as ze}from"../shared/ids.js";import{isValidAgentName as Pt,isInstanceId as Ve,makeInstanceId as Xe,toolOf as Qe,cwdKey as Ct}from"../shared/identity.js";import{PACKAGE_VERSION as Lt,PROTOCOL_VERSION as Dt}from"../shared/version.js";import{pingHub as Ze,pidAlive as Mt,homeId as Nt}from"../shared/probe.js";function st(i){try{let e=JSON.parse(Be(i,"utf8"));return Number.isInteger(e?.pid)&&Number.isInteger(e?.port)?e:null}catch{return null}}var it=class{constructor({home:e=Je(),port:t=0,verifyDelayMs:n=100}={}){this.home=e,this.requestedPort=t,this.verifyDelayMs=n;let r=new D(e);this.channels=new N(r),this.tasks=new G(this.channels),this.context=new W(this.channels),this.agents=new M(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(){Fe(this.home,{recursive:!0}),await new Promise((t,n)=>{this.wss=new He({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.#c(t,n)),t.on("close",()=>{this.connections.get(t)?.abort.abort(),this.connections.delete(t)})});let e=At(this.home,"hub.json");for(let t=0;t<2;t++){if(this.#r(e)){await new Promise(s=>setTimeout(s,this.verifyDelayMs));let r=st(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=st(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};xt(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(!Mt(e.pid))return!1;let t=Date.now()+2e3;for(;;){let n=await Ze(e.port,300);if(n)return!n.home||n.home===Nt(this.home);if(Date.now()>=t||!Mt(e.pid))return!1;await new Promise(r=>setTimeout(r,200))}}#r(e){let t=JSON.stringify({pid:process.pid,port:this.wss.address().port,version:Lt,protocol:Dt,startedAt:new Date().toISOString()},null,2);try{return Ue(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=At(this.home,"hub.json");st(e)?.pid===process.pid&&xt(e,{force:!0});for(let t of this.connections.keys())t.terminate();await new Promise(t=>this.wss.close(t)),this.wss=null}async#c(e,t){let n;try{n=JSON.parse(t.toString())}catch{return}try{let r=await this.#a(n.type,n.payload??{},e);this.#s(e,{id:n.id,ok:!0,payload:r})}catch(r){this.#s(e,{id:n.id,ok:!1,error:{message:r.message}})}}#s(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}#i(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}}#n(e,t){let n=this.onlineInstances(e);return t.map(r=>({...r,online:n.has(r.instanceId)}))}#l(e){for(;;){let t=Xe(e,ze(4));if(!(this.issued.has(t)||[...this.connections.values()].some(r=>r.instanceId===t)))return this.issued.add(t),t}}#o(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.#n(n,s)}#a(e,t,n){switch(e){case"ping":return{pong:!0,version:Lt,protocol:Dt,pid:process.pid,home:Nt(this.home)};case"hook.poll":{let r=t.tool,s={channelCode:null,tool:r,incoming:[],results:[],more:0};if(!Pt(r)||typeof t.cwd!="string"||!Ot(t.cwd))return s;let o=Ct(r,t.cwd),c=[...this.connections.values()].filter(a=>a.cwdKey===o);if(c.length>0&&c.every(a=>a.worker))return s;let l=c.filter(a=>!a.worker),u=l.length>0?[...new Set(l.flatMap(a=>[...a.channels]))]:[this.agents.resume(o)].filter(Boolean);if(u.length===0)return s;for(let a of u)this.workers.reconcile(a);let h=new Set(l.map(a=>a.instanceId)),d=a=>!a.hookDelivered?.[o],p=u.flatMap(a=>this.tasks.list(a).map(m=>({t:m,code:a}))),w=p.filter(({t:a})=>a.status==="submitted"&&d(a)&&(a.toInstance?h.has(a.toInstance):(a.toTool??a.to)===r)),y=p.filter(({t:a})=>a.fromCwdKey===o&&(a.status==="completed"||a.status==="failed")&&d(a)),k=a=>{let m=String(a.request??"").replace(/\s+/g," ").trim();return m.length>80?`${m.slice(0,80)}\u2026`:m},g=[...w.map(({t:a,code:m})=>({t:a,code:m,at:a.createdAt,entry:{taskId:a.taskId,kind:a.kind??"task",from:a.from,summary:k(a)},side:"incoming"})),...y.map(({t:a,code:m})=>({t:a,code:m,at:a.updatedAt,entry:{taskId:a.taskId,status:a.status,to:a.completedBy??a.to,summary:k(a)},side:"results"}))].sort((a,m)=>a.at<m.at?-1:a.at>m.at?1:0),v=g.slice(0,10),f=new Date;for(let a of v)this.tasks.markHookDelivered(a.code,a.t.taskId,o,f);return{channelCode:u[0],tool:r,incoming:v.filter(a=>a.side==="incoming").map(a=>a.entry),results:v.filter(a=>a.side==="results").map(a=>a.entry),more:g.length-v.length}}case"channel.create":return{channelCode:this.channels.create().channel.code};case"agent.hello":{if(!Pt(t.tool))throw new Error(`invalid agent name: ${t.tool}`);if(typeof t.cwd!="string"||t.cwd.length===0||!Ot(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(!Ve(s)||Qe(s)!==t.tool)throw new Error(`invalid instanceId: ${s}`)}else s=this.#l(t.tool);return r.instanceId=s,r.tool=t.tool,r.worker=!!t.worker,r.cwdKey=Ct(t.tool,t.cwd),{instanceId:s}}case"channel.join":{let r=this.#e(n);this.channels.get(t.channelCode);let s=this.#o(n,r,t.channelCode);return{channelCode:t.channelCode,peers:s}}case"channel.peers":return{peers:this.#n(t.channelCode,this.channels.peers(t.channelCode,{online:this.onlineInstances(t.channelCode)}))};case"channel.presence":return this.channels.get(t.channelCode),this.#i(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 o=this.#o(n,r,s);return{channelCode:s,peers:o}}case"task.create":{let r=this.#e(n),s=Ye(this.home),{task:o,targetJoined:c,targetOnline:l,warning:u}=this.tasks.create(t.channelCode,{...t,from:r.instanceId,fromCwdKey:r.cwdKey,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 h=this.workers.dispatch(t.channelCode,o,{interactive:this.isInteractive(t.channelCode,o.toTool),config:s}),d={taskId:o.taskId,targetJoined:c,dispatch:h.kind};return l!==void 0&&(d.targetOnline=l),u&&(d.warning=u),h.hint&&(d.hint=h.hint),d}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(o=>{if(o===null)throw new Error("connection closed while waiting");return{task:o}})}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 tn}from"node:child_process";import{existsSync as en,rmSync as ot}from"node:fs";import{join as nn}from"node:path";import{fileURLToPath as rn}from"node:url";import{pingHub as jt,homeId as sn,pidAlive as on}from"../shared/probe.js";import{readLock as Gt}from"../shared/lock.js";var an=rn(new URL("../../bin/pluriply.js",import.meta.url));async function Wt(i){let e=Gt(i);if(!e)return null;let t=await jt(e.port);return!t||t.home&&t.home!==sn(i)?null:{...t,port:e.port,lockPid:e.pid}}async function cn({home:i,timeoutMs:e=5e3}){tn(process.execPath,[an,"hub","start"],{detached:!0,stdio:"ignore",env:{...process.env,PLURIPLY_HOME:i}}).unref();let n=Date.now()+e;for(;Date.now()<n;){let r=await Wt(i);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 ln({home:i,timeoutMs:e=5e3}){let t=Gt(i),n=nn(i,"hub.json");if(!t)return"not-running";let r=await jt(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 o=Date.now()+e;for(;Date.now()<o;){if(!en(n))return"stopped";if(!on(t.pid))return ot(n,{force:!0}),"stopped";await new Promise(c=>setTimeout(c,100))}return"timeout"}import{readLock as vr}from"../shared/lock.js";import{loadConfig as Tr,saveConfig as br,setWorkerEnabled as Rr,TEMPLATE_AGENTS as xr}from"../shared/config.js";export{it as Hub,xr as TEMPLATE_AGENTS,Wt as liveHub,Tr as loadConfig,vr as readLock,br as saveConfig,Rr as setWorkerEnabled,cn as spawnHub,ln as stopHub};
14
+ ${We(c)}`;this.tasks.failIfOpen(e,t.taskId,{result:I,by:`${s} worker`})}catch{}l&&rt(E(u,"tree"),q).catch(()=>{}),this.#i(s)};y.on("exit",(f,a)=>v(f??(a?-1:0))),y.on("error",f=>v(-1,f))}async#l(){if(process.env.PLURIPLY_ALLOW_TEMPLATE_OVERRIDE!=="1")return;let e=Number(process.env.PLURIPLY_TEST_PREPARE_DELAY_MS);e>0&&await new Promise(t=>setTimeout(t,e))}#i(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(je.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.#o(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{loadConfig as Ve}from"../shared/config.js";import{pluriplyHome as Xe}from"../shared/paths.js";import{shortId as Qe}from"../shared/ids.js";import{isValidAgentName as Pt,isInstanceId as Ze,makeInstanceId as tn,toolOf as en,cwdKey as Ct}from"../shared/identity.js";import{PACKAGE_VERSION as Lt,PROTOCOL_VERSION as Dt}from"../shared/version.js";import{pingHub as nn,pidAlive as Mt,homeId as Nt}from"../shared/probe.js";function st(i){try{let e=JSON.parse(Be(i,"utf8"));return Number.isInteger(e?.pid)&&Number.isInteger(e?.port)?e:null}catch{return null}}var rn="unauthorized: hub requires a token \u2014 re-run `npx pluriply@latest setup` and restart your tool",it=class{constructor({home:e=Xe(),port:t=0,verifyDelayMs:n=100,log:r=s=>process.stderr.write(s)}={}){this.home=e,this.requestedPort=t,this.verifyDelayMs=n,this.log=r,this.token=null;let s=new D(e);this.channels=new N(s),this.tasks=new G(this.channels),this.context=new W(this.channels),this.agents=new M(s),this.workers=new H({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(){Ue(this.home,{recursive:!0,mode:448});try{Ye(this.home,448)}catch(t){this.log(`hub: could not chmod ${this.home} to 0700 (${t.code??t.message})
15
+ `)}await new Promise((t,n)=>{this.wss=new Ke({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,n)=>{this.connections.set(t,{authed:this.#o(n),remote:n.socket?.remoteAddress??"?",warned:!1,instanceId:null,tool:null,worker:!1,cwdKey:null,channels:new Set,abort:new AbortController}),t.on("message",r=>this.#c(t,r)),t.on("close",()=>{this.connections.get(t)?.abort.abort(),this.connections.delete(t)})});let e=At(this.home,"hub.json");for(let t=0;t<2;t++){if(this.#r(e)){await new Promise(s=>setTimeout(s,this.verifyDelayMs));let r=st(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,this.token=typeof r.token=="string"?r.token:null,{port:r.port,redundant:!0}):{port:this.port}}let n=st(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,this.token=typeof n.token=="string"?n.token:null,{port:n.port,redundant:!0};Rt(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(!Mt(e.pid))return!1;let t=Date.now()+2e3;for(;;){let n=await nn(e.port,300);if(n)return!n.home||n.home===Nt(this.home);if(Date.now()>=t||!Mt(e.pid))return!1;await new Promise(r=>setTimeout(r,200))}}#r(e){let t=ze(32).toString("hex"),n=JSON.stringify({pid:process.pid,port:this.wss.address().port,version:Lt,protocol:Dt,startedAt:new Date().toISOString(),token:t},null,2);try{return Fe(e,n,{flag:"wx",mode:384}),this.token=t,!0}catch(r){if(r.code==="EEXIST")return!1;throw r}}async stop(){if(this.redundant||!this.wss)return;await this.workers.stopAll();let e=At(this.home,"hub.json");st(e)?.pid===process.pid&&Rt(e,{force:!0}),this.token=null;for(let t of this.connections.keys())t.terminate();await new Promise(t=>this.wss.close(t)),this.wss=null}async#c(e,t){let n;try{n=JSON.parse(t.toString())}catch{return}if(!n||typeof n!="object")return;let r=this.connections.get(e);if(n.type!=="ping"&&!r?.authed){if(r&&!r.warned){r.warned=!0;let s=String(n.type).slice(0,40).replace(/[^\w.-]/g,"?");this.log(`hub: rejected unauthenticated ${s} from ${r.remote}
16
+ `)}this.#s(e,{id:n.id,ok:!1,error:{message:rn}});return}try{let s=await this.#u(n.type,n.payload??{},e);this.#s(e,{id:n.id,ok:!0,payload:s})}catch(s){this.#s(e,{id:n.id,ok:!1,error:{message:s.message}})}}#s(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}#o(e){if(!this.token)return!1;let t=e.headers.authorization;if(typeof t!="string")return!1;let n=/^Bearer (\S+)$/.exec(t);if(!n)return!1;let r=Buffer.from(n[1]),s=Buffer.from(this.token);return r.length===s.length&&Je(r,s)}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}#n(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}}#a(e,t){let n=this.onlineInstances(e);return t.map(r=>({...r,online:n.has(r.instanceId)}))}#l(e){for(;;){let t=tn(e,Qe(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.#a(n,s)}#u(e,t,n){switch(e){case"ping":return{pong:!0,version:Lt,protocol:Dt,pid:process.pid,home:Nt(this.home)};case"hook.poll":{let r=t.tool,s={channelCode:null,tool:r,incoming:[],results:[],more:0};if(!Pt(r)||typeof t.cwd!="string"||!Ot(t.cwd))return s;let o=Ct(r,t.cwd),c=[...this.connections.values()].filter(a=>a.cwdKey===o);if(c.length>0&&c.every(a=>a.worker))return s;let l=c.filter(a=>!a.worker),u=l.length>0?[...new Set(l.flatMap(a=>[...a.channels]))]:[this.agents.resume(o)].filter(Boolean);if(u.length===0)return s;for(let a of u)this.workers.reconcile(a);let h=new Set(l.map(a=>a.instanceId)),d=a=>!a.hookDelivered?.[o],p=u.flatMap(a=>this.tasks.list(a).map(m=>({t:m,code:a}))),w=p.filter(({t:a})=>a.status==="submitted"&&d(a)&&(a.toInstance?h.has(a.toInstance):(a.toTool??a.to)===r)),y=p.filter(({t:a})=>a.fromCwdKey===o&&(a.status==="completed"||a.status==="failed")&&d(a)),k=a=>{let m=String(a.request??"").replace(/\s+/g," ").trim();return m.length>80?`${m.slice(0,80)}\u2026`:m},g=[...w.map(({t:a,code:m})=>({t:a,code:m,at:a.createdAt,entry:{taskId:a.taskId,kind:a.kind??"task",from:a.from,summary:k(a)},side:"incoming"})),...y.map(({t:a,code:m})=>({t:a,code:m,at:a.updatedAt,entry:{taskId:a.taskId,status:a.status,to:a.completedBy??a.to,summary:k(a)},side:"results"}))].sort((a,m)=>a.at<m.at?-1:a.at>m.at?1:0),v=g.slice(0,10),f=new Date;for(let a of v)this.tasks.markHookDelivered(a.code,a.t.taskId,o,f);return{channelCode:u[0],tool:r,incoming:v.filter(a=>a.side==="incoming").map(a=>a.entry),results:v.filter(a=>a.side==="results").map(a=>a.entry),more:g.length-v.length}}case"channel.create":return{channelCode:this.channels.create().channel.code};case"agent.hello":{if(!Pt(t.tool))throw new Error(`invalid agent name: ${t.tool}`);if(typeof t.cwd!="string"||t.cwd.length===0||!Ot(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(!Ze(s)||en(s)!==t.tool)throw new Error(`invalid instanceId: ${s}`)}else s=this.#l(t.tool);return r.instanceId=s,r.tool=t.tool,r.worker=!!t.worker,r.cwdKey=Ct(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.#a(t.channelCode,this.channels.peers(t.channelCode,{online:this.onlineInstances(t.channelCode)}))};case"channel.presence":return this.channels.get(t.channelCode),this.#n(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 o=this.#i(n,r,s);return{channelCode:s,peers:o}}case"task.create":{let r=this.#e(n),s=Ve(this.home),{task:o,targetJoined:c,targetOnline:l,warning:u}=this.tasks.create(t.channelCode,{...t,from:r.instanceId,fromCwdKey:r.cwdKey,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 h=this.workers.dispatch(t.channelCode,o,{interactive:this.isInteractive(t.channelCode,o.toTool),config:s}),d={taskId:o.taskId,targetJoined:c,dispatch:h.kind};return l!==void 0&&(d.targetOnline=l),u&&(d.warning=u),h.hint&&(d.hint=h.hint),d}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(o=>{if(o===null)throw new Error("connection closed while waiting");return{task:o}})}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 sn}from"node:child_process";import{existsSync as on,rmSync as ot}from"node:fs";import{join as an}from"node:path";import{fileURLToPath as cn}from"node:url";import{pingHub as jt,homeId as ln,pidAlive as un}from"../shared/probe.js";import{readLock as Gt}from"../shared/lock.js";var hn=cn(new URL("../../bin/pluriply.js",import.meta.url));async function Wt(i){let e=Gt(i);if(!e)return null;let t=await jt(e.port);return!t||t.home&&t.home!==ln(i)?null:{...t,port:e.port,lockPid:e.pid,token:e.token}}async function dn({home:i,timeoutMs:e=5e3}){sn(process.execPath,[hn,"hub","start"],{detached:!0,stdio:"ignore",env:{...process.env,PLURIPLY_HOME:i}}).unref();let n=Date.now()+e;for(;Date.now()<n;){let r=await Wt(i);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 fn({home:i,timeoutMs:e=5e3}){let t=Gt(i),n=an(i,"hub.json");if(!t)return"not-running";let r=await jt(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 o=Date.now()+e;for(;Date.now()<o;){if(!on(n))return"stopped";if(!un(t.pid))return ot(n,{force:!0}),"stopped";await new Promise(c=>setTimeout(c,100))}return"timeout"}import{readLock as Rr}from"../shared/lock.js";import{loadConfig as Or,saveConfig as Pr,setWorkerEnabled as Cr,TEMPLATE_AGENTS as Lr}from"../shared/config.js";export{it as Hub,Lr as TEMPLATE_AGENTS,Wt as liveHub,Or as loadConfig,Rr as readLock,Pr as saveConfig,Cr as setWorkerEnabled,dn as spawnHub,fn as stopHub};
@@ -14,6 +14,10 @@ const hubClient = () => import("../connector/hub-client.js");
14
14
  export const REMOVE_NOTE =
15
15
  "note: close or restart open client sessions; their connectors may restart the hub";
16
16
 
17
+ /** `--hooks-only` 실행의 첫 줄(스펙 §4.3). MCP 표가 비어 있는 이유를 알려 준다. */
18
+ export const HOOKS_ONLY_NOTE =
19
+ "hooks only — MCP registration, workers and hub untouched";
20
+
17
21
  /** @param {string[]|undefined} only */
18
22
  function resolveTargets(only) {
19
23
  if (!only) return CLIENTS;
@@ -108,7 +112,8 @@ function walkHooks({ targets, rows, e, dryRun, hooks, remove }) {
108
112
  * env.stopHub / env.rm / env.lstat 은 테스트가 주입한다.
109
113
  * `hooks`(기본 true)는 Claude Code·Codex 의 Stop 훅 등록 여부다(스펙 §6). `--remove` 는 이 값과
110
114
  * 무관하게 항상 훅을 제거한다.
111
- * @param {{only?: string[], workers?: boolean, dryRun?: boolean, remove?: boolean, purge?: boolean, hooks?: boolean, env?: object, home: string}} opts
115
+ * `hooksOnly`(스펙 §4)는 MCP 등록·워커·허브를 건너뛰고 Stop 훅만 설치(`remove` 면 제거)한다.
116
+ * @param {{only?: string[], workers?: boolean, dryRun?: boolean, remove?: boolean, purge?: boolean, hooks?: boolean, hooksOnly?: boolean, env?: object, home: string}} opts
112
117
  */
113
118
  export async function runSetup({
114
119
  only,
@@ -117,11 +122,13 @@ export async function runSetup({
117
122
  remove = false,
118
123
  purge = false,
119
124
  hooks = true,
125
+ hooksOnly = false,
120
126
  env,
121
127
  home,
122
128
  }) {
123
129
  const e = env ?? makeEnv();
124
130
  const targets = resolveTargets(only);
131
+ if (hooksOnly) return runHooksOnly({ targets, e, dryRun, remove });
125
132
  if (remove) return runRemove({ targets, only, dryRun, purge, e, home });
126
133
  const enabledAgents = [];
127
134
  const { rows, failed } = walkClients({
@@ -207,6 +214,29 @@ export function purgeRefusal(home, e = {}) {
207
214
  return null;
208
215
  }
209
216
 
217
+ /**
218
+ * `setup --hooks-only` / `setup --remove --hooks-only`(스펙 §4.2). walkHooks 는 도구 감지 여부를
219
+ * walkClients 의 행(`installed`)에서 읽으므로, 등록을 건드리지 않고 detect 만 돌려 같은 모양의 행을 만든다.
220
+ * 허브는 띄우지도 세우지도 않는다 — 훅은 발동 시점에 connectIfLive 로 허브를 찾는다(D7).
221
+ */
222
+ function runHooksOnly({ targets, e, dryRun, remove }) {
223
+ const rows = targets.map((c) => ({
224
+ id: c.id,
225
+ label: c.label,
226
+ installed: Boolean(c.detect(e).installed),
227
+ result: "untouched",
228
+ }));
229
+ const hk = walkHooks({ targets, rows, e, dryRun, hooks: true, remove });
230
+ return {
231
+ mode: remove ? "hooks-only-remove" : "hooks-only",
232
+ rows: [],
233
+ hookRows: hk.hookRows,
234
+ failed: hk.failed,
235
+ workers: [],
236
+ workersDisabled: [],
237
+ };
238
+ }
239
+
210
240
  async function runRemove({ targets, only, dryRun, purge, e, home }) {
211
241
  const { rows, failed: rowsFailed } = walkClients({
212
242
  targets,
@@ -297,16 +327,38 @@ async function runRemove({ targets, only, dryRun, purge, e, home }) {
297
327
  return out;
298
328
  }
299
329
 
330
+ /** @param {object[]} hookRows @returns {string[]} */
331
+ function hookLines(hookRows) {
332
+ return (hookRows ?? []).map(
333
+ (row) =>
334
+ `hooks ${row.id.padEnd(12)} ${row.installed ? "installed " : "not installed"} ${row.result}`,
335
+ );
336
+ }
337
+ /** Codex 는 새 훅을 다음 세션에서 신뢰 승인해야 한다 — 새로 쓰였을 때만 안내한다. @param {object[]} hookRows */
338
+ function codexTrustHint(hookRows) {
339
+ return (hookRows ?? []).some(
340
+ (x) =>
341
+ x.id === "codex" && (x.result === "registered" || x.result === "updated"),
342
+ )
343
+ ? [
344
+ "hint: Codex asks to trust the new hook in its next session — approve it.",
345
+ ]
346
+ : [];
347
+ }
348
+
300
349
  /** @param {Awaited<ReturnType<typeof runSetup>>} r @returns {string[]} 사람이 읽는 표 */
301
350
  export function formatSetup(r, { workers = false } = {}) {
351
+ if (r.mode === "hooks-only" || r.mode === "hooks-only-remove")
352
+ return [
353
+ HOOKS_ONLY_NOTE,
354
+ ...hookLines(r.hookRows),
355
+ ...codexTrustHint(r.hookRows),
356
+ ];
302
357
  const lines = r.rows.map(
303
358
  (row) =>
304
359
  `${row.id.padEnd(16)} ${row.installed ? "installed " : "not installed"} ${row.result}`,
305
360
  );
306
- for (const row of r.hookRows ?? [])
307
- lines.push(
308
- `hooks ${row.id.padEnd(12)} ${row.installed ? "installed " : "not installed"} ${row.result}`,
309
- );
361
+ lines.push(...hookLines(r.hookRows));
310
362
  if (r.mode === "remove") {
311
363
  if (r.workersDisabled.length)
312
364
  lines.push(`workers disabled: ${r.workersDisabled.join(", ")}`);
@@ -336,15 +388,6 @@ export function formatSetup(r, { workers = false } = {}) {
336
388
  `hint: run \`pluriply worker enable <${cli.join("|")}>\` to let the hub run that tool headlessly (or re-run setup --workers)`,
337
389
  );
338
390
  }
339
- if (
340
- (r.hookRows ?? []).some(
341
- (x) =>
342
- x.id === "codex" &&
343
- (x.result === "registered" || x.result === "updated"),
344
- )
345
- )
346
- lines.push(
347
- "hint: Codex asks to trust the new hook in its next session — approve it.",
348
- );
391
+ lines.push(...codexTrustHint(r.hookRows));
349
392
  return lines;
350
393
  }
@@ -11,9 +11,13 @@ export function readLock(home) {
11
11
  if (!existsSync(lockPath)) return null;
12
12
  try {
13
13
  const doc = JSON.parse(readFileSync(lockPath, "utf8"));
14
- return Number.isInteger(doc?.pid) && Number.isInteger(doc?.port)
15
- ? doc
16
- : null;
14
+ if (!Number.isInteger(doc?.pid) || !Number.isInteger(doc?.port))
15
+ return null;
16
+ // Plan 4f: 연결 토큰. 구버전 허브의 락에는 없고, 문자열이 아니면 없는 것으로 본다.
17
+ const { token, ...rest } = doc;
18
+ return typeof token === "string" && token.length > 0
19
+ ? { ...rest, token }
20
+ : rest;
17
21
  } catch {
18
22
  return null;
19
23
  }
@@ -14,5 +14,6 @@ export const PACKAGE_VERSION = pkg.version;
14
14
  * 4: Plan 2e (agent.hello, 인스턴스 단위 peers, 행위자는 연결에서, dispatch pinned).
15
15
  * 5: Plan 3a (task.wait 보류 응답).
16
16
  * 6: Plan 3b (task.kind/review, task.complete의 review, task.list의 kind).
17
+ * 7: Plan 4f (연결 토큰 — ping 외 모든 요청은 Authorization: Bearer <token> 연결에서만 처리).
17
18
  */
18
- export const PROTOCOL_VERSION = 6;
19
+ export const PROTOCOL_VERSION = 7;