pluriply 0.2.0 → 0.3.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 +13 -0
- package/bin/pluriply.js +44 -2
- package/package.json +1 -1
- package/src/hub/index.js +10 -10
- package/src/setup/clients.js +3 -1
- package/src/setup/hook-stop.js +157 -0
- package/src/setup/hooks.js +233 -0
- package/src/setup/run-setup.js +54 -3
- package/src/shared/identity.js +13 -2
package/README.md
CHANGED
|
@@ -32,6 +32,8 @@ Pluriply MCP connector with each of them (idempotent — run it again any time).
|
|
|
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
34
|
- Codex and Antigravity get a 600 s MCP tool timeout written into their config at registration (their default is 60 s, too short for `ask_agent`/`request_review` waits). If you registered with an earlier version, run `setup --remove` then `setup` again to pick it up.
|
|
35
|
+
- `setup` also installs a Stop hook for Claude Code, Codex and Antigravity CLI so a live session notices new tasks and finished results at the end of its turn (see _Warm reception_). `--no-hooks` skips it; `setup --remove` takes it out again.
|
|
36
|
+
- Re-run `setup` after upgrading or cleaning the npx cache — the hook command embeds the installed path.
|
|
35
37
|
|
|
36
38
|
Restart your AI tools afterwards so they pick up the new MCP server.
|
|
37
39
|
|
|
@@ -55,6 +57,17 @@ npx pluriply worker enable codex # allow headless Codex workers
|
|
|
55
57
|
npx pluriply worker list
|
|
56
58
|
```
|
|
57
59
|
|
|
60
|
+
## Warm reception
|
|
61
|
+
|
|
62
|
+
With the Stop hook installed, a Claude Code, Codex or Antigravity CLI session
|
|
63
|
+
that is finishing a turn asks the hub whether anything arrived for it: tasks
|
|
64
|
+
sent to it, or results of tasks it delegated. If so, the session is asked to
|
|
65
|
+
handle them before it stops — no polling, no "check your tasks" from you.
|
|
66
|
+
Each item is announced once; the session reads details with `list_tasks` and
|
|
67
|
+
`get_task_result`. An idle session (waiting for your input) notices them at
|
|
68
|
+
the end of its next turn. Codex asks you to trust the new hook the first time
|
|
69
|
+
it runs. Antigravity's hook lives in `~/.gemini/config/hooks.json`.
|
|
70
|
+
|
|
58
71
|
## Where your data lives
|
|
59
72
|
|
|
60
73
|
Everything stays on your machine under `~/.pluriply/` (channels, task history,
|
package/bin/pluriply.js
CHANGED
|
@@ -44,7 +44,7 @@ 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 = ["workers", "dry-run", "remove", "purge", "no-hooks"];
|
|
48
48
|
const SETUP_VALUE_FLAGS = ["only"];
|
|
49
49
|
|
|
50
50
|
if (cmd === "hub" && sub === "start") {
|
|
@@ -165,9 +165,11 @@ if (cmd === "hub" && sub === "start") {
|
|
|
165
165
|
const dryRun = rest.includes("--dry-run");
|
|
166
166
|
const remove = rest.includes("--remove");
|
|
167
167
|
const purge = rest.includes("--purge");
|
|
168
|
+
const hooks = !rest.includes("--no-hooks");
|
|
168
169
|
if (remove && workers) usage("--remove cannot be combined with --workers");
|
|
169
170
|
if (purge && !remove) usage("--purge requires --remove");
|
|
170
171
|
if (purge && onlyArg) usage("--purge cannot be combined with --only");
|
|
172
|
+
if (remove && !hooks) usage("--no-hooks has no effect with --remove");
|
|
171
173
|
try {
|
|
172
174
|
const r = await runSetup({
|
|
173
175
|
only: onlyArg
|
|
@@ -180,6 +182,7 @@ if (cmd === "hub" && sub === "start") {
|
|
|
180
182
|
dryRun,
|
|
181
183
|
remove,
|
|
182
184
|
purge,
|
|
185
|
+
hooks,
|
|
183
186
|
env: makeEnv({ binPath: BIN_PATH }),
|
|
184
187
|
home: pluriplyHome(),
|
|
185
188
|
});
|
|
@@ -215,6 +218,45 @@ if (cmd === "hub" && sub === "start") {
|
|
|
215
218
|
console.log(`stale lockfile (pid ${lock.pid} not responding)`);
|
|
216
219
|
}
|
|
217
220
|
}
|
|
221
|
+
} else if (cmd === "hook" && sub === "stop") {
|
|
222
|
+
// Stop 훅(스펙 §3). 어떤 경우에도 JSON 한 줄 + exit 0. 늦게 열린 소켓이 프로세스를 잡아두지
|
|
223
|
+
// 않도록 출력 뒤 바로 종료한다.
|
|
224
|
+
const { runStopHook } = await import("../src/setup/hook-stop.js");
|
|
225
|
+
// 기존 flag()는 값이 없거나 모양이 이상하면(마지막 토큰, `--agent=`, 다음 플래그를
|
|
226
|
+
// 값으로 삼키려는 모양) exit(1)을 부르는데, 훅은 무슨 입력이 와도 항상 exit 0이어야
|
|
227
|
+
// 한다 — flag()를 쓰지 않고 여기서 직접 파싱해, 못 읽으면 그냥 undefined로 둔다
|
|
228
|
+
// (runStopHook이 알 수 없는/undefined agent를 {}로 처리한다).
|
|
229
|
+
let agent;
|
|
230
|
+
const ai = rest.findIndex((t) => t === "--agent" || t.startsWith("--agent="));
|
|
231
|
+
if (ai !== -1) {
|
|
232
|
+
if (rest[ai].startsWith("--agent=")) {
|
|
233
|
+
const v = rest[ai].slice("--agent=".length);
|
|
234
|
+
agent = v === "" ? undefined : v;
|
|
235
|
+
} else {
|
|
236
|
+
const v = rest[ai + 1];
|
|
237
|
+
agent = v === undefined || v.startsWith("--") ? undefined : v;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
let input = "";
|
|
241
|
+
if (!process.stdin.isTTY) {
|
|
242
|
+
try {
|
|
243
|
+
for await (const chunk of process.stdin) input += chunk;
|
|
244
|
+
} catch {
|
|
245
|
+
input = "";
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
let out = {};
|
|
249
|
+
try {
|
|
250
|
+
out = await runStopHook({ agent, input, home: pluriplyHome() });
|
|
251
|
+
} catch {
|
|
252
|
+
out = {};
|
|
253
|
+
}
|
|
254
|
+
// 출력 뒤 바로 종료한다: 늦게 열린 소켓이 프로세스를 잡아두지 않게 한다. write()의
|
|
255
|
+
// 콜백을 기다려 스트림이 비동기 파이프인 플랫폼(Windows)에서 출력이 잘리지 않게 하고,
|
|
256
|
+
// 그 콜백이 오지 않는 경우를 대비해 폴백 타이머도 둔다(타이머 자체가 프로세스를
|
|
257
|
+
// 붙잡지 않도록 unref).
|
|
258
|
+
process.stdout.write(JSON.stringify(out) + "\n", () => process.exit(0));
|
|
259
|
+
setTimeout(() => process.exit(0), 500).unref?.();
|
|
218
260
|
} else if (cmd === "connector") {
|
|
219
261
|
const agent = flag("agent");
|
|
220
262
|
if (!agent) {
|
|
@@ -229,7 +271,7 @@ if (cmd === "hub" && sub === "start") {
|
|
|
229
271
|
await startConnector({ agent });
|
|
230
272
|
} else {
|
|
231
273
|
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>",
|
|
274
|
+
"usage: pluriply <setup [--workers] [--dry-run] [--only a,b] [--no-hooks]|setup --remove [--purge] [--dry-run] [--only a,b]|hub start|hub stop|hub restart|hook stop --agent <claude-code|codex|antigravity>|connector --agent <name>|status|worker enable|disable <codex|claude-code|antigravity>|worker list>",
|
|
233
275
|
);
|
|
234
276
|
process.exit(1);
|
|
235
277
|
}
|
package/package.json
CHANGED
package/src/hub/index.js
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
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
|
|
5
|
-
`,1)[0];if(s.has(
|
|
3
|
+
import{WebSocketServer as He}from"ws";import{mkdirSync as Fe,writeFileSync as Ue,rmSync as xt,readFileSync as Be}from"node:fs";import{join as At,isAbsolute as Ot}from"node:path";import{mkdirSync as qt,readFileSync as ct,writeFileSync as lt,renameSync as ut,existsSync as ht,readdirSync as U,rmSync as dt}from"node:fs";import{join as T}from"node:path";import{pluriplyHome as Kt}from"../shared/paths.js";var B=/^plp-[a-z0-9]{4}-[a-z0-9]{4}$/,D=class{constructor(e=Kt()){this.root=e,this.dir=T(e,"channels"),qt(this.dir,{recursive:!0}),this.#t()}#t(){for(let e of U(this.dir))e.endsWith(".tmp")&&dt(T(this.dir,e),{force:!0});for(let e of U(this.root))e.startsWith("agents.json.")&&e.endsWith(".tmp")&&dt(T(this.root,e),{force:!0})}loadChannel(e){if(!B.test(e))throw new Error(`invalid channel code: ${e}`);let t=T(this.dir,`${e}.json`);return ht(t)?JSON.parse(ct(t,"utf8")):null}saveChannel(e,t){if(!B.test(e))throw new Error(`invalid channel code: ${e}`);let n=T(this.dir,`${e}.json`),r=`${n}.${process.pid}.tmp`;lt(r,JSON.stringify(t,null,2)),ut(r,n)}listChannels(){return U(this.dir).filter(e=>e.endsWith(".json")).map(e=>e.slice(0,-5)).filter(e=>B.test(e))}loadAgents(){let e=T(this.root,"agents.json");if(!ht(e))return{};try{let t=JSON.parse(ct(e,"utf8"));return t&&typeof t=="object"&&!Array.isArray(t)?t:{}}catch{return{}}}saveAgents(e){let t=T(this.root,"agents.json"),n=`${t}.${process.pid}.tmp`;lt(n,JSON.stringify(e,null,2)),ut(n,t)}};import{channelCode as Ft}from"../shared/ids.js";var M=class{constructor(e){this.store=e}touch(e,t,n=new Date){try{let r=this.store.loadAgents();r[e]={lastChannel:t,lastSeenAt:n.toISOString()},this.store.saveAgents(r)}catch{}}resume(e,t=new Date){let n=this.store.loadAgents()[e];if(!n)return null;let r=t.getTime()-Date.parse(n.lastSeenAt);if(!(r>=0&&r<=432e5))return null;try{if(!this.store.loadChannel(n.lastChannel))return null}catch{return null}return n.lastChannel}};var Y=class extends Error{constructor(e){super(`channel not found: ${e}`),this.code=e}},N=class{constructor(e){this.store=e}create(){let e={channel:{code:Ft(),createdAt:new Date().toISOString(),peers:[]},tasks:[],context:[]};return this.store.saveChannel(e.channel.code,e),e}get(e){let t=this.store.loadChannel(e);if(!t)throw new Y(e);return t}save(e,t){this.store.saveChannel(e,t)}join(e,{instanceId:t,tool:n,worker:r=!1},{online:s=new Set,now:o=new Date}={}){let c=this.get(e);this.#t(c,s,o);let l=o.toISOString(),u=c.channel.peers.find(h=>h.instanceId===t);return u?u.lastSeenAt=l:c.channel.peers.push({instanceId:t,tool:n,worker:!!r,joinedAt:l,lastSeenAt:l}),this.save(e,c),{channel:c.channel,peers:c.channel.peers}}peers(e,{online:t=new Set,now:n=new Date}={}){let r=this.get(e);return this.#t(r,t,n)&&this.save(e,r),r.channel.peers}#t(e,t,n){let r=e.channel.peers.length;return e.channel.peers=e.channel.peers.filter(s=>{if(!s.instanceId)return!1;if(t.has(s.instanceId))return!0;let o=n.getTime()-Date.parse(s.lastSeenAt);return o>=0&&o<=432e5}),e.channel.peers.length!==r}};import{statSync as Ut,realpathSync as P}from"node:fs";import{sep as Bt,join as Yt,isAbsolute as Jt}from"node:path";import{taskId as zt}from"../shared/ids.js";import{parseTarget as ft,toolOf as gt,isInstanceId as Vt}from"../shared/identity.js";var J=new Set(["completed","failed","cancelled"]),C=class extends Error{constructor(e){super(`task not found: ${e}`)}},A=class extends Error{constructor(e,t){super(`invalid task transition: ${e} -> ${t}`)}},j=class extends Error{constructor(e,t,n){super(`task ${e} is addressed to "${t}", not "${n}"`)}},z=class extends Error{constructor(e,t,n){super(`only the sender "${t}" can cancel task ${e}, not "${n}"`)}},b=class extends Error{constructor(e){super(e)}},V=class extends Error{constructor(e){super(`delegation depth limit (${e}) exceeded`)}},Xt=["task","review"],mt=["approve","request_changes","comment"],pt=["critical","important","minor"],$=class extends Error{constructor(e){super(e)}},_=class extends Error{constructor(e){super(e)}},X=class extends Error{constructor(e){super(`use submit_review for review tasks (task ${e})`)}},Q=class extends Error{constructor(e){super(`task ${e} is not a review`)}},Z=class extends Error{constructor(e){super(`cwd is not an existing directory: ${e}`)}},tt=class extends Error{constructor(e){super(`cwd outside allowed roots: ${e}`)}},Qt=["auto","spawn","interactive"];function yt(i,e){return i===e||i.startsWith(e+Bt)}function Zt(i,e){if(!i||typeof i!="object"||Array.isArray(i))throw new $("review must be an object");let t={};if(i.gitRange!==void 0){if(typeof i.gitRange!="string"||i.gitRange.length===0||i.gitRange.length>200||/[\r\n]/.test(i.gitRange))throw new $("gitRange must be a single line of at most 200 characters");if(i.gitRange.startsWith("-"))throw new $("gitRange must not start with '-'");t.gitRange=i.gitRange}if(i.paths!==void 0){if(!Array.isArray(i.paths)||i.paths.some(n=>typeof n!="string"||n.length===0))throw new $("paths must be an array of strings");if(i.paths.length>0){if(e===void 0)throw new $("paths need a cwd to resolve against");let n=e;try{n=P(e)}catch{}t.paths=i.paths.map(r=>{let s=Jt(r)?r:Yt(e,r),o;try{o=P(s)}catch{throw new $(`path does not exist: ${r}`)}if(!yt(o,n))throw new $(`path outside cwd: ${r}`);return o})}}if(i.focus!==void 0){if(typeof i.focus!="string"||i.focus.length>500)throw new $("focus must be a string of at most 500 characters");i.focus.length>0&&(t.focus=i.focus)}return t}function te(i){return`Review ${i.gitRange?`git range ${i.gitRange}`:i.paths?.length?`files ${i.paths.join(", ")}`:"the uncommitted changes (git diff HEAD)"}${i.focus?`, focusing on ${i.focus}`:""}.`}function ee(i){if(!i||typeof i!="object"||Array.isArray(i))throw new _("review result must be an object");if(!mt.includes(i.verdict))throw new _(`verdict must be one of ${mt.join(", ")}`);if(typeof i.summary!="string"||i.summary.trim().length===0)throw new _("summary is required");let e=i.findings??[];if(!Array.isArray(e))throw new _("findings must be an array");if(e.length>200)throw new _("findings must have at most 200 items");let t=e.map((n,r)=>{if(!n||typeof n!="object"||Array.isArray(n))throw new _(`findings[${r}] must be an object`);if(!pt.includes(n.severity))throw new _(`findings[${r}].severity must be one of ${pt.join(", ")}`);if(typeof n.message!="string"||n.message.length===0)throw new _(`findings[${r}].message is required`);let s={severity:n.severity,message:n.message};if(n.file!==void 0){if(typeof n.file!="string")throw new _(`findings[${r}].file must be a string`);s.file=n.file}if(n.line!==void 0){if(!Number.isInteger(n.line)||n.line<1)throw new _(`findings[${r}].line must be a positive integer`);s.line=n.line}if(n.suggestion!==void 0){if(typeof n.suggestion!="string")throw new _(`findings[${r}].suggestion must be a string`);s.suggestion=n.suggestion}return s});return{verdict:i.verdict,findings:t,summary:i.summary}}function wt(i,e){return i.toInstance?e===i.toInstance:gt(e)===(i.toTool??i.to)}function ne(i,e){return i.from===e?!0:!i.from.includes("#")&>(e)===i.from}var G=class{constructor(e){this.registry=e,this.waiters=new Map}create(e,{from:t,to:n,request:r,attachments:s=[],depth:o=0,cwd:c,origin:l,allowedRoots:u=[],mode:h="auto",maxDepth:d=2,online:p=new Set,kind:w="task",review:y,fromCwdKey:k}){if(typeof n!="string"||n.length===0)throw new b("target agent name is required");if(n.includes("#")&&!Vt(n))throw new b(`no peer "${n}" on this channel`);let g=ft(n);if(g.instance!==null&&g.instance===t)throw new b("cannot delegate a task to yourself");if((!Number.isInteger(o)||o<0)&&(o=0),o>d)throw new V(d);if(!Qt.includes(h))throw new Error(`invalid mode: ${h}`);if(c!==void 0){let S=!1;try{S=Ut(c).isDirectory()}catch{S=!1}if(!S)throw new Z(c);let x=P(c),H=[];if(l!==void 0)try{H.push(P(l))}catch{}for(let F of u)try{H.push(P(F))}catch{}if(!H.some(F=>yt(x,F)))throw new tt(c);c=x}if(!Xt.includes(w))throw new Error(`invalid kind: ${w}`);let v;if(w==="review")v=Zt(y??{},c??l),(typeof r!="string"||r.length===0)&&(r=te(v));else if(y!==void 0)throw new $('review is only valid for kind "review"');let f=this.registry.get(e),a=f.channel.peers,m=a.filter(S=>S.tool===g.tool),I;if(g.instance===null){if(I=m.length>0,!I){let S=a.find(x=>x.tool?.toLowerCase()===g.tool.toLowerCase());if(S)throw new b(`no peer named "${n}" on this channel; did you mean "${S.tool}"?`)}}else if(I=m.some(S=>S.instanceId===g.instance),!I&&m.length>0){let S=m.find(x=>p.has(x.instanceId))??m[0];throw new b(`no peer "${n}" on this channel; did you mean "${S.instanceId}"?`)}let at=new Date().toISOString(),R={taskId:zt(),from:t,to:n,toTool:g.tool,request:r,attachments:s,depth:o,mode:h,kind:w,status:"submitted",result:null,createdAt:at,updatedAt:at};g.instance!==null&&(R.toInstance=g.instance),c!==void 0&&(R.cwd=c),typeof k=="string"&&k.length>0&&(R.fromCwdKey=k),w==="review"&&(R.review=v),f.tasks.push(R),this.registry.save(e,f);let O={task:R,targetJoined:I};return g.instance!==null&&(O.targetOnline=p.has(g.instance),I&&!O.targetOnline&&(O.warning=`"${n}" is not online; the task will wait until it reconnects.`)),I||(O.warning=`"${n}" has not joined this channel yet; the task will wait until it joins.`),O}list(e,{to:t,from:n,status:r,kind:s}={}){let o=this.registry.get(e).tasks;if(t){let c=ft(t);o=o.filter(l=>c.instance===null?(l.toTool??l.to)===c.tool:l.toInstance===c.instance||!l.toInstance&&(l.toTool??l.to)===c.tool)}return n&&(o=o.filter(c=>c.from===n)),r&&(o=o.filter(c=>c.status===r)),s&&(o=o.filter(c=>(c.kind??"task")===s)),o}get(e,t){let n=this.registry.get(e).tasks.find(r=>r.taskId===t);if(!n)throw new C(t);return n}#t(e,t,n){let r=this.registry.get(e),s=r.tasks.find(c=>c.taskId===t);if(!s)throw new C(t);let o=s.status;return n(s),s.updatedAt=new Date().toISOString(),this.registry.save(e,r),s.status!==o&&this.#r(e,t,s),s}#r(e,t,n){let r=this.waiters.get(`${e}/${t}`);if(r){this.waiters.delete(`${e}/${t}`);for(let s of r)s(n)}}waitFor(e,t,n,{signal:r}={}){let s=this.get(e,t);if(J.has(s.status))return Promise.resolve(s);if(r?.aborted)return Promise.resolve(null);let o=`${e}/${t}`;return new Promise(c=>{let l,u=this.waiters.get(o)??new Set;this.waiters.set(o,u);let h=p=>{clearTimeout(l),r?.removeEventListener("abort",d),u.delete(h),u.size===0&&this.waiters.get(o)===u&&this.waiters.delete(o),c(p)},d=()=>h(null);u.add(h),r?.addEventListener("abort",d,{once:!0}),l=setTimeout(()=>{let p=s;try{p=this.get(e,t)}catch{}h(p)},n)})}claim(e,t,n){return this.#t(e,t,r=>{if(!wt(r,n))throw new j(t,r.to,n);if(r.status!=="submitted")throw new A(r.status,"working");r.status="working"})}complete(e,t,{from:n,result:r,status:s="completed",worker:o=!1,review:c}){if(s!=="completed"&&s!=="failed")throw new A("?",s);return this.#t(e,t,l=>{if(!wt(l,n))throw new j(t,l.to,n);if(J.has(l.status))throw new A(l.status,s);let u=(l.kind??"task")==="review";if(!u&&c!==void 0)throw new Q(t);if(u&&s==="completed"){if(c===void 0)throw new X(t);r=ee(c)}l.status=s,l.result=r,l.completedBy=n,o?l.completedByWorker=!0:delete l.completedByWorker})}cancel(e,t,{agent:n,reason:r}){return this.#t(e,t,s=>{if(!ne(s,n))throw new z(t,s.from,n);if(s.status!=="submitted"&&s.status!=="working")throw new A(s.status,"cancelled");s.status="cancelled",s.result=r??null,s.cancelledBy=n})}markHookDelivered(e,t,n,r=new Date){let s=this.registry.get(e),o=s.tasks.find(c=>c.taskId===t);if(!o)throw new C(t);return o.hookDelivered={...o.hookDelivered??{},[n]:r.toISOString()},this.registry.save(e,s),o}setWorker(e,t,n){return this.#t(e,t,r=>{r.worker={...r.worker??{},...n}})}failIfOpen(e,t,{result:n,by:r}){return this.#t(e,t,s=>{J.has(s.status)||(s.status="failed",s.result=n,s.completedBy=r)})}};import{entryId as re}from"../shared/ids.js";var W=class{constructor(e){this.registry=e}add(e,{from:t,summary:n,artifacts:r=[]}){let s=this.registry.get(e),o={entryId:re(),from:t,summary:n,artifacts:r,at:new Date().toISOString()};return s.context.push(o),this.registry.save(e,s),o}list(e,{limit:t}={}){let n=this.registry.get(e).context;return Number.isInteger(t)&&t>0?n.slice(-t):n}};import{spawn as Te}from"node:child_process";import{mkdirSync as Rt,openSync as be,closeSync as Re,readFileSync as xe,appendFileSync as nt,readdirSync as Ae,rmSync as Oe}from"node:fs";import{mkdir as Pe,rm as rt}from"node:fs/promises";import{join as E}from"node:path";import{workerEnabled as Ce,TEMPLATE_AGENTS as Le}from"../shared/config.js";import{join as kt}from"node:path";import{fileURLToPath as se}from"node:url";import{agyCommand as ie}from"../shared/agy.js";import{DEFAULT_LIMITS as oe}from"../shared/config.js";var ae=se(new URL("../../bin/pluriply.js",import.meta.url)),ce=["acceptEdits","bypassPermissions"],le=["mcp__pluriply__join_channel","mcp__pluriply__channel_status","mcp__pluriply__list_peers","mcp__pluriply__list_tasks","mcp__pluriply__get_task_result","mcp__pluriply__submit_review","mcp__pluriply__submit_result","mcp__pluriply__share_update","mcp__pluriply__get_channel_context"];function et(){if(process.env.PLURIPLY_ALLOW_TEMPLATE_OVERRIDE!=="1")return null;let i=process.env.PLURIPLY_WORKER_TEMPLATE_OVERRIDE;if(!i)return null;try{return JSON.parse(i)}catch(e){throw new Error(`PLURIPLY_WORKER_TEMPLATE_OVERRIDE is not valid JSON: ${e.message}`)}}function ue(i,e){let t=et()?.[i];if(!t)return;let n=r=>r.replace(/\{(taskId|channelCode|home|cwd|prompt|readOnly)\}/g,(s,o)=>String(e[o]));return{command:t.command,args:t.args.map(n)}}function It(i,{home:e,cwd:t,prompt:n,logDir:r,taskId:s,channelCode:o,taskDir:c=kt(r,s),permissionMode:l="acceptEdits",timeoutMs:u=oe.timeoutMs,readOnly:h=!1}){let d=ue(i,{taskId:s,channelCode:o,home:e,cwd:t,prompt:n,readOnly:h});if(d)return d;switch(i){case"codex":return{command:"codex",args:["exec","-C",t,"--skip-git-repo-check","-s",h?"read-only":"workspace-write","-c",'approval_policy="never"',"-o",kt(r,`${s}.last.md`),n]};case"claude-code":{if(!ce.includes(l))throw new Error(`invalid permissionMode "${l}" for claude-code worker`);let p=JSON.stringify({mcpServers:{pluriply:{command:process.execPath,args:[ae,"connector","--agent","claude-code"],env:{PLURIPLY_HOME:e}}}});return{command:"claude",args:["-p",...h?["--allowedTools",...le,"--permission-mode","default","--add-dir",c]:["--permission-mode",l],"--mcp-config",p,"--strict-mcp-config","--output-format","json",n]}}case"antigravity":return{command:ie(),args:["-p",n,...h?["--mode","plan"]:[],"--dangerously-skip-permissions","--output-format","text","--print-timeout",`${Math.ceil(u/1e3)}s`]};default:return null}}import{pidAlive as De}from"../shared/probe.js";import{writeFile as ye}from"node:fs/promises";import{execFile as he}from"node:child_process";import{promisify as de}from"node:util";var fe=de(he),me=[/^filter\..+\.(clean|smudge|process|required)$/,/^diff\..+\.(command|textconv)$/,/^merge\..+\.driver$/,/^core\.(hookspath|fsmonitor|sshcommand|pager|editor|askpass|gitproxy)$/,/^credential\.(.+\.)?helper$/,/^alias\..+$/,/^sequence\.editor$/,/^gpg\.(.+\.)?program$/],pe=["GIT_DIR","GIT_WORK_TREE","GIT_INDEX_FILE","GIT_CONFIG_PARAMETERS","GIT_CONFIG_COUNT","GIT_EXTERNAL_DIFF","GIT_SSH","GIT_SSH_COMMAND","GIT_ASKPASS","GIT_EDITOR","GIT_PAGER"];function we(){let i={...process.env};for(let e of pe)delete i[e];return i.GIT_CONFIG_GLOBAL=process.platform==="win32"?"NUL":"/dev/null",i.GIT_CONFIG_NOSYSTEM="1",i.GIT_ATTR_NOSYSTEM="1",i.GIT_TERMINAL_PROMPT="0",i}function ge(i){if(!i)return"";let e=String(i).trim().split(`
|
|
4
|
+
`).map(t=>t.trim()).filter(Boolean);return e.length===0?"":e.find(t=>/^(fatal|error):/i.test(t))??e.at(-1)}async function L(i,{cwd:e,env:t,maxBuffer:n=4*1024*1024,timeout:r=2e4}){try{let{stdout:s}=await fe("git",i,{cwd:e,env:t,encoding:"utf8",maxBuffer:n,timeout:r,windowsHide:!0});return s}catch(s){throw new Error(ge(s.stderr)||s.message)}}async function _t(i){let e=we(),t=o=>L(["config","--list",o,"--includes","-z"],{cwd:i,env:e,timeout:1e4}),n=[await t("--local")];try{n.push(await t("--worktree"))}catch(o){if(!/cannot be used with multiple working trees|unable to read config file/i.test(o.message))throw o}let r=["-c","core.fsmonitor=false"],s=new Set(["core.fsmonitor"]);for(let o of n)for(let c of o.split("\0")){if(!c)continue;let l=c.split(`
|
|
5
|
+
`,1)[0];if(s.has(l))continue;let u=l.toLowerCase();me.some(h=>h.test(u))&&(s.add(l),r.push("-c",`${l}=`))}return{args:r,env:e}}var ke=20*1024*1024;async function St({cwd:i,review:e,outFile:t,git:n}){if(e.gitRange?.startsWith("-"))throw new Error('gitRange must not start with "-"');let r=[...n.args,"diff","--no-color","--no-ext-diff","--no-textconv"],s;e.gitRange?(r.push(e.gitRange,"--"),e.paths?.length&&r.push(...e.paths),s=`git diff ${e.gitRange}`):e.paths?.length?(r.push("HEAD","--",...e.paths),s=`git diff HEAD -- ${e.paths.join(" ")}`):(r.push("HEAD"),s="git diff HEAD");let o;try{o=await L(r,{cwd:i,env:n.env,maxBuffer:ke,timeout:2e4})}catch(c){throw new Error(`${s} failed: ${c.message}`)}return await ye(t,o),{file:t,bytes:Buffer.byteLength(o),target:s}}import{copyFile as Ie,lstat as _e,mkdir as Et,rm as vt}from"node:fs/promises";import{dirname as Se,isAbsolute as Ee,join as $t}from"node:path";var ve=512*1024*1024,$e=16,Tt=Object.freeze({recursive:!0,force:!0,maxRetries:5,retryDelay:100});async function bt({repoDir:i,destDir:e,git:t,maxBytes:n=ve}){let s=(await L([...t.args,"ls-files","-z","-co","--exclude-standard"],{cwd:i,env:t.env,maxBuffer:67108864})).split("\0").filter(Boolean);await vt(e,Tt),await Et(e,{recursive:!0});let o=0,c=0,l=0,u=0,h=async()=>{for(;u<s.length;){let w=s[u++];if(Ee(w)||w.split("/").includes(".."))continue;let y=$t(i,w),k;try{k=await _e(y)}catch{continue}if(k.isSymbolicLink()){l++;continue}if(!k.isFile())continue;if(c+=k.size,c>n)throw new Error(`snapshot exceeds ${Math.floor(n/1024/1024)}MB`);let g=$t(e,w);await Et(Se(g),{recursive:!0}),await Ie(y,g),o++}},p=(await Promise.allSettled(Array.from({length:Math.min($e,s.length)},h))).find(w=>w.status==="rejected");if(p)throw await vt(e,Tt),p.reason;return{files:o,bytes:c,skippedSymlinks:l}}var q=Object.freeze({recursive:!0,force:!0,maxRetries:5,retryDelay:100}),Me=i=>`run \`pluriply worker enable ${i}\` to let the hub process this automatically`,Ne=i=>`no worker template is configured for "${i}"; enable it via config or set PLURIPLY_WORKER_TEMPLATE_OVERRIDE`,je=new Set(["completed","failed","cancelled"]);function Ge(i){return Le.includes(i)?!0:!!et()?.[i]}function We(i){try{return xe(i,"utf8").trimEnd().split(`
|
|
6
6
|
`).slice(-20).join(`
|
|
7
|
-
`)}catch{return""}}function
|
|
8
|
-
`)}function
|
|
9
|
-
`)}var
|
|
10
|
-
`)}catch{}try{this.tasks.failIfOpen(e.code,n.taskId,{result:r,by:`${t} worker`})}catch{}rt(
|
|
11
|
-
`,
|
|
12
|
-
`)}catch{}}else
|
|
7
|
+
`)}catch{return""}}function qe({agent:i,channelCode:e,taskId:t,cwd:n,from:r}){return[`\uB2F9\uC2E0\uC740 Pluriply \uCC44\uB110 ${e}\uC5D0\uC11C \uC704\uC784\uBC1B\uC740 \uC791\uC5C5\uC744 \uCC98\uB9AC\uD558\uB294 ${i} \uC6CC\uCEE4\uC785\uB2C8\uB2E4.`,`\uC694\uCCAD\uC790\uB294 ${r} \uC785\uB2C8\uB2E4. join_channel \uC751\uB2F5\uC758 me \uAC00 \uB2F9\uC2E0\uC758 \uC778\uC2A4\uD134\uC2A4 ID\uC785\uB2C8\uB2E4.`,`1. pluriply MCP \uB3C4\uAD6C join_channel \uB85C \uCC44\uB110 ${e} \uC5D0 \uCC38\uC5EC\uD558\uC138\uC694.`,`2. get_task_result \uB85C \uD0DC\uC2A4\uD06C ${t} \uB97C \uC77D\uC73C\uC138\uC694. \uC694\uCCAD \uBCF8\uBB38\uACFC \uCCA8\uBD80 \uACBD\uB85C\uAC00 \uC788\uC2B5\uB2C8\uB2E4.`,`3. \uC791\uC5C5\uC744 \uC218\uD589\uD558\uC138\uC694. \uC791\uC5C5 \uD3F4\uB354\uB294 ${n} \uC785\uB2C8\uB2E4. \uADF8 \uBC16\uC758 \uD30C\uC77C\uC740 \uC218\uC815\uD558\uC9C0 \uB9C8\uC138\uC694.`,"4. \uC911\uAC04\uC5D0 share_update \uB85C \uC9C4\uD589 \uC0C1\uD669\uC744 \uD55C \uBC88 \uC774\uC0C1 \uB0A8\uAE30\uC138\uC694.","5. \uB05D\uB098\uBA74 submit_result \uB85C \uACB0\uACFC\uB97C \uC81C\uCD9C\uD558\uC138\uC694. \uD560 \uC218 \uC5C6\uC73C\uBA74 failed: true \uB85C \uC774\uC720\uB97C \uC81C\uCD9C\uD558\uC138\uC694.","6. \uB2E4\uB978 \uB3C4\uAD6C\uC5D0 \uC704\uC784\uC774 \uAF2D \uD544\uC694\uD560 \uB54C\uB9CC send_task \uB97C \uC4F0\uC138\uC694. \uC704\uC784 \uAE4A\uC774 \uC81C\uD55C\uC774 \uC788\uC2B5\uB2C8\uB2E4."].join(`
|
|
8
|
+
`)}function Ke({agent:i,channelCode:e,taskId:t,cwd:n,origin:r,from:s,diff:o}){return[`\uB2F9\uC2E0\uC740 Pluriply \uCC44\uB110 ${e}\uC5D0\uC11C \uCF54\uB4DC \uB9AC\uBDF0\uB97C \uC704\uC784\uBC1B\uC740 ${i} \uB9AC\uBDF0\uC5B4\uC785\uB2C8\uB2E4.`,`\uC694\uCCAD\uC790\uB294 ${s} \uC785\uB2C8\uB2E4. join_channel \uC751\uB2F5\uC758 me \uAC00 \uB2F9\uC2E0\uC758 \uC778\uC2A4\uD134\uC2A4 ID\uC785\uB2C8\uB2E4.`,`1. pluriply MCP \uB3C4\uAD6C join_channel \uB85C \uCC44\uB110 ${e} \uC5D0 \uCC38\uC5EC\uD558\uC138\uC694.`,`2. get_task_result \uB85C \uD0DC\uC2A4\uD06C ${t} \uB97C \uC77D\uC73C\uC138\uC694. review.gitRange / review.paths \uAC00 \uB300\uC0C1, review.focus \uAC00 \uAD00\uC810, request \uC5D0 \uCD94\uAC00 \uC124\uBA85\uC774 \uC788\uC2B5\uB2C8\uB2E4.`,`3. \uC791\uC5C5 \uD3F4\uB354 ${n} \uB294 \uC6D0\uBCF8 \uC800\uC7A5\uC18C ${r} \uC758 \uC2A4\uB0C5\uC0F7 \uBCF5\uC0AC\uBCF8\uC774\uBA70 git \uC800\uC7A5\uC18C\uAC00 \uC544\uB2D9\uB2C8\uB2E4. \uD5C8\uBE0C\uAC00 \uB9CC\uB4E0 unified diff \uD30C\uC77C ${o.file} (${o.target}) \uC744 \uC77D\uACE0, \uD544\uC694\uD558\uBA74 \uC791\uC5C5 \uD3F4\uB354\uC758 \uD30C\uC77C\uC744 \uD568\uAED8 \uC77D\uC5B4 \uAC80\uD1A0\uD558\uC138\uC694. git \uC744 \uC2E4\uD589\uD558\uC9C0 \uB9D0\uACE0, \uD30C\uC77C\uC744 \uC218\uC815\uD558\uAC70\uB098 \uC0C1\uD0DC\uB97C \uBC14\uAFB8\uB294 \uBA85\uB839\uC744 \uC2E4\uD589\uD558\uC9C0 \uB9C8\uC138\uC694.`,"4. \uBC1C\uACAC\uC744 \uC2EC\uAC01\uB3C4(critical/important/minor)\uC640 file/line \uC73C\uB85C \uC815\uB9AC\uD574 submit_review \uB85C \uC81C\uCD9C\uD558\uC138\uC694. file \uC740 \uC800\uC7A5\uC18C \uB8E8\uD2B8 \uAE30\uC900 \uC0C1\uB300\uACBD\uB85C\uB85C \uC801\uC73C\uC138\uC694. critical \uC774\uB098 important \uAC00 \uD558\uB098\uB77C\uB3C4 \uC788\uC73C\uBA74 verdict \uB294 request_changes, \uC5C6\uC73C\uBA74 approve, \uD310\uB2E8\uC744 \uC720\uBCF4\uD558\uBA74 comment \uC785\uB2C8\uB2E4.","5. \uAC80\uD1A0\uAC00 \uBD88\uAC00\uB2A5\uD558\uBA74(diff \uAC00 \uBE44\uC5B4 \uC788\uAC70\uB098 \uAC80\uD1A0 \uBC94\uC704\uB97C \uD310\uB2E8\uD560 \uC218 \uC5C6\uC73C\uBA74) submit_result \uC5D0 failed: true \uB85C \uC0AC\uC720\uB97C \uC81C\uCD9C\uD558\uC138\uC694.","6. \uB2E4\uB978 \uB3C4\uAD6C\uC5D0 \uC704\uC784\uD558\uC9C0 \uB9C8\uC138\uC694."].join(`
|
|
9
|
+
`)}var K=class{constructor({home:e,tasks:t}){this.home=e,this.tasks=t,this.running=new Map,this.swept=!1,this.queue=[],this.stopping=!1}dispatch(e,t,{interactive:n,config:r}){if(t.toInstance)return{kind:"pinned",hint:"pinned tasks are never handed to a worker"};let s=t.toTool??t.to;if(t.mode==="interactive")return{kind:"interactive"};if(t.mode==="auto"&&n)return{kind:"interactive"};if(this.stopping)return{kind:"none",hint:"hub is stopping"};if(!Ce(r,s))return{kind:"none",hint:Me(s)};try{if(!Ge(s))return{kind:"none",hint:Ne(s)};if(this.#t(s)>=r.limits.maxConcurrentPerAgent)return this.#r(s)>=r.limits.maxQueuedPerAgent?{kind:"none",hint:`worker queue full (${r.limits.maxQueuedPerAgent}) for ${s}`}:(this.queue.push({code:e,task:t,config:r}),{kind:"queued"});this.#i(e,t,r)}catch(o){return{kind:"none",hint:`worker spawn failed: ${o.message}`}}return{kind:"spawned"}}runningCount(e){let t=0;for(let n of this.running.values())for(let r of n)(e===void 0||r.code===e)&&t++;return t}onCancelled(e,t){for(let r of this.running.values())for(let s of r)s.code===e&&s.taskId===t&&(s.child?s.child.kill("SIGTERM"):s.cancelled=!0);let n=this.queue.findIndex(r=>r.code===e&&r.task.taskId===t);n!==-1&&this.queue.splice(n,1)}reconcile(e){for(let t of this.tasks.list(e)){let n=t.worker;!n||n.endedAt||!Number.isInteger(n.pid)||this.#c(e,t.taskId)||De(n.pid)||(this.tasks.setWorker(e,t.taskId,{endedAt:new Date().toISOString()}),this.tasks.failIfOpen(e,t.taskId,{result:"hub restarted while worker was running",by:`${n.agent} worker`}))}this.swept||(this.swept=!0,this.#e())}async stopAll(){this.stopping=!0;let e=[];for(let[r,s]of this.running)for(let o of s){if(!o.child){o.cancelled=!0,s.delete(o);try{this.tasks.failIfOpen(o.code,o.taskId,{result:"hub stopped while the review was being prepared",by:`${r} worker`})}catch{}rt(E(this.home,"workers",o.taskId,"tree"),q).catch(()=>{});continue}o.child.kill("SIGTERM"),e.push(o.child)}for(let r of this.queue)try{this.tasks.failIfOpen(r.code,r.task.taskId,{result:"hub stopped while the task was queued",by:`${r.task.toTool??r.task.to} worker`})}catch{}this.queue.length=0;let t=r=>r.exitCode===null&&r.signalCode===null,n=Date.now()+2e3;for(;e.some(t)&&Date.now()<n;)await new Promise(r=>setTimeout(r,100));for(let r of e)if(t(r))try{r.kill("SIGKILL")}catch{}}#t(e){return this.running.get(e)?.size??0}#r(e){let t=0;for(let n of this.queue)(n.task.toTool??n.task.to)===e&&t++;return t}#c(e,t){for(let n of this.running.values())for(let r of n)if(r.code===e&&r.taskId===t)return!0;return!1}#s(e){for(let t of this.running.values())for(let n of t)if(n.taskId===e)return!0;return!1}#e(){let e=E(this.home,"workers"),t;try{t=Ae(e,{withFileTypes:!0})}catch{return}for(let n of t)if(!(!n.isDirectory()||this.#s(n.name)))try{Oe(E(e,n.name,"tree"),q)}catch{}}#i(e,t,n){let r=t.toTool??t.to,s={code:e,taskId:t.taskId,child:null,cancelled:!1};this.running.has(r)||this.running.set(r,new Set),this.running.get(r).add(s),this.#l(e,t,n,s).catch(o=>{this.#n(s,r,t,`worker spawn failed: ${o.message}`)})}#n(e,t,n,r){this.running.get(t)?.delete(e);let s=E(this.home,"workers",`${n.taskId}.log`);try{nt(s,`${r}
|
|
10
|
+
`)}catch{}try{this.tasks.failIfOpen(e.code,n.taskId,{result:r,by:`${t} worker`})}catch{}rt(E(this.home,"workers",n.taskId,"tree"),q).catch(()=>{}),this.#a(t)}async#l(e,t,n,r){let s=t.toTool??t.to,o=E(this.home,"workers");Rt(o,{recursive:!0});let c=E(o,`${t.taskId}.log`),l=(t.kind??"task")==="review",u=E(o,t.taskId),h={agent:s,channelCode:e,taskId:t.taskId,from:t.from},d,p,w="";try{if(l){if(!t.cwd)throw new Error("review task has no cwd");await Pe(u,{recursive:!0});let f=await _t(t.cwd);d=E(u,"tree");let a=await bt({repoDir:t.cwd,destDir:d,git:f}),m=await St({cwd:t.cwd,review:t.review??{},outFile:E(u,"review.diff"),git:f});w=`snapshot: ${a.files} files, ${a.bytes} bytes, ${a.skippedSymlinks} symlinks skipped
|
|
11
|
+
`,p=Ke({...h,cwd:d,origin:t.cwd,diff:m}),await this.#o()}else d=t.cwd??E(this.home,"workspaces",t.taskId),Rt(d,{recursive:!0}),p=qe({...h,cwd:d})}catch(f){this.#n(r,s,t,`${l?"review preparation":"worker spawn"} failed: ${f.message}`);return}if(r.cancelled||this.stopping){this.#n(r,s,t,this.stopping?"hub stopped while the worker was being prepared":"worker cancelled while it was being prepared");return}let y;try{let f=It(s,{home:this.home,cwd:d,prompt:p,logDir:o,taskDir:u,taskId:t.taskId,channelCode:e,permissionMode:n.workers[s]?.permissionMode,timeoutMs:n.limits.timeoutMs,readOnly:l});w&&nt(c,w);let a=be(c,"a");try{y=Te(f.command,f.args,{cwd:d,shell:!1,stdio:["ignore",a,a],env:{...process.env,PLURIPLY_HOME:this.home,PLURIPLY_WORKER_TASK:t.taskId,PLURIPLY_WORKER_AGENT:s,PLURIPLY_DEPTH:String(t.depth??0)}})}finally{Re(a)}}catch(f){this.#n(r,s,t,`worker spawn failed: ${f.message}`);return}r.child=y;try{this.tasks.setWorker(e,t.taskId,{agent:s,pid:y.pid,startedAt:new Date().toISOString(),log:c})}catch(f){try{y.kill("SIGKILL")}catch{}this.#n(r,s,t,`worker spawn failed: ${f.message}`);return}let k=!1,g=setTimeout(()=>{k=!0,y.kill("SIGTERM"),setTimeout(()=>y.kill("SIGKILL"),5e3).unref()},n.limits.timeoutMs),v=(f,a)=>{clearTimeout(g),this.running.get(s)?.delete(r);let m={endedAt:new Date().toISOString(),exitCode:f};k&&(m.timedOut=!0);try{this.tasks.setWorker(e,t.taskId,m);let I;if(a){I=`worker failed to start: ${a.message}`;try{nt(c,`${I}
|
|
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.#a(s)};y.on("exit",(f,a)=>v(f??(a?-1:0))),y.on("error",f=>v(-1,f))}async#o(){if(process.env.PLURIPLY_ALLOW_TEMPLATE_OVERRIDE!=="1")return;let e=Number(process.env.PLURIPLY_TEST_PREPARE_DELAY_MS);e>0&&await new Promise(t=>setTimeout(t,e))}#a(e){if(!this.stopping)for(let t=0;t<this.queue.length;t++){let n=this.queue[t];if((n.task.toTool??n.task.to)!==e)continue;let r;try{r=this.tasks.get(n.code,n.task.taskId).status}catch{this.queue.splice(t,1),t--;continue}if(je.has(r)){this.queue.splice(t,1),t--;continue}if(this.#t(e)>=n.config.limits.maxConcurrentPerAgent)return;this.queue.splice(t,1);try{this.#i(n.code,n.task,n.config)}catch(s){try{this.tasks.failIfOpen(n.code,n.task.taskId,{result:`worker spawn failed: ${s.message}`,by:`${n.task.toTool??n.task.to} worker`})}catch{}}return}}};import{loadConfig as Ye}from"../shared/config.js";import{pluriplyHome as Je}from"../shared/paths.js";import{shortId as ze}from"../shared/ids.js";import{isValidAgentName as Pt,isInstanceId as Ve,makeInstanceId as Xe,toolOf as Qe,cwdKey as Ct}from"../shared/identity.js";import{PACKAGE_VERSION as Lt,PROTOCOL_VERSION as Dt}from"../shared/version.js";import{pingHub as Ze,pidAlive as Mt,homeId as Nt}from"../shared/probe.js";function st(i){try{let e=JSON.parse(Be(i,"utf8"));return Number.isInteger(e?.pid)&&Number.isInteger(e?.port)?e:null}catch{return null}}var it=class{constructor({home:e=Je(),port:t=0,verifyDelayMs:n=100}={}){this.home=e,this.requestedPort=t,this.verifyDelayMs=n;let r=new D(e);this.channels=new N(r),this.tasks=new G(this.channels),this.context=new W(this.channels),this.agents=new M(r),this.workers=new K({home:e,tasks:this.tasks}),this.wss=null,this.redundant=!1,this.redundantPort=null,this.connections=new Map,this.issued=new Set}get port(){return this.redundant?this.redundantPort:this.wss?.address()?.port}async start(){Fe(this.home,{recursive:!0}),await new Promise((t,n)=>{this.wss=new He({host:"127.0.0.1",port:this.requestedPort,verifyClient:(r,s)=>{"origin"in r.req.headers?s(!1,403,"Forbidden"):s(!0)}}),this.wss.on("listening",t),this.wss.on("error",n)}),this.wss.on("connection",t=>{this.connections.set(t,{instanceId:null,tool:null,worker:!1,cwdKey:null,channels:new Set,abort:new AbortController}),t.on("message",n=>this.#c(t,n)),t.on("close",()=>{this.connections.get(t)?.abort.abort(),this.connections.delete(t)})});let e=At(this.home,"hub.json");for(let t=0;t<2;t++){if(this.#r(e)){await new Promise(s=>setTimeout(s,this.verifyDelayMs));let r=st(e);return r&&r.pid!==process.pid?(await new Promise(s=>this.wss.close(s)),this.wss=null,this.redundant=!0,this.redundantPort=r.port,{port:r.port,redundant:!0}):{port:this.port}}let n=st(e);if(n&&await this.#t(n))return await new Promise(r=>this.wss.close(r)),this.wss=null,this.redundant=!0,this.redundantPort=n.port,{port:n.port,redundant:!0};xt(e,{force:!0})}throw await new Promise(t=>this.wss.close(t)),this.wss=null,new Error("could not acquire hub lock")}async#t(e){if(!Mt(e.pid))return!1;let t=Date.now()+2e3;for(;;){let n=await Ze(e.port,300);if(n)return!n.home||n.home===Nt(this.home);if(Date.now()>=t||!Mt(e.pid))return!1;await new Promise(r=>setTimeout(r,200))}}#r(e){let t=JSON.stringify({pid:process.pid,port:this.wss.address().port,version:Lt,protocol:Dt,startedAt:new Date().toISOString()},null,2);try{return Ue(e,t,{flag:"wx"}),!0}catch(n){if(n.code==="EEXIST")return!1;throw n}}async stop(){if(this.redundant||!this.wss)return;await this.workers.stopAll();let e=At(this.home,"hub.json");st(e)?.pid===process.pid&&xt(e,{force:!0});for(let t of this.connections.keys())t.terminate();await new Promise(t=>this.wss.close(t)),this.wss=null}async#c(e,t){let n;try{n=JSON.parse(t.toString())}catch{return}try{let r=await this.#a(n.type,n.payload??{},e);this.#s(e,{id:n.id,ok:!0,payload:r})}catch(r){this.#s(e,{id:n.id,ok:!1,error:{message:r.message}})}}#s(e,t){e.readyState===e.OPEN&&e.send(JSON.stringify(t))}#e(e){let t=this.connections.get(e);if(!t||!t.instanceId)throw new Error("say hello first");return t}onlineInstances(e){let t=new Set;for(let n of this.connections.values())n.instanceId&&n.channels.has(e)&&t.add(n.instanceId);return t}isInteractive(e,t){for(let n of this.connections.values())if(n.tool===t&&!n.worker&&n.channels.has(e))return!0;return!1}#i(e){let t=[],n=[];for(let r of this.connections.values())!r.instanceId||!r.channels.has(e)||(r.worker?n:t).push(r.instanceId);return{interactive:t,workers:n}}#n(e,t){let n=this.onlineInstances(e);return t.map(r=>({...r,online:n.has(r.instanceId)}))}#l(e){for(;;){let t=Xe(e,ze(4));if(!(this.issued.has(t)||[...this.connections.values()].some(r=>r.instanceId===t)))return this.issued.add(t),t}}#o(e,t,n){let r=this.onlineInstances(n);r.add(t.instanceId);let{peers:s}=this.channels.join(n,{instanceId:t.instanceId,tool:t.tool,worker:t.worker},{online:r});return this.agents.touch(t.cwdKey,n),t.channels.add(n),this.#n(n,s)}#a(e,t,n){switch(e){case"ping":return{pong:!0,version:Lt,protocol:Dt,pid:process.pid,home:Nt(this.home)};case"hook.poll":{let r=t.tool,s={channelCode:null,tool:r,incoming:[],results:[],more:0};if(!Pt(r)||typeof t.cwd!="string"||!Ot(t.cwd))return s;let o=Ct(r,t.cwd),c=[...this.connections.values()].filter(a=>a.cwdKey===o);if(c.length>0&&c.every(a=>a.worker))return s;let l=c.filter(a=>!a.worker),u=l.length>0?[...new Set(l.flatMap(a=>[...a.channels]))]:[this.agents.resume(o)].filter(Boolean);if(u.length===0)return s;for(let a of u)this.workers.reconcile(a);let h=new Set(l.map(a=>a.instanceId)),d=a=>!a.hookDelivered?.[o],p=u.flatMap(a=>this.tasks.list(a).map(m=>({t:m,code:a}))),w=p.filter(({t:a})=>a.status==="submitted"&&d(a)&&(a.toInstance?h.has(a.toInstance):(a.toTool??a.to)===r)),y=p.filter(({t:a})=>a.fromCwdKey===o&&(a.status==="completed"||a.status==="failed")&&d(a)),k=a=>{let m=String(a.request??"").replace(/\s+/g," ").trim();return m.length>80?`${m.slice(0,80)}\u2026`:m},g=[...w.map(({t:a,code:m})=>({t:a,code:m,at:a.createdAt,entry:{taskId:a.taskId,kind:a.kind??"task",from:a.from,summary:k(a)},side:"incoming"})),...y.map(({t:a,code:m})=>({t:a,code:m,at:a.updatedAt,entry:{taskId:a.taskId,status:a.status,to:a.completedBy??a.to,summary:k(a)},side:"results"}))].sort((a,m)=>a.at<m.at?-1:a.at>m.at?1:0),v=g.slice(0,10),f=new Date;for(let a of v)this.tasks.markHookDelivered(a.code,a.t.taskId,o,f);return{channelCode:u[0],tool:r,incoming:v.filter(a=>a.side==="incoming").map(a=>a.entry),results:v.filter(a=>a.side==="results").map(a=>a.entry),more:g.length-v.length}}case"channel.create":return{channelCode:this.channels.create().channel.code};case"agent.hello":{if(!Pt(t.tool))throw new Error(`invalid agent name: ${t.tool}`);if(typeof t.cwd!="string"||t.cwd.length===0||!Ot(t.cwd))throw new Error("cwd is required");let r=this.connections.get(n);if(r.instanceId)return{instanceId:r.instanceId};let s=t.instanceId;if(s!==void 0){if(!Ve(s)||Qe(s)!==t.tool)throw new Error(`invalid instanceId: ${s}`)}else s=this.#l(t.tool);return r.instanceId=s,r.tool=t.tool,r.worker=!!t.worker,r.cwdKey=Ct(t.tool,t.cwd),{instanceId:s}}case"channel.join":{let r=this.#e(n);this.channels.get(t.channelCode);let s=this.#o(n,r,t.channelCode);return{channelCode:t.channelCode,peers:s}}case"channel.peers":return{peers:this.#n(t.channelCode,this.channels.peers(t.channelCode,{online:this.onlineInstances(t.channelCode)}))};case"channel.presence":return this.channels.get(t.channelCode),this.#i(t.channelCode);case"agent.resume":{let r=this.#e(n);if(r.channels.size>0)return{channelCode:null,alreadyJoined:!0};let s=this.agents.resume(r.cwdKey);if(!s)return{channelCode:null};let o=this.#o(n,r,s);return{channelCode:s,peers:o}}case"task.create":{let r=this.#e(n),s=Ye(this.home),{task:o,targetJoined:c,targetOnline:l,warning:u}=this.tasks.create(t.channelCode,{...t,from:r.instanceId,fromCwdKey:r.cwdKey,depth:t.depth??0,mode:t.mode??"auto",maxDepth:s.limits.maxDepth,allowedRoots:s.allowedRoots,online:this.onlineInstances(t.channelCode)});this.agents.touch(r.cwdKey,t.channelCode);let h=this.workers.dispatch(t.channelCode,o,{interactive:this.isInteractive(t.channelCode,o.toTool),config:s}),d={taskId:o.taskId,targetJoined:c,dispatch:h.kind};return l!==void 0&&(d.targetOnline=l),u&&(d.warning=u),h.hint&&(d.hint=h.hint),d}case"task.list":return this.workers.reconcile(t.channelCode),{tasks:this.tasks.list(t.channelCode,{to:t.to,from:t.from,status:t.status,kind:t.kind})};case"task.get":return this.workers.reconcile(t.channelCode),{task:this.tasks.get(t.channelCode,t.taskId)};case"task.wait":{this.workers.reconcile(t.channelCode);let r=Math.min(Math.max(Number(t.timeoutMs)||0,1),15e3),s=this.connections.get(n)?.abort.signal;return this.tasks.waitFor(t.channelCode,t.taskId,r,{signal:s}).then(o=>{if(o===null)throw new Error("connection closed while waiting");return{task:o}})}case"task.claim":{let r=this.#e(n),s=this.tasks.claim(t.channelCode,t.taskId,r.instanceId);return this.agents.touch(r.cwdKey,t.channelCode),{task:s}}case"task.complete":{let r=this.#e(n),s=this.tasks.complete(t.channelCode,t.taskId,{from:r.instanceId,result:t.result,status:t.status,worker:r.worker,review:t.review});if(this.agents.touch(r.cwdKey,t.channelCode),(s.kind??"task")==="review"&&s.status==="completed")try{this.context.add(t.channelCode,{from:r.instanceId,summary:`[review] ${s.result.verdict} by ${r.instanceId}: ${s.result.summary}`,artifacts:[]})}catch{}return{task:s}}case"task.cancel":{let r=this.#e(n),s=this.tasks.cancel(t.channelCode,t.taskId,{agent:r.instanceId,reason:t.reason});return this.agents.touch(r.cwdKey,t.channelCode),this.workers.onCancelled(t.channelCode,t.taskId),{task:s}}case"worker.status":return t.channelCode!==void 0&&this.channels.get(t.channelCode),{running:this.workers.runningCount(t.channelCode)};case"context.add":{let r=this.#e(n),s=this.context.add(t.channelCode,{...t,from:r.instanceId});return this.agents.touch(r.cwdKey,t.channelCode),{entryId:s.entryId}}case"context.list":return{entries:this.context.list(t.channelCode,{limit:t.limit})};default:throw new Error(`unknown message type: ${e}`)}}};import{spawn as tn}from"node:child_process";import{existsSync as en,rmSync as ot}from"node:fs";import{join as nn}from"node:path";import{fileURLToPath as rn}from"node:url";import{pingHub as jt,homeId as sn,pidAlive as on}from"../shared/probe.js";import{readLock as Gt}from"../shared/lock.js";var an=rn(new URL("../../bin/pluriply.js",import.meta.url));async function Wt(i){let e=Gt(i);if(!e)return null;let t=await jt(e.port);return!t||t.home&&t.home!==sn(i)?null:{...t,port:e.port,lockPid:e.pid}}async function cn({home:i,timeoutMs:e=5e3}){tn(process.execPath,[an,"hub","start"],{detached:!0,stdio:"ignore",env:{...process.env,PLURIPLY_HOME:i}}).unref();let n=Date.now()+e;for(;Date.now()<n;){let r=await Wt(i);if(r)return r;await new Promise(s=>setTimeout(s,100))}throw new Error(`failed to start pluriply hub within ${e/1e3}s`)}async function ln({home:i,timeoutMs:e=5e3}){let t=Gt(i),n=nn(i,"hub.json");if(!t)return"not-running";let r=await jt(t.port,1e3),s=r&&Number.isInteger(r.pid)&&Number.isInteger(t.pid)?r.pid!==t.pid:!1;if(!r||s)return ot(n,{force:!0}),"not-running";try{process.kill(t.pid,"SIGTERM")}catch{return ot(n,{force:!0}),"stopped"}let o=Date.now()+e;for(;Date.now()<o;){if(!en(n))return"stopped";if(!on(t.pid))return ot(n,{force:!0}),"stopped";await new Promise(c=>setTimeout(c,100))}return"timeout"}import{readLock as vr}from"../shared/lock.js";import{loadConfig as Tr,saveConfig as br,setWorkerEnabled as Rr,TEMPLATE_AGENTS as xr}from"../shared/config.js";export{it as Hub,xr as TEMPLATE_AGENTS,Wt as liveHub,Tr as loadConfig,vr as readLock,br as saveConfig,Rr as setWorkerEnabled,cn as spawnHub,ln as stopHub};
|
package/src/setup/clients.js
CHANGED
|
@@ -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
|
+
}
|
package/src/setup/run-setup.js
CHANGED
|
@@ -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 가
|
|
@@ -71,13 +72,43 @@ function walkClients({ targets, e, dryRun, act, dryRunResult, collect }) {
|
|
|
71
72
|
return { rows, failed };
|
|
72
73
|
}
|
|
73
74
|
|
|
75
|
+
/**
|
|
76
|
+
* MCP 등록 표(`rows`)를 바탕으로 훅 행을 만든다(스펙 §6). 도구가 감지되지 않았으면 not installed,
|
|
77
|
+
* `hooks:false` 면 skipped, dry-run 은 상태만 본다. 실패는 failed 로 세고 진행한다.
|
|
78
|
+
* @returns {{hookRows: object[], failed: number}}
|
|
79
|
+
*/
|
|
80
|
+
function walkHooks({ targets, rows, e, dryRun, hooks, remove }) {
|
|
81
|
+
const hookRows = [];
|
|
82
|
+
let failed = 0;
|
|
83
|
+
for (const hc of HOOK_CLIENTS) {
|
|
84
|
+
if (!targets.some((c) => c.id === hc.id)) continue;
|
|
85
|
+
const row = rows.find((x) => x.id === hc.id);
|
|
86
|
+
const installed = Boolean(row?.installed);
|
|
87
|
+
let result;
|
|
88
|
+
if (!installed) result = "not installed";
|
|
89
|
+
else if (!hooks) result = "skipped";
|
|
90
|
+
else if (dryRun) {
|
|
91
|
+
const st = hookStatus(e, hc);
|
|
92
|
+
if (typeof st === "object") result = `failed: ${st.error}`;
|
|
93
|
+
else if (remove) result = st === "missing" ? "absent" : "planned";
|
|
94
|
+
else result = st === "present" ? "present" : "planned";
|
|
95
|
+
} else result = remove ? removeHook(e, hc) : installHook(e, hc);
|
|
96
|
+
if (result === "failed") result = "failed: see hint above";
|
|
97
|
+
if (result.startsWith("failed")) failed++;
|
|
98
|
+
hookRows.push({ id: hc.id, label: hc.label, installed, result });
|
|
99
|
+
}
|
|
100
|
+
return { hookRows, failed };
|
|
101
|
+
}
|
|
102
|
+
|
|
74
103
|
/**
|
|
75
104
|
* `pluriply setup`: 설치된 클라이언트를 감지해 pluriply 커넥터를 멱등 등록한다.
|
|
76
105
|
* `remove` 면 반대로 등록을 풀고 워커 설정·허브·(purge 시) 데이터까지 정리한다.
|
|
77
106
|
* `--purge` 는 pluriply 홈이 심볼릭 링크면 **링크만 끊고** 링크가 가리키는 디렉터리는 남긴다
|
|
78
107
|
* (그 안의 내용까지 지우려면 실제 경로를 직접 지워야 한다).
|
|
79
108
|
* env.stopHub / env.rm / env.lstat 은 테스트가 주입한다.
|
|
80
|
-
*
|
|
109
|
+
* `hooks`(기본 true)는 Claude Code·Codex 의 Stop 훅 등록 여부다(스펙 §6). `--remove` 는 이 값과
|
|
110
|
+
* 무관하게 항상 훅을 제거한다.
|
|
111
|
+
* @param {{only?: string[], workers?: boolean, dryRun?: boolean, remove?: boolean, purge?: boolean, hooks?: boolean, env?: object, home: string}} opts
|
|
81
112
|
*/
|
|
82
113
|
export async function runSetup({
|
|
83
114
|
only,
|
|
@@ -85,6 +116,7 @@ export async function runSetup({
|
|
|
85
116
|
dryRun = false,
|
|
86
117
|
remove = false,
|
|
87
118
|
purge = false,
|
|
119
|
+
hooks = true,
|
|
88
120
|
env,
|
|
89
121
|
home,
|
|
90
122
|
}) {
|
|
@@ -103,11 +135,13 @@ export async function runSetup({
|
|
|
103
135
|
enabledAgents.push(c.agent);
|
|
104
136
|
},
|
|
105
137
|
});
|
|
138
|
+
const hk = walkHooks({ targets, rows, e, dryRun, hooks, remove: false });
|
|
106
139
|
if (workers && !dryRun && enabledAgents.length > 0)
|
|
107
140
|
setWorkerEnabled(home, enabledAgents, true);
|
|
108
141
|
const out = {
|
|
109
142
|
rows,
|
|
110
|
-
failed,
|
|
143
|
+
failed: failed + hk.failed,
|
|
144
|
+
hookRows: hk.hookRows,
|
|
111
145
|
workers: workers && !dryRun ? enabledAgents : [],
|
|
112
146
|
};
|
|
113
147
|
if (!dryRun) {
|
|
@@ -184,10 +218,13 @@ async function runRemove({ targets, only, dryRun, purge, e, home }) {
|
|
|
184
218
|
// 여기서 따로 모을 게 없다.
|
|
185
219
|
collect: () => {},
|
|
186
220
|
});
|
|
221
|
+
// --remove 는 --no-hooks 와 무관하게 항상 훅을 제거한다.
|
|
222
|
+
const hk = walkHooks({ targets, rows, e, dryRun, hooks: true, remove: true });
|
|
187
223
|
const out = {
|
|
188
224
|
mode: "remove",
|
|
189
225
|
rows,
|
|
190
|
-
failed: rowsFailed,
|
|
226
|
+
failed: rowsFailed + hk.failed,
|
|
227
|
+
hookRows: hk.hookRows,
|
|
191
228
|
workers: [],
|
|
192
229
|
workersDisabled: [],
|
|
193
230
|
};
|
|
@@ -266,6 +303,10 @@ export function formatSetup(r, { workers = false } = {}) {
|
|
|
266
303
|
(row) =>
|
|
267
304
|
`${row.id.padEnd(16)} ${row.installed ? "installed " : "not installed"} ${row.result}`,
|
|
268
305
|
);
|
|
306
|
+
for (const row of r.hookRows ?? [])
|
|
307
|
+
lines.push(
|
|
308
|
+
`hooks ${row.id.padEnd(12)} ${row.installed ? "installed " : "not installed"} ${row.result}`,
|
|
309
|
+
);
|
|
269
310
|
if (r.mode === "remove") {
|
|
270
311
|
if (r.workersDisabled.length)
|
|
271
312
|
lines.push(`workers disabled: ${r.workersDisabled.join(", ")}`);
|
|
@@ -295,5 +336,15 @@ export function formatSetup(r, { workers = false } = {}) {
|
|
|
295
336
|
`hint: run \`pluriply worker enable <${cli.join("|")}>\` to let the hub run that tool headlessly (or re-run setup --workers)`,
|
|
296
337
|
);
|
|
297
338
|
}
|
|
339
|
+
if (
|
|
340
|
+
(r.hookRows ?? []).some(
|
|
341
|
+
(x) =>
|
|
342
|
+
x.id === "codex" &&
|
|
343
|
+
(x.result === "registered" || x.result === "updated"),
|
|
344
|
+
)
|
|
345
|
+
)
|
|
346
|
+
lines.push(
|
|
347
|
+
"hint: Codex asks to trust the new hook in its next session — approve it.",
|
|
348
|
+
);
|
|
298
349
|
return lines;
|
|
299
350
|
}
|
package/src/shared/identity.js
CHANGED
|
@@ -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)
|
|
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
|
-
|
|
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
|
}
|