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