pluriply 0.2.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,7 +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.
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.
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.
35
38
 
36
39
  Restart your AI tools afterwards so they pick up the new MCP server.
37
40
 
@@ -55,11 +58,29 @@ npx pluriply worker enable codex # allow headless Codex workers
55
58
  npx pluriply worker list
56
59
  ```
57
60
 
61
+ ## Warm reception
62
+
63
+ With the Stop hook installed, a Claude Code, Codex or Antigravity CLI session
64
+ that is finishing a turn asks the hub whether anything arrived for it: tasks
65
+ sent to it, or results of tasks it delegated. If so, the session is asked to
66
+ handle them before it stops — no polling, no "check your tasks" from you.
67
+ Each item is announced once; the session reads details with `list_tasks` and
68
+ `get_task_result`. An idle session (waiting for your input) notices them at
69
+ the end of its next turn. Codex asks you to trust the new hook the first time
70
+ it runs. Antigravity's hook lives in `~/.gemini/config/hooks.json`.
71
+
58
72
  ## Where your data lives
59
73
 
60
74
  Everything stays on your machine under `~/.pluriply/` (channels, task history,
61
75
  results). There is no server and no account. Delete the folder to reset.
62
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
+
63
84
  ## License
64
85
 
65
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"];
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") {
@@ -165,9 +172,18 @@ if (cmd === "hub" && sub === "start") {
165
172
  const dryRun = rest.includes("--dry-run");
166
173
  const remove = rest.includes("--remove");
167
174
  const purge = rest.includes("--purge");
175
+ const hooks = !rest.includes("--no-hooks");
176
+ const hooksOnly = rest.includes("--hooks-only");
168
177
  if (remove && workers) usage("--remove cannot be combined with --workers");
169
178
  if (purge && !remove) usage("--purge requires --remove");
170
179
  if (purge && onlyArg) usage("--purge cannot be combined with --only");
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");
171
187
  try {
172
188
  const r = await runSetup({
173
189
  only: onlyArg
@@ -180,6 +196,8 @@ if (cmd === "hub" && sub === "start") {
180
196
  dryRun,
181
197
  remove,
182
198
  purge,
199
+ hooks,
200
+ hooksOnly,
183
201
  env: makeEnv({ binPath: BIN_PATH }),
184
202
  home: pluriplyHome(),
185
203
  });
@@ -215,6 +233,45 @@ if (cmd === "hub" && sub === "start") {
215
233
  console.log(`stale lockfile (pid ${lock.pid} not responding)`);
216
234
  }
217
235
  }
236
+ } else if (cmd === "hook" && sub === "stop") {
237
+ // Stop 훅(스펙 §3). 어떤 경우에도 JSON 한 줄 + exit 0. 늦게 열린 소켓이 프로세스를 잡아두지
238
+ // 않도록 출력 뒤 바로 종료한다.
239
+ const { runStopHook } = await import("../src/setup/hook-stop.js");
240
+ // 기존 flag()는 값이 없거나 모양이 이상하면(마지막 토큰, `--agent=`, 다음 플래그를
241
+ // 값으로 삼키려는 모양) exit(1)을 부르는데, 훅은 무슨 입력이 와도 항상 exit 0이어야
242
+ // 한다 — flag()를 쓰지 않고 여기서 직접 파싱해, 못 읽으면 그냥 undefined로 둔다
243
+ // (runStopHook이 알 수 없는/undefined agent를 {}로 처리한다).
244
+ let agent;
245
+ const ai = rest.findIndex((t) => t === "--agent" || t.startsWith("--agent="));
246
+ if (ai !== -1) {
247
+ if (rest[ai].startsWith("--agent=")) {
248
+ const v = rest[ai].slice("--agent=".length);
249
+ agent = v === "" ? undefined : v;
250
+ } else {
251
+ const v = rest[ai + 1];
252
+ agent = v === undefined || v.startsWith("--") ? undefined : v;
253
+ }
254
+ }
255
+ let input = "";
256
+ if (!process.stdin.isTTY) {
257
+ try {
258
+ for await (const chunk of process.stdin) input += chunk;
259
+ } catch {
260
+ input = "";
261
+ }
262
+ }
263
+ let out = {};
264
+ try {
265
+ out = await runStopHook({ agent, input, home: pluriplyHome() });
266
+ } catch {
267
+ out = {};
268
+ }
269
+ // 출력 뒤 바로 종료한다: 늦게 열린 소켓이 프로세스를 잡아두지 않게 한다. write()의
270
+ // 콜백을 기다려 스트림이 비동기 파이프인 플랫폼(Windows)에서 출력이 잘리지 않게 하고,
271
+ // 그 콜백이 오지 않는 경우를 대비해 폴백 타이머도 둔다(타이머 자체가 프로세스를
272
+ // 붙잡지 않도록 unref).
273
+ process.stdout.write(JSON.stringify(out) + "\n", () => process.exit(0));
274
+ setTimeout(() => process.exit(0), 500).unref?.();
218
275
  } else if (cmd === "connector") {
219
276
  const agent = flag("agent");
220
277
  if (!agent) {
@@ -229,7 +286,7 @@ if (cmd === "hub" && sub === "start") {
229
286
  await startConnector({ agent });
230
287
  } else {
231
288
  console.error(
232
- "usage: pluriply <setup [--workers] [--dry-run] [--only a,b]|setup --remove [--purge] [--dry-run] [--only a,b]|hub start|hub stop|hub restart|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>",
233
290
  );
234
291
  process.exit(1);
235
292
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pluriply",
3
- "version": "0.2.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 Ge}from"ws";import{mkdirSync as We,writeFileSync as qe,rmSync as xt,readFileSync as Fe}from"node:fs";import{join as Rt,isAbsolute as Ue}from"node:path";import{mkdirSync as Nt,readFileSync as at,writeFileSync as ct,renameSync as lt,existsSync as ut,readdirSync as K,rmSync as ht}from"node:fs";import{join as $}from"node:path";import{pluriplyHome as jt}from"../shared/paths.js";var B=/^plp-[a-z0-9]{4}-[a-z0-9]{4}$/,L=class{constructor(e=jt()){this.root=e,this.dir=$(e,"channels"),Nt(this.dir,{recursive:!0}),this.#t()}#t(){for(let e of K(this.dir))e.endsWith(".tmp")&&ht($(this.dir,e),{force:!0});for(let e of K(this.root))e.startsWith("agents.json.")&&e.endsWith(".tmp")&&ht($(this.root,e),{force:!0})}loadChannel(e){if(!B.test(e))throw new Error(`invalid channel code: ${e}`);let t=$(this.dir,`${e}.json`);return ut(t)?JSON.parse(at(t,"utf8")):null}saveChannel(e,t){if(!B.test(e))throw new Error(`invalid channel code: ${e}`);let n=$(this.dir,`${e}.json`),r=`${n}.${process.pid}.tmp`;ct(r,JSON.stringify(t,null,2)),lt(r,n)}listChannels(){return K(this.dir).filter(e=>e.endsWith(".json")).map(e=>e.slice(0,-5)).filter(e=>B.test(e))}loadAgents(){let e=$(this.root,"agents.json");if(!ut(e))return{};try{let t=JSON.parse(at(e,"utf8"));return t&&typeof t=="object"&&!Array.isArray(t)?t:{}}catch{return{}}}saveAgents(e){let t=$(this.root,"agents.json"),n=`${t}.${process.pid}.tmp`;ct(n,JSON.stringify(e,null,2)),lt(n,t)}};import{channelCode as Wt}from"../shared/ids.js";var D=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}},M=class{constructor(e){this.store=e}create(){let e={channel:{code:Wt(),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 a=this.get(e);this.#t(a,s,o);let c=o.toISOString(),l=a.channel.peers.find(u=>u.instanceId===t);return l?l.lastSeenAt=c:a.channel.peers.push({instanceId:t,tool:n,worker:!!r,joinedAt:c,lastSeenAt:c}),this.save(e,a),{channel:a.channel,peers:a.channel.peers}}peers(e,{online:t=new Set,now:n=new Date}={}){let r=this.get(e);return this.#t(r,t,n)&&this.save(e,r),r.channel.peers}#t(e,t,n){let r=e.channel.peers.length;return e.channel.peers=e.channel.peers.filter(s=>{if(!s.instanceId)return!1;if(t.has(s.instanceId))return!0;let o=n.getTime()-Date.parse(s.lastSeenAt);return o>=0&&o<=432e5}),e.channel.peers.length!==r}};import{statSync as qt,realpathSync as P}from"node:fs";import{sep as Ft,join as Ut,isAbsolute as Ht}from"node:path";import{taskId as Kt}from"../shared/ids.js";import{parseTarget as dt,toolOf as wt,isInstanceId as Bt}from"../shared/identity.js";var J=new Set(["completed","failed","cancelled"]),N=class extends Error{constructor(e){super(`task not found: ${e}`)}},R=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}"`)}},T=class extends Error{constructor(e){super(e)}},V=class extends Error{constructor(e){super(`delegation depth limit (${e}) exceeded`)}},Yt=["task","review"],ft=["approve","request_changes","comment"],mt=["critical","important","minor"],S=class extends Error{constructor(e){super(e)}},y=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}`)}},Jt=["auto","spawn","interactive"];function gt(i,e){return i===e||i.startsWith(e+Ft)}function zt(i,e){if(!i||typeof i!="object"||Array.isArray(i))throw new S("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 S("gitRange must be a single line of at most 200 characters");if(i.gitRange.startsWith("-"))throw new S("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 S("paths must be an array of strings");if(i.paths.length>0){if(e===void 0)throw new S("paths need a cwd to resolve against");let n=e;try{n=P(e)}catch{}t.paths=i.paths.map(r=>{let s=Ht(r)?r:Ut(e,r),o;try{o=P(s)}catch{throw new S(`path does not exist: ${r}`)}if(!gt(o,n))throw new S(`path outside cwd: ${r}`);return o})}}if(i.focus!==void 0){if(typeof i.focus!="string"||i.focus.length>500)throw new S("focus must be a string of at most 500 characters");i.focus.length>0&&(t.focus=i.focus)}return t}function Vt(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 Xt(i){if(!i||typeof i!="object"||Array.isArray(i))throw new y("review result must be an object");if(!ft.includes(i.verdict))throw new y(`verdict must be one of ${ft.join(", ")}`);if(typeof i.summary!="string"||i.summary.trim().length===0)throw new y("summary is required");let e=i.findings??[];if(!Array.isArray(e))throw new y("findings must be an array");if(e.length>200)throw new y("findings must have at most 200 items");let t=e.map((n,r)=>{if(!n||typeof n!="object"||Array.isArray(n))throw new y(`findings[${r}] must be an object`);if(!mt.includes(n.severity))throw new y(`findings[${r}].severity must be one of ${mt.join(", ")}`);if(typeof n.message!="string"||n.message.length===0)throw new y(`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 y(`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 y(`findings[${r}].line must be a positive integer`);s.line=n.line}if(n.suggestion!==void 0){if(typeof n.suggestion!="string")throw new y(`findings[${r}].suggestion must be a string`);s.suggestion=n.suggestion}return s});return{verdict:i.verdict,findings:t,summary:i.summary}}function pt(i,e){return i.toInstance?e===i.toInstance:wt(e)===(i.toTool??i.to)}function Qt(i,e){return i.from===e?!0:!i.from.includes("#")&&wt(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:a,origin:c,allowedRoots:l=[],mode:u="auto",maxDepth:h=2,online:w=new Set,kind:p="task",review:g}){if(typeof n!="string"||n.length===0)throw new T("target agent name is required");if(n.includes("#")&&!Bt(n))throw new T(`no peer "${n}" on this channel`);let f=dt(n);if(f.instance!==null&&f.instance===t)throw new T("cannot delegate a task to yourself");if((!Number.isInteger(o)||o<0)&&(o=0),o>h)throw new V(h);if(!Jt.includes(u))throw new Error(`invalid mode: ${u}`);if(a!==void 0){let k=!1;try{k=qt(a).isDirectory()}catch{k=!1}if(!k)throw new Z(a);let x=P(a),U=[];if(c!==void 0)try{U.push(P(c))}catch{}for(let H of l)try{U.push(P(H))}catch{}if(!U.some(H=>gt(x,H)))throw new tt(a);a=x}if(!Yt.includes(p))throw new Error(`invalid kind: ${p}`);let E;if(p==="review")E=zt(g??{},a??c),(typeof r!="string"||r.length===0)&&(r=Vt(E));else if(g!==void 0)throw new S('review is only valid for kind "review"');let b=this.registry.get(e),d=b.channel.peers,m=d.filter(k=>k.tool===f.tool),I;if(f.instance===null){if(I=m.length>0,!I){let k=d.find(x=>x.tool?.toLowerCase()===f.tool.toLowerCase());if(k)throw new T(`no peer named "${n}" on this channel; did you mean "${k.tool}"?`)}}else if(I=m.some(k=>k.instanceId===f.instance),!I&&m.length>0){let k=m.find(x=>w.has(x.instanceId))??m[0];throw new T(`no peer "${n}" on this channel; did you mean "${k.instanceId}"?`)}let v=new Date().toISOString(),A={taskId:Kt(),from:t,to:n,toTool:f.tool,request:r,attachments:s,depth:o,mode:u,kind:p,status:"submitted",result:null,createdAt:v,updatedAt:v};f.instance!==null&&(A.toInstance=f.instance),a!==void 0&&(A.cwd=a),p==="review"&&(A.review=E),b.tasks.push(A),this.registry.save(e,b);let O={task:A,targetJoined:I};return f.instance!==null&&(O.targetOnline=w.has(f.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 a=dt(t);o=o.filter(c=>a.instance===null?(c.toTool??c.to)===a.tool:c.toInstance===a.instance||!c.toInstance&&(c.toTool??c.to)===a.tool)}return n&&(o=o.filter(a=>a.from===n)),r&&(o=o.filter(a=>a.status===r)),s&&(o=o.filter(a=>(a.kind??"task")===s)),o}get(e,t){let n=this.registry.get(e).tasks.find(r=>r.taskId===t);if(!n)throw new N(t);return n}#t(e,t,n){let r=this.registry.get(e),s=r.tasks.find(a=>a.taskId===t);if(!s)throw new N(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(a=>{let c,l=this.waiters.get(o)??new Set;this.waiters.set(o,l);let u=w=>{clearTimeout(c),r?.removeEventListener("abort",h),l.delete(u),l.size===0&&this.waiters.get(o)===l&&this.waiters.delete(o),a(w)},h=()=>u(null);l.add(u),r?.addEventListener("abort",h,{once:!0}),c=setTimeout(()=>{let w=s;try{w=this.get(e,t)}catch{}u(w)},n)})}claim(e,t,n){return this.#t(e,t,r=>{if(!pt(r,n))throw new j(t,r.to,n);if(r.status!=="submitted")throw new R(r.status,"working");r.status="working"})}complete(e,t,{from:n,result:r,status:s="completed",worker:o=!1,review:a}){if(s!=="completed"&&s!=="failed")throw new R("?",s);return this.#t(e,t,c=>{if(!pt(c,n))throw new j(t,c.to,n);if(J.has(c.status))throw new R(c.status,s);let l=(c.kind??"task")==="review";if(!l&&a!==void 0)throw new Q(t);if(l&&s==="completed"){if(a===void 0)throw new X(t);r=Xt(a)}c.status=s,c.result=r,c.completedBy=n,o?c.completedByWorker=!0:delete c.completedByWorker})}cancel(e,t,{agent:n,reason:r}){return this.#t(e,t,s=>{if(!Qt(s,n))throw new z(t,s.from,n);if(s.status!=="submitted"&&s.status!=="working")throw new R(s.status,"cancelled");s.status="cancelled",s.result=r??null,s.cancelledBy=n})}setWorker(e,t,n){return this.#t(e,t,r=>{r.worker={...r.worker??{},...n}})}failIfOpen(e,t,{result:n,by:r}){return this.#t(e,t,s=>{J.has(s.status)||(s.status="failed",s.result=n,s.completedBy=r)})}};import{entryId as Zt}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:Zt(),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 Se}from"node:child_process";import{mkdirSync as bt,openSync as Ee,closeSync as ve,readFileSync as $e,appendFileSync as nt,readdirSync as Te,rmSync as be}from"node:fs";import{mkdir as xe,rm as rt}from"node:fs/promises";import{join as _}from"node:path";import{workerEnabled as Re,TEMPLATE_AGENTS as Ae}from"../shared/config.js";import{join as yt}from"node:path";import{fileURLToPath as te}from"node:url";import{agyCommand as ee}from"../shared/agy.js";import{DEFAULT_LIMITS as ne}from"../shared/config.js";var re=te(new URL("../../bin/pluriply.js",import.meta.url)),se=["acceptEdits","bypassPermissions"],ie=["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 oe(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:a=yt(r,s),permissionMode:c="acceptEdits",timeoutMs:l=ne.timeoutMs,readOnly:u=!1}){let h=oe(i,{taskId:s,channelCode:o,home:e,cwd:t,prompt:n,readOnly:u});if(h)return h;switch(i){case"codex":return{command:"codex",args:["exec","-C",t,"--skip-git-repo-check","-s",u?"read-only":"workspace-write","-c",'approval_policy="never"',"-o",yt(r,`${s}.last.md`),n]};case"claude-code":{if(!se.includes(c))throw new Error(`invalid permissionMode "${c}" for claude-code worker`);let w=JSON.stringify({mcpServers:{pluriply:{command:process.execPath,args:[re,"connector","--agent","claude-code"],env:{PLURIPLY_HOME:e}}}});return{command:"claude",args:["-p",...u?["--allowedTools",...ie,"--permission-mode","default","--add-dir",a]:["--permission-mode",c],"--mcp-config",w,"--strict-mcp-config","--output-format","json",n]}}case"antigravity":return{command:ee(),args:["-p",n,...u?["--mode","plan"]:[],"--dangerously-skip-permissions","--output-format","text","--print-timeout",`${Math.ceil(l/1e3)}s`]};default:return null}}import{pidAlive as Oe}from"../shared/probe.js";import{writeFile as me}from"node:fs/promises";import{execFile as ae}from"node:child_process";import{promisify as ce}from"node:util";var le=ce(ae),ue=[/^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$/],he=["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 de(){let i={...process.env};for(let e of he)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 fe(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 C(i,{cwd:e,env:t,maxBuffer:n=4*1024*1024,timeout:r=2e4}){try{let{stdout:s}=await le("git",i,{cwd:e,env:t,encoding:"utf8",maxBuffer:n,timeout:r,windowsHide:!0});return s}catch(s){throw new Error(fe(s.stderr)||s.message)}}async function kt(i){let e=de(),t=o=>C(["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 a of o.split("\0")){if(!a)continue;let c=a.split(`
5
- `,1)[0];if(s.has(c))continue;let l=c.toLowerCase();ue.some(u=>u.test(l))&&(s.add(c),r.push("-c",`${c}=`))}return{args:r,env:e}}var pe=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 C(r,{cwd:i,env:n.env,maxBuffer:pe,timeout:2e4})}catch(a){throw new Error(`${s} failed: ${a.message}`)}return await me(t,o),{file:t,bytes:Buffer.byteLength(o),target:s}}import{copyFile as we,lstat as ge,mkdir as St,rm as Et}from"node:fs/promises";import{dirname as ye,isAbsolute as Ie,join as vt}from"node:path";var ke=512*1024*1024,_e=16,$t=Object.freeze({recursive:!0,force:!0,maxRetries:5,retryDelay:100});async function Tt({repoDir:i,destDir:e,git:t,maxBytes:n=ke}){let s=(await C([...t.args,"ls-files","-z","-co","--exclude-standard"],{cwd:i,env:t.env,maxBuffer:67108864})).split("\0").filter(Boolean);await Et(e,$t),await St(e,{recursive:!0});let o=0,a=0,c=0,l=0,u=async()=>{for(;l<s.length;){let p=s[l++];if(Ie(p)||p.split("/").includes(".."))continue;let g=vt(i,p),f;try{f=await ge(g)}catch{continue}if(f.isSymbolicLink()){c++;continue}if(!f.isFile())continue;if(a+=f.size,a>n)throw new Error(`snapshot exceeds ${Math.floor(n/1024/1024)}MB`);let E=vt(e,p);await St(ye(E),{recursive:!0}),await we(g,E),o++}},w=(await Promise.allSettled(Array.from({length:Math.min(_e,s.length)},u))).find(p=>p.status==="rejected");if(w)throw await Et(e,$t),w.reason;return{files:o,bytes:a,skippedSymlinks:c}}var q=Object.freeze({recursive:!0,force:!0,maxRetries:5,retryDelay:100}),Pe=i=>`run \`pluriply worker enable ${i}\` to let the hub process this automatically`,Ce=i=>`no worker template is configured for "${i}"; enable it via config or set PLURIPLY_WORKER_TEMPLATE_OVERRIDE`,Le=new Set(["completed","failed","cancelled"]);function De(i){return Ae.includes(i)?!0:!!et()?.[i]}function Me(i){try{return $e(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
- `)}catch{return""}}function Ne({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 je({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 F=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(!Re(r,s))return{kind:"none",hint:Pe(s)};try{if(!De(s))return{kind:"none",hint:Ce(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)||Oe(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(_(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=_(this.home,"workers"),t;try{t=Te(e,{withFileTypes:!0})}catch{return}for(let n of t)if(!(!n.isDirectory()||this.#s(n.name)))try{be(_(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=_(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(_(this.home,"workers",n.taskId,"tree"),q).catch(()=>{}),this.#a(t)}async#l(e,t,n,r){let s=t.toTool??t.to,o=_(this.home,"workers");bt(o,{recursive:!0});let a=_(o,`${t.taskId}.log`),c=(t.kind??"task")==="review",l=_(o,t.taskId),u={agent:s,channelCode:e,taskId:t.taskId,from:t.from},h,w,p="";try{if(c){if(!t.cwd)throw new Error("review task has no cwd");await xe(l,{recursive:!0});let d=await kt(t.cwd);h=_(l,"tree");let m=await Tt({repoDir:t.cwd,destDir:h,git:d}),I=await _t({cwd:t.cwd,review:t.review??{},outFile:_(l,"review.diff"),git:d});p=`snapshot: ${m.files} files, ${m.bytes} bytes, ${m.skippedSymlinks} symlinks skipped
11
- `,w=je({...u,cwd:h,origin:t.cwd,diff:I}),await this.#o()}else h=t.cwd??_(this.home,"workspaces",t.taskId),bt(h,{recursive:!0}),w=Ne({...u,cwd:h})}catch(d){this.#n(r,s,t,`${c?"review preparation":"worker spawn"} failed: ${d.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 g;try{let d=It(s,{home:this.home,cwd:h,prompt:w,logDir:o,taskDir:l,taskId:t.taskId,channelCode:e,permissionMode:n.workers[s]?.permissionMode,timeoutMs:n.limits.timeoutMs,readOnly:c});p&&nt(a,p);let m=Ee(a,"a");try{g=Se(d.command,d.args,{cwd:h,shell:!1,stdio:["ignore",m,m],env:{...process.env,PLURIPLY_HOME:this.home,PLURIPLY_WORKER_TASK:t.taskId,PLURIPLY_WORKER_AGENT:s,PLURIPLY_DEPTH:String(t.depth??0)}})}finally{ve(m)}}catch(d){this.#n(r,s,t,`worker spawn failed: ${d.message}`);return}r.child=g;try{this.tasks.setWorker(e,t.taskId,{agent:s,pid:g.pid,startedAt:new Date().toISOString(),log:a})}catch(d){try{g.kill("SIGKILL")}catch{}this.#n(r,s,t,`worker spawn failed: ${d.message}`);return}let f=!1,E=setTimeout(()=>{f=!0,g.kill("SIGTERM"),setTimeout(()=>g.kill("SIGKILL"),5e3).unref()},n.limits.timeoutMs),b=(d,m)=>{clearTimeout(E),this.running.get(s)?.delete(r);let I={endedAt:new Date().toISOString(),exitCode:d};f&&(I.timedOut=!0);try{this.tasks.setWorker(e,t.taskId,I);let v;if(m){v=`worker failed to start: ${m.message}`;try{nt(a,`${v}
12
- `)}catch{}}else f?v=`worker timed out after ${n.limits.timeoutMs/1e3}s`:v=`worker exited without submitting a result (exit ${d})
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 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
+ `)}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
- ${Me(a)}`;this.tasks.failIfOpen(e,t.taskId,{result:v,by:`${s} worker`})}catch{}c&&rt(_(l,"tree"),q).catch(()=>{}),this.#a(s)};g.on("exit",(d,m)=>b(d??(m?-1:0))),g.on("error",d=>b(-1,d))}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(Le.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 He}from"../shared/config.js";import{pluriplyHome as Ke}from"../shared/paths.js";import{shortId as Be}from"../shared/ids.js";import{isValidAgentName as Ye,isInstanceId as Je,makeInstanceId as ze,toolOf as Ve,cwdKey as Xe}from"../shared/identity.js";import{PACKAGE_VERSION as At,PROTOCOL_VERSION as Ot}from"../shared/version.js";import{pingHub as Qe,pidAlive as Pt,homeId as Ct}from"../shared/probe.js";function st(i){try{let e=JSON.parse(Fe(i,"utf8"));return Number.isInteger(e?.pid)&&Number.isInteger(e?.port)?e:null}catch{return null}}var it=class{constructor({home:e=Ke(),port:t=0,verifyDelayMs:n=100}={}){this.home=e,this.requestedPort=t,this.verifyDelayMs=n;let r=new L(e);this.channels=new M(r),this.tasks=new G(this.channels),this.context=new W(this.channels),this.agents=new D(r),this.workers=new F({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(){We(this.home,{recursive:!0}),await new Promise((t,n)=>{this.wss=new Ge({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=Rt(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(!Pt(e.pid))return!1;let t=Date.now()+2e3;for(;;){let n=await Qe(e.port,300);if(n)return!n.home||n.home===Ct(this.home);if(Date.now()>=t||!Pt(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:At,protocol:Ot,startedAt:new Date().toISOString()},null,2);try{return qe(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=Rt(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=ze(e,Be(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:At,protocol:Ot,pid:process.pid,home:Ct(this.home)};case"channel.create":return{channelCode:this.channels.create().channel.code};case"agent.hello":{if(!Ye(t.tool))throw new Error(`invalid agent name: ${t.tool}`);if(typeof t.cwd!="string"||t.cwd.length===0||!Ue(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(!Je(s)||Ve(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=Xe(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=He(this.home),{task:o,targetJoined:a,targetOnline:c,warning:l}=this.tasks.create(t.channelCode,{...t,from:r.instanceId,depth:t.depth??0,mode:t.mode??"auto",maxDepth:s.limits.maxDepth,allowedRoots:s.allowedRoots,online:this.onlineInstances(t.channelCode)});this.agents.touch(r.cwdKey,t.channelCode);let u=this.workers.dispatch(t.channelCode,o,{interactive:this.isInteractive(t.channelCode,o.toTool),config:s}),h={taskId:o.taskId,targetJoined:a,dispatch:u.kind};return c!==void 0&&(h.targetOnline=c),l&&(h.warning=l),u.hint&&(h.hint=u.hint),h}case"task.list":return this.workers.reconcile(t.channelCode),{tasks:this.tasks.list(t.channelCode,{to:t.to,from:t.from,status:t.status,kind:t.kind})};case"task.get":return this.workers.reconcile(t.channelCode),{task:this.tasks.get(t.channelCode,t.taskId)};case"task.wait":{this.workers.reconcile(t.channelCode);let r=Math.min(Math.max(Number(t.timeoutMs)||0,1),15e3),s=this.connections.get(n)?.abort.signal;return this.tasks.waitFor(t.channelCode,t.taskId,r,{signal:s}).then(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 Ze}from"node:child_process";import{existsSync as tn,rmSync as ot}from"node:fs";import{join as en}from"node:path";import{fileURLToPath as nn}from"node:url";import{pingHub as Lt,homeId as rn,pidAlive as sn}from"../shared/probe.js";import{readLock as Dt}from"../shared/lock.js";var on=nn(new URL("../../bin/pluriply.js",import.meta.url));async function Mt(i){let e=Dt(i);if(!e)return null;let t=await Lt(e.port);return!t||t.home&&t.home!==rn(i)?null:{...t,port:e.port,lockPid:e.pid}}async function an({home:i,timeoutMs:e=5e3}){Ze(process.execPath,[on,"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 Mt(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 cn({home:i,timeoutMs:e=5e3}){let t=Dt(i),n=en(i,"hub.json");if(!t)return"not-running";let r=await Lt(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(!tn(n))return"stopped";if(!sn(t.pid))return ot(n,{force:!0}),"stopped";await new Promise(a=>setTimeout(a,100))}return"timeout"}import{readLock as Er}from"../shared/lock.js";import{loadConfig as $r,saveConfig as Tr,setWorkerEnabled as br,TEMPLATE_AGENTS as xr}from"../shared/config.js";export{it as Hub,xr as TEMPLATE_AGENTS,Mt as liveHub,$r as loadConfig,Er as readLock,Tr as saveConfig,br as setWorkerEnabled,an as spawnHub,cn 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};
@@ -8,6 +8,7 @@ import {
8
8
  realpathSync,
9
9
  statSync,
10
10
  chmodSync,
11
+ mkdirSync,
11
12
  } from "node:fs";
12
13
  import { join } from "node:path";
13
14
  import { homedir } from "node:os";
@@ -82,6 +83,7 @@ export function makeEnv(o = {}) {
82
83
  realpathSync,
83
84
  statSync,
84
85
  chmodSync,
86
+ mkdirSync,
85
87
  },
86
88
  ...o,
87
89
  };
@@ -243,7 +245,7 @@ function chmodQuiet(fs, path, mode) {
243
245
  }
244
246
  }
245
247
 
246
- function writeJsonAtomic(env, path, doc) {
248
+ export function writeJsonAtomic(env, path, doc) {
247
249
  writeFileAtomic(env, path, JSON.stringify(doc, null, 2) + "\n");
248
250
  }
249
251
 
@@ -0,0 +1,157 @@
1
+ // Stop 훅 명령의 본체(스펙 §3·§5). 도구가 턴을 끝낼 때 불려 허브에 "나에게 온 것"을 묻고,
2
+ // 있으면 {"decision":"block","reason":…} 으로 세션이 이어서 일하게 한다. 어떤 경우에도 {} 로
3
+ // 조용히 끝나야 한다 — 훅이 도구를 방해하면 안 된다.
4
+ // 공개 미러에도 실리므로 허브·커넥터는 쓰는 순간에만 동적으로 불러온다(경계 테스트).
5
+ import { pluriplyHome } from "../shared/paths.js";
6
+ import { resolveAgentName } from "../shared/identity.js";
7
+
8
+ export const HOOK_AGENTS = ["claude-code", "codex", "antigravity"];
9
+ const MAX_REASON = 2000;
10
+ const MORE_LINE = (n) => `(+${n} more: run list_tasks)`;
11
+
12
+ /**
13
+ * 허브 hook.poll 응답을 모델이 읽을 문구로 만든다. 순수 함수.
14
+ * @param {{tool: string, cwd: string, channelCode: string, incoming: object[], results: object[], more: number}} r
15
+ * @returns {string}
16
+ */
17
+ export function formatStopReason(r) {
18
+ const head = `pluriply: new activity on channel ${r.channelCode} for ${r.tool} (cwd ${r.cwd}). Handle it before finishing.`;
19
+ const inLines = (r.incoming ?? []).map(
20
+ (t) => `- ${t.taskId} ${t.kind ?? "task"} from ${t.from}: "${t.summary}"`,
21
+ );
22
+ const resLines = (r.results ?? []).map(
23
+ (t) => `- ${t.taskId} ${t.status} by ${t.to}: "${t.summary}"`,
24
+ );
25
+ let more = r.more ?? 0;
26
+ const build = () => {
27
+ const parts = [head];
28
+ if (inLines.length)
29
+ parts.push(
30
+ "Incoming tasks (do the work, then submit_result — or submit_review for reviews; skip one another instance already claimed):",
31
+ ...inLines,
32
+ );
33
+ if (resLines.length)
34
+ parts.push(
35
+ "Results of tasks you delegated (read them with get_task_result):",
36
+ ...resLines,
37
+ );
38
+ if (more > 0) parts.push(MORE_LINE(more));
39
+ return parts.join("\n");
40
+ };
41
+ let text = build();
42
+ // 2,000자를 넘으면 뒤 항목부터 덜어내고 more 를 늘린다
43
+ while (text.length > MAX_REASON && inLines.length + resLines.length > 0) {
44
+ if (resLines.length) resLines.pop();
45
+ else inLines.pop();
46
+ more++;
47
+ text = build();
48
+ }
49
+ return text;
50
+ }
51
+
52
+ /** @param {string} input @returns {object} 손상·빈 입력은 {} */
53
+ function parseInput(input) {
54
+ try {
55
+ const v = JSON.parse(input);
56
+ return v && typeof v === "object" && !Array.isArray(v) ? v : {};
57
+ } catch {
58
+ return {};
59
+ }
60
+ }
61
+
62
+ /**
63
+ * @param {{agent: string, input?: string, cwd?: string, home?: string, env?: NodeJS.ProcessEnv, connect?: () => Promise<object|null>, timeoutMs?: number}} o
64
+ * @returns {Promise<object>} stdout 에 찍을 객체
65
+ */
66
+ export async function runStopHook({
67
+ agent,
68
+ input = "",
69
+ cwd = process.cwd(),
70
+ home = pluriplyHome(),
71
+ env = process.env,
72
+ connect,
73
+ timeoutMs = 2000,
74
+ }) {
75
+ if (!HOOK_AGENTS.includes(agent)) return {};
76
+ // 워커 자식이 대화형 세션의 알림을 가로채면 안 된다(허브가 spawn 시 심는 표식, 스펙 §3).
77
+ if (env.PLURIPLY_WORKER_TASK) return {};
78
+ // Antigravity IDE 는 agy 훅과 같은 --agent antigravity 명령을 심으므로, IDE가 스폰한
79
+ // 프로세스는 antigravity-ide 전용 어댑터에게 맡기고 여기서는 조용히 빠진다.
80
+ if (resolveAgentName(agent, env) !== agent) return {};
81
+ const data = parseInput(input);
82
+ // stop_hook_active 는 Claude Code/Codex 만 보낸다 — antigravity 페이로드엔 없어 자연히 무시된다.
83
+ if (data.stop_hook_active === true) return {};
84
+ // Claude Code 는 세션 도중 cd 로 옮겨 다녀도 프로젝트 루트를 CLAUDE_PROJECT_DIR 로 계속
85
+ // 알려준다(stdin의 cwd 는 그 순간의 작업 폴더라 어긋날 수 있다) — 실기기 확인, 2026-09-16.
86
+ // antigravity 는 cwd 대신 workspacePaths 배열의 첫 항목을 보낸다.
87
+ const at =
88
+ agent === "claude-code" &&
89
+ typeof env.CLAUDE_PROJECT_DIR === "string" &&
90
+ env.CLAUDE_PROJECT_DIR.length > 0
91
+ ? env.CLAUDE_PROJECT_DIR
92
+ : agent === "antigravity"
93
+ ? (Array.isArray(data.workspacePaths) &&
94
+ typeof data.workspacePaths[0] === "string" &&
95
+ data.workspacePaths[0]) ||
96
+ cwd
97
+ : typeof data.cwd === "string" && data.cwd.length > 0
98
+ ? data.cwd
99
+ : cwd;
100
+ const doConnect =
101
+ connect ??
102
+ (async () => {
103
+ const { connectIfLive } = await import("../connector/hub-client.js");
104
+ return connectIfLive({ home });
105
+ });
106
+ let client = null;
107
+ let timer;
108
+ let settled = false;
109
+ const deadline = new Promise((resolve) => {
110
+ timer = setTimeout(() => {
111
+ settled = true;
112
+ resolve(null);
113
+ }, timeoutMs);
114
+ });
115
+ try {
116
+ const work = (async () => {
117
+ client = await doConnect();
118
+ if (!client) return null;
119
+ // 데드라인이 이미 지난 뒤에야 연결됐다: 아무도 기다리지 않는 요청을 보내는 대신
120
+ // 바로 닫는다(연결이 새고 늦은 응답·거부가 붕 뜨는 것을 막는다).
121
+ if (settled) {
122
+ try {
123
+ client.close();
124
+ } catch {
125
+ // 닫기 실패는 무시
126
+ }
127
+ return null;
128
+ }
129
+ return client.request(
130
+ "hook.poll",
131
+ { tool: agent, cwd: at },
132
+ { timeoutMs },
133
+ );
134
+ })();
135
+ work.catch(() => {}); // 위 race 가 이미 끝난 뒤의 거부가 미처리로 남지 않게
136
+ const r = await Promise.race([work, deadline]);
137
+ if (!r || !r.channelCode) return {};
138
+ if ((r.incoming?.length ?? 0) + (r.results?.length ?? 0) === 0) return {};
139
+ const reason = formatStopReason({ ...r, cwd: at });
140
+ // antigravity 는 {decision:"continue"} 라야 멈추지 않고 reason 을 주입한다(실기기 확인).
141
+ // 다른 도구는 그대로 block.
142
+ return agent === "antigravity"
143
+ ? { decision: "continue", reason }
144
+ : { decision: "block", reason };
145
+ } catch (err) {
146
+ if (process.env.PLURIPLY_HOOK_DEBUG === "1")
147
+ process.stderr.write(`pluriply hook: ${err.message}\n`);
148
+ return {};
149
+ } finally {
150
+ clearTimeout(timer);
151
+ try {
152
+ client?.close();
153
+ } catch {
154
+ // 닫기 실패는 무시
155
+ }
156
+ }
157
+ }
@@ -0,0 +1,233 @@
1
+ // Stop 훅 설치·제거 어댑터(스펙 §6). 대상 파일의 hooks.Stop[] 에 우리 그룹 하나를 병합하고,
2
+ // 다른 그룹(사용자·다른 도구의 훅)은 순서까지 보존한다. 쓰기는 clients.js 의 백업·원자 쓰기.
3
+ import { join, dirname } from "node:path";
4
+ import { writeJsonAtomic } from "./clients.js";
5
+
6
+ export const HOOK_TIMEOUT_SEC = 10;
7
+
8
+ /**
9
+ * `layout` 이 없으면(claude-code·codex) 스펙 §3 의 stop-groups 모양(`hooks.Stop[].hooks[]`)을 쓴다.
10
+ * `layout: "named"`(antigravity) 는 이름 키 아래 **평평한** 항목 배열을 쓴다 — 실기기 확인(2026-09-16).
11
+ * @type {Array<{id: string, label: string, file: (env: object) => string, layout?: "named"}>}
12
+ */
13
+ export const HOOK_CLIENTS = [
14
+ {
15
+ id: "claude-code",
16
+ label: "Claude Code hooks",
17
+ file: (env) => join(env.homeDir, ".claude", "settings.json"),
18
+ },
19
+ {
20
+ id: "codex",
21
+ label: "Codex hooks",
22
+ file: (env) =>
23
+ join(
24
+ env.processEnv?.CODEX_HOME || join(env.homeDir, ".codex"),
25
+ "hooks.json",
26
+ ),
27
+ },
28
+ {
29
+ id: "antigravity",
30
+ label: "Antigravity hooks",
31
+ file: (env) => join(env.homeDir, ".gemini", "config", "hooks.json"),
32
+ layout: "named",
33
+ },
34
+ ];
35
+
36
+ /** named 레이아웃에서 우리 항목을 담는 최상위 키 */
37
+ const NAMED_KEY = "pluriply";
38
+
39
+ /** @param {object} env @param {string} agent @returns {string} */
40
+ export function hookCommand(env, agent) {
41
+ return `"${env.node}" "${env.binPath}" hook stop --agent ${agent}`;
42
+ }
43
+
44
+ /**
45
+ * command 문자열에서 우리가 심은 `--agent` 값을 뽑는다. 끝에 정확히 고정한다 — `.includes`
46
+ * 였다면 `--agent antigravity`가 `--agent antigravity-ide`의 접두사라 서로를 자기 것으로
47
+ * 착각하거나(더 긴 이름), 뒤에 다른 인자가 붙은 남의 명령까지 우리 것으로 삼켰다.
48
+ * @param {unknown} cmd @returns {string|undefined}
49
+ */
50
+ function agentOf(cmd) {
51
+ return typeof cmd === "string"
52
+ ? /pluriply\.js" hook stop --agent (\S+)\s*$/.exec(cmd)?.[1]
53
+ : undefined;
54
+ }
55
+
56
+ /** 우리 그룹인지(stop-groups): 훅 중 하나의 --agent 값이 정확히 agent 다 */
57
+ function isOurs(group, agent) {
58
+ return (
59
+ Array.isArray(group?.hooks) &&
60
+ group.hooks.some((h) => agentOf(h?.command) === agent)
61
+ );
62
+ }
63
+
64
+ /** 우리 항목인지(named 레이아웃의 평평한 항목) */
65
+ function isOursFlat(item, agent) {
66
+ return agentOf(item?.command) === agent;
67
+ }
68
+
69
+ function ourGroup(env, agent) {
70
+ return {
71
+ hooks: [
72
+ {
73
+ type: "command",
74
+ command: hookCommand(env, agent),
75
+ timeout: HOOK_TIMEOUT_SEC,
76
+ },
77
+ ],
78
+ };
79
+ }
80
+
81
+ /** named 레이아웃에 쓰는 평평한 항목 하나 */
82
+ function ourFlatItem(env, agent) {
83
+ return {
84
+ type: "command",
85
+ command: hookCommand(env, agent),
86
+ timeout: HOOK_TIMEOUT_SEC,
87
+ };
88
+ }
89
+
90
+ /**
91
+ * 문서에서 우리 Stop 항목을 찾는다. 레이아웃(stop-groups/named)에 따라 다른 모양을 읽되
92
+ * hookStatus 는 이 공통 반환값만 보면 되게 한다.
93
+ * @param {object} doc @param {object} hc @param {object} env
94
+ * @returns {{stopArr: object[], idx: number, mine: object|null, matches: boolean}}
95
+ */
96
+ function readOurs(doc, hc, env) {
97
+ const want = hookCommand(env, hc.id);
98
+ if (hc.layout === "named") {
99
+ const stopArr = Array.isArray(doc[NAMED_KEY]?.Stop)
100
+ ? doc[NAMED_KEY].Stop
101
+ : [];
102
+ const idx = stopArr.findIndex((h) => isOursFlat(h, hc.id));
103
+ const mine = idx === -1 ? null : stopArr[idx];
104
+ const matches =
105
+ mine != null &&
106
+ mine.type === "command" &&
107
+ mine.command === want &&
108
+ mine.timeout === HOOK_TIMEOUT_SEC;
109
+ return { stopArr, idx, mine, matches };
110
+ }
111
+ const stopArr = Array.isArray(doc.hooks?.Stop) ? doc.hooks.Stop : [];
112
+ const idx = stopArr.findIndex((g) => isOurs(g, hc.id));
113
+ const mine = idx === -1 ? null : stopArr[idx];
114
+ const matches =
115
+ mine != null &&
116
+ mine.hooks.length === 1 &&
117
+ mine.hooks[0].type === "command" &&
118
+ mine.hooks[0].command === want &&
119
+ mine.hooks[0].timeout === HOOK_TIMEOUT_SEC;
120
+ return { stopArr, idx, mine, matches };
121
+ }
122
+
123
+ /**
124
+ * 우리 항목을 심은/갈아 끼운 새 문서를 돌려준다(설치 전용). named 레이아웃은 `doc.pluriply` 를
125
+ * 통째로 새로 쓴다(우리만 쓰는 키라 병합할 다른 항목이 없다) — 다른 최상위 키는 그대로 둔다.
126
+ * stop-groups 는 기존처럼 그룹 하나만 심거나 갈아 끼우고 다른 그룹·다른 훅 이벤트는 보존한다.
127
+ * @param {object} doc @param {object} hc @param {object} env
128
+ * @returns {object}
129
+ */
130
+ function writeOurs(doc, hc, env) {
131
+ if (hc.layout === "named") {
132
+ return { ...doc, [NAMED_KEY]: { Stop: [ourFlatItem(env, hc.id)] } };
133
+ }
134
+ const hooks =
135
+ doc.hooks && typeof doc.hooks === "object" && !Array.isArray(doc.hooks)
136
+ ? doc.hooks
137
+ : {};
138
+ const stopArr = Array.isArray(hooks.Stop) ? hooks.Stop : [];
139
+ const idx = stopArr.findIndex((g) => isOurs(g, hc.id));
140
+ const group = ourGroup(env, hc.id);
141
+ const nextStop =
142
+ idx === -1
143
+ ? [...stopArr, group]
144
+ : stopArr.map((g, j) => (j === idx ? group : g));
145
+ return { ...doc, hooks: { ...hooks, Stop: nextStop } };
146
+ }
147
+
148
+ /** @returns {object} 파일 없음·빈 파일은 {} ; JSON 이 아니거나 객체가 아니면 throw */
149
+ function readDoc(env, path) {
150
+ if (!env.fs.existsSync(path)) return {};
151
+ const raw = env.fs.readFileSync(path, "utf8");
152
+ if (raw.trim() === "") return {};
153
+ const doc = JSON.parse(raw);
154
+ if (!doc || typeof doc !== "object" || Array.isArray(doc))
155
+ throw new Error("hooks file root is not an object");
156
+ return doc;
157
+ }
158
+
159
+ /** @returns {"present"|"stale"|"missing"|{error: string}} */
160
+ export function hookStatus(env, hc) {
161
+ const path = hc.file(env);
162
+ let doc;
163
+ try {
164
+ doc = readDoc(env, path);
165
+ } catch (err) {
166
+ return { error: `${path}: ${err.message}` };
167
+ }
168
+ const { mine, matches } = readOurs(doc, hc, env);
169
+ if (!mine) return "missing";
170
+ return matches ? "present" : "stale";
171
+ }
172
+
173
+ /** @returns {"present"|"updated"|"registered"|"failed"} */
174
+ export function installHook(env, hc) {
175
+ const path = hc.file(env);
176
+ const st = hookStatus(env, hc);
177
+ if (st === "present") return "present";
178
+ if (typeof st === "object") {
179
+ env.log(`cannot update ${hc.label}: ${st.error}`);
180
+ if (hc.layout === "named") {
181
+ env.log(
182
+ `add this to ${NAMED_KEY}.Stop in ${path} yourself: ${JSON.stringify({ Stop: [ourFlatItem(env, hc.id)] })}`,
183
+ );
184
+ } else {
185
+ env.log(
186
+ `add this to hooks.Stop in ${path} yourself: ${JSON.stringify(ourGroup(env, hc.id))}`,
187
+ );
188
+ }
189
+ return "failed";
190
+ }
191
+ const doc = readDoc(env, path);
192
+ const next = writeOurs(doc, hc, env);
193
+ env.fs.mkdirSync?.(dirname(path), { recursive: true });
194
+ writeJsonAtomic(env, path, next);
195
+ env.log(
196
+ `${st === "missing" ? "installed" : "updated"} pluriply Stop hook in ${hc.label} (${path}, backup ${path}.bak)`,
197
+ );
198
+ return st === "missing" ? "registered" : "updated";
199
+ }
200
+
201
+ /** @returns {"removed"|"absent"|"failed"} */
202
+ export function removeHook(env, hc) {
203
+ const path = hc.file(env);
204
+ if (!env.fs.existsSync(path)) return "absent";
205
+ let doc;
206
+ try {
207
+ doc = readDoc(env, path);
208
+ } catch (err) {
209
+ env.log(`cannot update ${hc.label}: ${path}: ${err.message}`);
210
+ return "failed";
211
+ }
212
+ if (hc.layout === "named") {
213
+ if (!(NAMED_KEY in doc)) return "absent";
214
+ const next = { ...doc };
215
+ delete next[NAMED_KEY];
216
+ writeJsonAtomic(env, path, next);
217
+ env.log(
218
+ `removed pluriply Stop hook from ${hc.label} (${path}, backup ${path}.bak)`,
219
+ );
220
+ return "removed";
221
+ }
222
+ const stop = Array.isArray(doc.hooks?.Stop) ? doc.hooks.Stop : [];
223
+ const rest = stop.filter((g) => !isOurs(g, hc.id));
224
+ if (rest.length === stop.length) return "absent";
225
+ const hooks = { ...doc.hooks };
226
+ if (rest.length) hooks.Stop = rest;
227
+ else delete hooks.Stop;
228
+ writeJsonAtomic(env, path, { ...doc, hooks });
229
+ env.log(
230
+ `removed pluriply Stop hook from ${hc.label} (${path}, backup ${path}.bak)`,
231
+ );
232
+ return "removed";
233
+ }
@@ -2,6 +2,7 @@ import { existsSync, rmSync, realpathSync, lstatSync } from "node:fs";
2
2
  import { join, dirname, resolve, sep } from "node:path";
3
3
  import { homedir } from "node:os";
4
4
  import { CLIENTS, makeEnv, isSkipped } from "./clients.js";
5
+ import { HOOK_CLIENTS, hookStatus, installHook, removeHook } from "./hooks.js";
5
6
  import { setWorkerEnabled, TEMPLATE_AGENTS } from "../shared/config.js";
6
7
  import { readLock } from "../shared/lock.js";
7
8
  // 허브(stopHub)와 커넥터(ensureHub → ws)는 쓰는 순간에만 불러온다. 공개 미러에는 src/hub 가
@@ -13,6 +14,10 @@ const hubClient = () => import("../connector/hub-client.js");
13
14
  export const REMOVE_NOTE =
14
15
  "note: close or restart open client sessions; their connectors may restart the hub";
15
16
 
17
+ /** `--hooks-only` 실행의 첫 줄(스펙 §4.3). MCP 표가 비어 있는 이유를 알려 준다. */
18
+ export const HOOKS_ONLY_NOTE =
19
+ "hooks only — MCP registration, workers and hub untouched";
20
+
16
21
  /** @param {string[]|undefined} only */
17
22
  function resolveTargets(only) {
18
23
  if (!only) return CLIENTS;
@@ -71,13 +76,44 @@ function walkClients({ targets, e, dryRun, act, dryRunResult, collect }) {
71
76
  return { rows, failed };
72
77
  }
73
78
 
79
+ /**
80
+ * MCP 등록 표(`rows`)를 바탕으로 훅 행을 만든다(스펙 §6). 도구가 감지되지 않았으면 not installed,
81
+ * `hooks:false` 면 skipped, dry-run 은 상태만 본다. 실패는 failed 로 세고 진행한다.
82
+ * @returns {{hookRows: object[], failed: number}}
83
+ */
84
+ function walkHooks({ targets, rows, e, dryRun, hooks, remove }) {
85
+ const hookRows = [];
86
+ let failed = 0;
87
+ for (const hc of HOOK_CLIENTS) {
88
+ if (!targets.some((c) => c.id === hc.id)) continue;
89
+ const row = rows.find((x) => x.id === hc.id);
90
+ const installed = Boolean(row?.installed);
91
+ let result;
92
+ if (!installed) result = "not installed";
93
+ else if (!hooks) result = "skipped";
94
+ else if (dryRun) {
95
+ const st = hookStatus(e, hc);
96
+ if (typeof st === "object") result = `failed: ${st.error}`;
97
+ else if (remove) result = st === "missing" ? "absent" : "planned";
98
+ else result = st === "present" ? "present" : "planned";
99
+ } else result = remove ? removeHook(e, hc) : installHook(e, hc);
100
+ if (result === "failed") result = "failed: see hint above";
101
+ if (result.startsWith("failed")) failed++;
102
+ hookRows.push({ id: hc.id, label: hc.label, installed, result });
103
+ }
104
+ return { hookRows, failed };
105
+ }
106
+
74
107
  /**
75
108
  * `pluriply setup`: 설치된 클라이언트를 감지해 pluriply 커넥터를 멱등 등록한다.
76
109
  * `remove` 면 반대로 등록을 풀고 워커 설정·허브·(purge 시) 데이터까지 정리한다.
77
110
  * `--purge` 는 pluriply 홈이 심볼릭 링크면 **링크만 끊고** 링크가 가리키는 디렉터리는 남긴다
78
111
  * (그 안의 내용까지 지우려면 실제 경로를 직접 지워야 한다).
79
112
  * env.stopHub / env.rm / env.lstat 은 테스트가 주입한다.
80
- * @param {{only?: string[], workers?: boolean, dryRun?: boolean, remove?: boolean, purge?: boolean, env?: object, home: string}} opts
113
+ * `hooks`(기본 true)는 Claude Code·Codex 의 Stop 훅 등록 여부다(스펙 §6). `--remove` 는 이 값과
114
+ * 무관하게 항상 훅을 제거한다.
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
81
117
  */
82
118
  export async function runSetup({
83
119
  only,
@@ -85,11 +121,14 @@ export async function runSetup({
85
121
  dryRun = false,
86
122
  remove = false,
87
123
  purge = false,
124
+ hooks = true,
125
+ hooksOnly = false,
88
126
  env,
89
127
  home,
90
128
  }) {
91
129
  const e = env ?? makeEnv();
92
130
  const targets = resolveTargets(only);
131
+ if (hooksOnly) return runHooksOnly({ targets, e, dryRun, remove });
93
132
  if (remove) return runRemove({ targets, only, dryRun, purge, e, home });
94
133
  const enabledAgents = [];
95
134
  const { rows, failed } = walkClients({
@@ -103,11 +142,13 @@ export async function runSetup({
103
142
  enabledAgents.push(c.agent);
104
143
  },
105
144
  });
145
+ const hk = walkHooks({ targets, rows, e, dryRun, hooks, remove: false });
106
146
  if (workers && !dryRun && enabledAgents.length > 0)
107
147
  setWorkerEnabled(home, enabledAgents, true);
108
148
  const out = {
109
149
  rows,
110
- failed,
150
+ failed: failed + hk.failed,
151
+ hookRows: hk.hookRows,
111
152
  workers: workers && !dryRun ? enabledAgents : [],
112
153
  };
113
154
  if (!dryRun) {
@@ -173,6 +214,29 @@ export function purgeRefusal(home, e = {}) {
173
214
  return null;
174
215
  }
175
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
+
176
240
  async function runRemove({ targets, only, dryRun, purge, e, home }) {
177
241
  const { rows, failed: rowsFailed } = walkClients({
178
242
  targets,
@@ -184,10 +248,13 @@ async function runRemove({ targets, only, dryRun, purge, e, home }) {
184
248
  // 여기서 따로 모을 게 없다.
185
249
  collect: () => {},
186
250
  });
251
+ // --remove 는 --no-hooks 와 무관하게 항상 훅을 제거한다.
252
+ const hk = walkHooks({ targets, rows, e, dryRun, hooks: true, remove: true });
187
253
  const out = {
188
254
  mode: "remove",
189
255
  rows,
190
- failed: rowsFailed,
256
+ failed: rowsFailed + hk.failed,
257
+ hookRows: hk.hookRows,
191
258
  workers: [],
192
259
  workersDisabled: [],
193
260
  };
@@ -260,12 +327,38 @@ async function runRemove({ targets, only, dryRun, purge, e, home }) {
260
327
  return out;
261
328
  }
262
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
+
263
349
  /** @param {Awaited<ReturnType<typeof runSetup>>} r @returns {string[]} 사람이 읽는 표 */
264
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
+ ];
265
357
  const lines = r.rows.map(
266
358
  (row) =>
267
359
  `${row.id.padEnd(16)} ${row.installed ? "installed " : "not installed"} ${row.result}`,
268
360
  );
361
+ lines.push(...hookLines(r.hookRows));
269
362
  if (r.mode === "remove") {
270
363
  if (r.workersDisabled.length)
271
364
  lines.push(`workers disabled: ${r.workersDisabled.join(", ")}`);
@@ -295,5 +388,6 @@ export function formatSetup(r, { workers = false } = {}) {
295
388
  `hint: run \`pluriply worker enable <${cli.join("|")}>\` to let the hub run that tool headlessly (or re-run setup --workers)`,
296
389
  );
297
390
  }
391
+ lines.push(...codexTrustHint(r.hookRows));
298
392
  return lines;
299
393
  }
@@ -1,4 +1,5 @@
1
1
  import { createHash } from "node:crypto";
2
+ import { realpathSync } from "node:fs";
2
3
  import { resolve } from "node:path";
3
4
 
4
5
  /** ids.js의 shortId 알파벳(i, l, o, 0, 1 제외)과 같은 문자 집합 */
@@ -16,7 +17,8 @@ export function isValidAgentName(name) {
16
17
  * @param {string} agent @param {NodeJS.ProcessEnv} env @returns {string}
17
18
  */
18
19
  export function resolveAgentName(agent, env) {
19
- if (agent === "antigravity" && env.ANTIGRAVITY_EDITOR_APP_ROOT) return "antigravity-ide";
20
+ if (agent === "antigravity" && env.ANTIGRAVITY_EDITOR_APP_ROOT)
21
+ return "antigravity-ide";
20
22
  return agent;
21
23
  }
22
24
 
@@ -50,6 +52,15 @@ export function toolOf(id) {
50
52
  * @param {string} tool @param {string} cwd @returns {string} `<tool>@<sha256 앞 8자>`
51
53
  */
52
54
  export function cwdKey(tool, cwd) {
53
- const hash = createHash("sha256").update(resolve(cwd)).digest("hex");
55
+ // 심볼릭 링크로 인한 별칭(예: macOS /var → /private/var, cwd 별칭 디렉터리)을
56
+ // 같은 키로 묶는다. 존재하지 않는 경로는 realpath 가 실패하므로 resolve 결과를 쓴다
57
+ // (커넥터가 아직 만들어지지 않은 폴더로 뜨는 드문 경우까지 키를 안정적으로 유지).
58
+ let p = resolve(cwd);
59
+ try {
60
+ p = realpathSync.native(p);
61
+ } catch {
62
+ // 그대로 둔다
63
+ }
64
+ const hash = createHash("sha256").update(p).digest("hex");
54
65
  return `${tool}@${hash.slice(0, 8)}`;
55
66
  }
@@ -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;