pluriply 0.3.0 → 0.5.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 +9 -1
- package/bin/pluriply.js +29 -4
- package/package.json +1 -1
- package/src/connector/hub-client.js +203 -38
- package/src/connector/tools.js +17 -7
- package/src/hub/index.js +16 -9
- package/src/setup/run-setup.js +58 -15
- package/src/shared/lock.js +7 -3
- package/src/shared/version.js +2 -1
package/README.md
CHANGED
|
@@ -31,9 +31,10 @@ Pluriply MCP connector with each of them (idempotent — run it again any time).
|
|
|
31
31
|
- `npx pluriply setup --workers` — also let the hub run Claude Code / Codex / Antigravity headlessly for `send_task` and `ask_agent`.
|
|
32
32
|
- `npx pluriply setup --only claude-code,codex` — limit to specific tools.
|
|
33
33
|
- `npx pluriply setup --remove` — unregister Pluriply from every tool, disable headless workers and stop the hub. Your channels and task history under `~/.pluriply` stay; add `--purge` to delete them too.
|
|
34
|
+
- `npx pluriply setup --remove --hooks-only` — take out only the Stop hooks; MCP registration, headless workers and the hub stay as they are.
|
|
34
35
|
- Codex and Antigravity get a 600 s MCP tool timeout written into their config at registration (their default is 60 s, too short for `ask_agent`/`request_review` waits). If you registered with an earlier version, run `setup --remove` then `setup` again to pick it up.
|
|
35
36
|
- `setup` also installs a Stop hook for Claude Code, Codex and Antigravity CLI so a live session notices new tasks and finished results at the end of its turn (see _Warm reception_). `--no-hooks` skips it; `setup --remove` takes it out again.
|
|
36
|
-
-
|
|
37
|
+
- After upgrading or cleaning the npx cache, run `npx pluriply@latest setup --hooks-only` — the hook command embeds the installed path, and this refreshes it without rewriting your MCP configuration.
|
|
37
38
|
|
|
38
39
|
Restart your AI tools afterwards so they pick up the new MCP server.
|
|
39
40
|
|
|
@@ -73,6 +74,13 @@ it runs. Antigravity's hook lives in `~/.gemini/config/hooks.json`.
|
|
|
73
74
|
Everything stays on your machine under `~/.pluriply/` (channels, task history,
|
|
74
75
|
results). There is no server and no account. Delete the folder to reset.
|
|
75
76
|
|
|
77
|
+
The hub only listens on `127.0.0.1`, and since 0.4.0 it also issues a random
|
|
78
|
+
token every time it starts, kept in `~/.pluriply/hub.json` (mode 600). Only
|
|
79
|
+
connectors and hooks that read that file can talk to it; anything else can
|
|
80
|
+
only ping it (version and pid) and gets `unauthorized` for everything else.
|
|
81
|
+
On Windows the file mode is not enforced — the profile folder's ACL is what
|
|
82
|
+
keeps other users out.
|
|
83
|
+
|
|
76
84
|
## License
|
|
77
85
|
|
|
78
86
|
The CLI, connector, shared utilities and setup code in this repository are
|
package/bin/pluriply.js
CHANGED
|
@@ -44,7 +44,14 @@ function flag(name) {
|
|
|
44
44
|
}
|
|
45
45
|
|
|
46
46
|
/** setup 이 아는 플래그. 오타 하나가 파괴적인 명령의 범위를 넓히지 못하게 한다. */
|
|
47
|
-
const SETUP_BOOL_FLAGS = [
|
|
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") {
|
|
@@ -56,9 +63,19 @@ if (cmd === "hub" && sub === "start") {
|
|
|
56
63
|
process.exit(0);
|
|
57
64
|
}
|
|
58
65
|
console.log(`pluriply hub listening on ${port}`);
|
|
59
|
-
|
|
60
|
-
|
|
66
|
+
// Plan 4g: 락이 다른 허브로 넘어가면 허브가 스스로 물러난다(stop 완료 후 이 이벤트).
|
|
67
|
+
hub.on("orphaned", () => {
|
|
68
|
+
console.log("pluriply hub: lock taken by another hub; exiting");
|
|
61
69
|
process.exit(0);
|
|
70
|
+
});
|
|
71
|
+
// 락 감시가 낸 stop() 과 겹칠 수 있다. stop() 이 거부돼도 unhandled rejection 으로
|
|
72
|
+
// 죽지 않고 반드시 종료한다(Plan 4g).
|
|
73
|
+
const shutdown = async () => {
|
|
74
|
+
try {
|
|
75
|
+
await hub.stop();
|
|
76
|
+
} finally {
|
|
77
|
+
process.exit(0);
|
|
78
|
+
}
|
|
62
79
|
};
|
|
63
80
|
process.on("SIGTERM", shutdown);
|
|
64
81
|
process.on("SIGINT", shutdown);
|
|
@@ -166,10 +183,17 @@ if (cmd === "hub" && sub === "start") {
|
|
|
166
183
|
const remove = rest.includes("--remove");
|
|
167
184
|
const purge = rest.includes("--purge");
|
|
168
185
|
const hooks = !rest.includes("--no-hooks");
|
|
186
|
+
const hooksOnly = rest.includes("--hooks-only");
|
|
169
187
|
if (remove && workers) usage("--remove cannot be combined with --workers");
|
|
170
188
|
if (purge && !remove) usage("--purge requires --remove");
|
|
171
189
|
if (purge && onlyArg) usage("--purge cannot be combined with --only");
|
|
172
190
|
if (remove && !hooks) usage("--no-hooks has no effect with --remove");
|
|
191
|
+
// --hooks-only 는 MCP 등록·워커·허브를 건드리지 않는다(스펙 §4.1). 그것들을 겨냥한 플래그와는 모순.
|
|
192
|
+
if (hooksOnly && workers)
|
|
193
|
+
usage("--hooks-only cannot be combined with --workers");
|
|
194
|
+
if (hooksOnly && !hooks)
|
|
195
|
+
usage("--hooks-only cannot be combined with --no-hooks");
|
|
196
|
+
if (hooksOnly && purge) usage("--hooks-only cannot be combined with --purge");
|
|
173
197
|
try {
|
|
174
198
|
const r = await runSetup({
|
|
175
199
|
only: onlyArg
|
|
@@ -183,6 +207,7 @@ if (cmd === "hub" && sub === "start") {
|
|
|
183
207
|
remove,
|
|
184
208
|
purge,
|
|
185
209
|
hooks,
|
|
210
|
+
hooksOnly,
|
|
186
211
|
env: makeEnv({ binPath: BIN_PATH }),
|
|
187
212
|
home: pluriplyHome(),
|
|
188
213
|
});
|
|
@@ -271,7 +296,7 @@ if (cmd === "hub" && sub === "start") {
|
|
|
271
296
|
await startConnector({ agent });
|
|
272
297
|
} else {
|
|
273
298
|
console.error(
|
|
274
|
-
"usage: pluriply <setup [--workers] [--dry-run] [--only a,b] [--no-hooks]|setup --remove [--purge] [--dry-run] [--only a,b]|hub start|hub stop|hub restart|hook stop --agent <claude-code|codex|antigravity>|connector --agent <name>|status|worker enable|disable <codex|claude-code|antigravity>|worker list>",
|
|
299
|
+
"usage: pluriply <setup [--workers] [--dry-run] [--only a,b] [--no-hooks|--hooks-only]|setup --remove [--purge] [--dry-run] [--only a,b] [--hooks-only]|hub start|hub stop|hub restart|hook stop --agent <claude-code|codex|antigravity>|connector --agent <name>|status|worker enable|disable <codex|claude-code|antigravity>|worker list>",
|
|
275
300
|
);
|
|
276
301
|
process.exit(1);
|
|
277
302
|
}
|
package/package.json
CHANGED
|
@@ -3,19 +3,34 @@ import { EventEmitter } from "node:events";
|
|
|
3
3
|
import { pluriplyHome } from "../shared/paths.js";
|
|
4
4
|
import { liveHub, spawnHub } from "../hub/index.js";
|
|
5
5
|
import { PROTOCOL_VERSION } from "../shared/version.js";
|
|
6
|
+
import { readLock } from "../shared/lock.js";
|
|
6
7
|
|
|
7
8
|
const RECONNECT_TOTAL_MS = 60_000;
|
|
8
9
|
const RECONNECT_MAX_DELAY_MS = 5_000;
|
|
10
|
+
/**
|
|
11
|
+
* 연결이 "자리 잡았다"고 보는 최소 유지 시간(Plan 4g). 이보다 짧게 살고 끊긴 연결은
|
|
12
|
+
* 실패한 시도로 세어, 다음 재접속이 대기·총 제한을 처음부터 다시 세지 않게 한다
|
|
13
|
+
* (2026-09-17 사고: 접속 즉시 끊김이 반복되자 대기도 포기도 없이 초당 수백 회 돌았다).
|
|
14
|
+
*/
|
|
15
|
+
const STABLE_MS = 5_000;
|
|
9
16
|
/**
|
|
10
17
|
* request()가 this.reconnecting을 기다리는 상한. BARRIER_TIMEOUT_MS(재접속 후
|
|
11
18
|
* "reconnected" 리스너를 기다리는 상한)보다 넉넉히 커야 한다 — this.reconnecting은
|
|
12
|
-
* 그 리스너뿐 아니라 허브 재스폰 전체(ensureHub → 없으면 spawnHub,
|
|
13
|
-
*
|
|
19
|
+
* 그 리스너뿐 아니라 허브 재스폰 전체(ensureHub → 없으면 spawnHub, 대기가 최대
|
|
20
|
+
* 20초다)까지 포함하므로, 이 값이 짧으면 재접속(허브 재스폰 포함)이 아직
|
|
14
21
|
* 끝나지 않았는데 request()가 먼저 포기하고 readyState===OPEN만 보고 재join이
|
|
15
|
-
* 안 끝난 소켓으로 그대로 전송해버릴 수 있다.
|
|
16
|
-
* 한
|
|
22
|
+
* 안 끝난 소켓으로 그대로 전송해버릴 수 있다.
|
|
23
|
+
* 이 값이 덮는 것은 재접속 "한 회차"다(#reconnect 루프 전체가 아니다): liveHub의 ping
|
|
24
|
+
* 1s + spawnHub 20s(Plan 4g에서 5s→20s; 루프가 기한을 liveHub 뒤에 확인해 최대 1s 더
|
|
25
|
+
* 넘길 수 있다) + tryConnect 3s + BARRIER_TIMEOUT_MS 5s ≈ 30s. 여기에 여유를 두어 35s로
|
|
26
|
+
* 한다. this.reconnecting은 #reconnect 루프 전체(최대 reconnectTotalMs=60s, 즉시
|
|
27
|
+
* 재시도가 붙으면 그 이상)에 걸쳐 있어 어떤 상수도 그 전체를 덮지 못한다 — 루프가 더
|
|
28
|
+
* 길어지면 request()는 기존의 "hub connection closed" 거절로 물러난다. spawnHub의
|
|
29
|
+
* 대기가 다시 바뀌면 이 값도 함께 옮겨야 한다. raceSleep이 경합이 끝나는 즉시 타이머를
|
|
30
|
+
* 걷으므로 값을 키워도 프로세스 종료가 늦어지지 않는다.
|
|
31
|
+
* 사슬 TAKEOVER_GRACE_MS < SPAWN_WAIT_MS < REQUEST_WAIT_MS 는 test/hub/timing.test.js 가 고정한다.
|
|
17
32
|
*/
|
|
18
|
-
const REQUEST_WAIT_MS =
|
|
33
|
+
export const REQUEST_WAIT_MS = 35_000;
|
|
19
34
|
/**
|
|
20
35
|
* 재접속 성공 뒤 "reconnected" 리스너(예: 채널 재join)를 기다리는 최대 시간.
|
|
21
36
|
* 리스너가 절대 끝나지 않아도(응답 없는 hub.request 등) 이 시간이 지나면
|
|
@@ -42,12 +57,30 @@ const RETRYABLE = new Set([
|
|
|
42
57
|
"context.list",
|
|
43
58
|
]);
|
|
44
59
|
|
|
45
|
-
|
|
60
|
+
// `p` 와 ms 타이머의 경합. 경합이 끝나면 타이머를 걷는다 — 진 타이머가 남으면 상대가 먼저
|
|
61
|
+
// 이겨도 N초 동안 이벤트 루프가 열려 있어 프로세스(테스트 파일 포함) 종료가 그만큼 늦어진다.
|
|
62
|
+
// unref 가 아니라 clear 인 이유: 경합이 진행 중일 때는 타이머가 루프를 붙잡고 있어야
|
|
63
|
+
// (예: 끝나지 않는 리스너를 배리어가 끊는 경우) 대기가 조용히 잘리지 않는다.
|
|
64
|
+
const raceSleep = (p, ms) => {
|
|
65
|
+
let t;
|
|
66
|
+
const timer = new Promise((r) => {
|
|
67
|
+
t = setTimeout(r, ms);
|
|
68
|
+
});
|
|
69
|
+
return Promise.race([p, timer]).finally(() => clearTimeout(t));
|
|
70
|
+
};
|
|
46
71
|
|
|
47
|
-
/**
|
|
48
|
-
|
|
72
|
+
/**
|
|
73
|
+
* @param {number} port @param {number} [timeoutMs] @param {string} [token] 허브 락의 연결 토큰(Plan 4f).
|
|
74
|
+
* 있으면 업그레이드 헤더 `Authorization: Bearer <token>` 으로 보낸다. 없으면(구버전 허브) 헤더 없이 붙는다.
|
|
75
|
+
* @returns {Promise<WebSocket|null>} 연결 실패 시 null
|
|
76
|
+
*/
|
|
77
|
+
function tryConnect(port, timeoutMs = 1000, token) {
|
|
49
78
|
return new Promise((resolve) => {
|
|
50
|
-
const ws =
|
|
79
|
+
const ws = token
|
|
80
|
+
? new WebSocket(`ws://127.0.0.1:${port}`, {
|
|
81
|
+
headers: { authorization: `Bearer ${token}` },
|
|
82
|
+
})
|
|
83
|
+
: new WebSocket(`ws://127.0.0.1:${port}`);
|
|
51
84
|
const timer = setTimeout(() => {
|
|
52
85
|
ws.terminate();
|
|
53
86
|
resolve(null);
|
|
@@ -95,31 +128,78 @@ export class HubClient extends EventEmitter {
|
|
|
95
128
|
|
|
96
129
|
/**
|
|
97
130
|
* @param {WebSocket} ws
|
|
98
|
-
* @param {{home?: string, reconnectTotalMs?: number}} [opts]
|
|
99
|
-
* reconnectTotalMs는 재접속을 포기하기까지의 총 시간
|
|
131
|
+
* @param {{home?: string, reconnectTotalMs?: number, stableMs?: number, token?: string}} [opts]
|
|
132
|
+
* home이 없으면 재접속하지 않는다. reconnectTotalMs는 재접속을 포기하기까지의 총 시간
|
|
133
|
+
* (기본 RECONNECT_TOTAL_MS), stableMs는 연결이 자리 잡았다고 보는 최소 유지 시간
|
|
134
|
+
* (기본 STABLE_MS) — 둘 다 테스트에서 짧게 만드는 데 쓴다. token은 이 소켓이 업그레이드
|
|
135
|
+
* 헤더로 보낸 허브 연결 토큰(Plan 4f)이다.
|
|
100
136
|
*/
|
|
101
|
-
constructor(
|
|
137
|
+
constructor(
|
|
138
|
+
ws,
|
|
139
|
+
{
|
|
140
|
+
home,
|
|
141
|
+
reconnectTotalMs = RECONNECT_TOTAL_MS,
|
|
142
|
+
stableMs = STABLE_MS,
|
|
143
|
+
token,
|
|
144
|
+
} = {},
|
|
145
|
+
) {
|
|
102
146
|
super();
|
|
103
147
|
this.home = home;
|
|
104
148
|
this.reconnectTotalMs = reconnectTotalMs;
|
|
149
|
+
this.stableMs = stableMs;
|
|
105
150
|
this.pending = new Map();
|
|
106
151
|
this.seq = 0;
|
|
107
152
|
this.stale = null;
|
|
108
153
|
this.closed = false;
|
|
109
154
|
this.dead = false;
|
|
155
|
+
/**
|
|
156
|
+
* Plan 4g: dead 가 허브의 unauthorized 응답 때문이면 그 문구. request() 오류에 붙여
|
|
157
|
+
* 사용자가 "도구를 다시 시작하라"는 안내를 그대로 보게 한다.
|
|
158
|
+
* @type {string|null}
|
|
159
|
+
*/
|
|
160
|
+
this.deadReason = null;
|
|
161
|
+
/**
|
|
162
|
+
* 이 연결에서 받은 unauthorized 응답(문구 + 그 연결에 쓴 토큰). #reconnect 가
|
|
163
|
+
* 배리어 뒤에 보고 판단한다.
|
|
164
|
+
* @type {{message: string, token: string|undefined}|null}
|
|
165
|
+
*/
|
|
166
|
+
this.lastUnauthorized = null;
|
|
167
|
+
/**
|
|
168
|
+
* Plan 4g: "토큰이 바뀌었으니 대기 없이 한 번 더"를 한 번만 허용한다. 허브가 계속
|
|
169
|
+
* 락을 새 토큰으로 갈아치우면 매 회차가 retry 가 되어 사다리가 서지 않기 때문이다.
|
|
170
|
+
* 정상 연결(ok)을 확인하면 다시 false 로 돌려 한 번의 기회를 되찾는다.
|
|
171
|
+
*/
|
|
172
|
+
this.retriedOnce = false;
|
|
110
173
|
/** @type {Promise<void>|null} 재접속 진행 중이면 그 프라미스 */
|
|
111
174
|
this.reconnecting = null;
|
|
175
|
+
/**
|
|
176
|
+
* Plan 4g: 재접속 백오프를 인스턴스에 남긴다. #reconnect 호출마다 새로 세면
|
|
177
|
+
* "접속 성공 → 곧바로 끊김"이 반복될 때 대기도 포기도 없이 돌게 된다.
|
|
178
|
+
* null이면 다음 #reconnect가 각각 250ms·now + reconnectTotalMs로 새로 잡는다.
|
|
179
|
+
* @type {number|null}
|
|
180
|
+
*/
|
|
181
|
+
this.backoffDelay = null;
|
|
182
|
+
/** @type {number|null} */
|
|
183
|
+
this.backoffDeadline = null;
|
|
112
184
|
this.#closedSignal = new Promise((resolve) => {
|
|
113
185
|
this.#resolveClosed = resolve;
|
|
114
186
|
});
|
|
115
|
-
this.#attach(ws);
|
|
187
|
+
this.#attach(ws, token);
|
|
116
188
|
}
|
|
117
189
|
|
|
118
|
-
#attach(ws) {
|
|
190
|
+
#attach(ws, token) {
|
|
119
191
|
// 이전 소켓의 리스너를 떼어낸다: 늦게 도착하는 error/close가 새 연결의
|
|
120
192
|
// pending 요청을 잘못 실패시키는 것을 막는다 (기존 소켓이 없으면 no-op).
|
|
121
193
|
this.ws?.removeAllListeners();
|
|
194
|
+
// 허브는 인증 못 한 연결도 열어 둔다(Plan 4f) — 재시도로 갈아탈 때 옛 소켓이 새지 않게 닫는다
|
|
195
|
+
this.ws?.terminate();
|
|
122
196
|
this.ws = ws;
|
|
197
|
+
/** Plan 4g: 이 연결이 붙은 시각 — stableMs 안에 끊기면 실패한 시도로 센다 */
|
|
198
|
+
this.attachedAt = Date.now();
|
|
199
|
+
/** @type {string|undefined} 이 연결이 업그레이드 헤더로 보낸 토큰 */
|
|
200
|
+
this.attachedToken = token;
|
|
201
|
+
// 표시는 연결 단위 — 옛 연결의 거절이 새 연결을 dead 로 만들지 않게
|
|
202
|
+
this.lastUnauthorized = null;
|
|
123
203
|
ws.on("message", (raw) => {
|
|
124
204
|
let msg;
|
|
125
205
|
try {
|
|
@@ -130,9 +210,16 @@ export class HubClient extends EventEmitter {
|
|
|
130
210
|
const entry = this.pending.get(msg.id);
|
|
131
211
|
if (!entry) return;
|
|
132
212
|
this.pending.delete(msg.id);
|
|
133
|
-
msg.ok
|
|
134
|
-
|
|
135
|
-
|
|
213
|
+
if (msg.ok) {
|
|
214
|
+
entry.resolve(msg.payload);
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
const message = msg.error?.message ?? "hub error";
|
|
218
|
+
// Plan 4g: 토큰이 틀렸다는 응답은 같은 토큰으로 재시도해도 결과가 같다.
|
|
219
|
+
// 어느 연결에서 받았는지(토큰)까지 남겨 #reconnect 가 판단한다.
|
|
220
|
+
if (message.startsWith("unauthorized:"))
|
|
221
|
+
this.lastUnauthorized = { message, token: this.attachedToken };
|
|
222
|
+
entry.reject(new Error(message));
|
|
136
223
|
});
|
|
137
224
|
ws.on("close", () => this.#onLost(new Error("hub connection closed")));
|
|
138
225
|
ws.on("error", (err) =>
|
|
@@ -148,6 +235,13 @@ export class HubClient extends EventEmitter {
|
|
|
148
235
|
#onLost(err) {
|
|
149
236
|
this.#failAll(err);
|
|
150
237
|
if (this.closed || this.dead || this.reconnecting || !this.home) return;
|
|
238
|
+
// Plan 4g: stableMs 이상 유지된 연결이 끊긴 것이면 정상적인 한 번의 끊김으로 보고
|
|
239
|
+
// 사다리를 초기화한다(허브 재시작 같은 흔한 경우는 지금처럼 곧바로 재접속한다).
|
|
240
|
+
// 그보다 짧게 살고 끊겼으면 실패한 시도로 보고 대기·총 제한을 이어서 쓴다.
|
|
241
|
+
if (Date.now() - this.attachedAt >= this.stableMs) {
|
|
242
|
+
this.backoffDelay = null;
|
|
243
|
+
this.backoffDeadline = null;
|
|
244
|
+
}
|
|
151
245
|
this.reconnecting = this.#reconnect().finally(() => {
|
|
152
246
|
this.reconnecting = null;
|
|
153
247
|
});
|
|
@@ -159,19 +253,38 @@ export class HubClient extends EventEmitter {
|
|
|
159
253
|
}
|
|
160
254
|
|
|
161
255
|
async #reconnect() {
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
256
|
+
// Plan 4g: 대기와 총 제한은 인스턴스에 남는다. 직전 시도가 실패했다면
|
|
257
|
+
// (접속 실패든, stableMs 전에 끊긴 연결이든) 다음 시도 전에 그 대기를 먼저
|
|
258
|
+
// 치른다 — 접속이 성공하면 루프가 곧바로 반환하므로, 대기를 루프 끝에만
|
|
259
|
+
// 두면 사다리가 전혀 올라가지 않는다(2026-09-17 폭주의 핵심).
|
|
260
|
+
this.backoffDeadline ??= Date.now() + this.reconnectTotalMs;
|
|
261
|
+
let retryNow = false;
|
|
262
|
+
while (Date.now() < this.backoffDeadline && !this.closed) {
|
|
263
|
+
if (retryNow) {
|
|
264
|
+
retryNow = false; // 직전 회차가 "허브 교체" 판정: 대기 없이 곧장 다시 시도한다
|
|
265
|
+
} else if (this.backoffDelay === null) {
|
|
266
|
+
this.backoffDelay = 250; // 첫 시도는 대기 없이
|
|
267
|
+
} else {
|
|
268
|
+
// 대기는 close()가 즉시 깨울 수 있어야 한다
|
|
269
|
+
await raceSleep(this.#closedSignal, this.backoffDelay);
|
|
270
|
+
if (this.closed) return;
|
|
271
|
+
// 대기 도중 총 제한이 지났으면 한 번 더 시도하지 않고 포기 경로로 간다
|
|
272
|
+
if (Date.now() >= this.backoffDeadline) break;
|
|
273
|
+
this.backoffDelay = Math.min(
|
|
274
|
+
this.backoffDelay * 2,
|
|
275
|
+
RECONNECT_MAX_DELAY_MS,
|
|
276
|
+
);
|
|
277
|
+
}
|
|
165
278
|
try {
|
|
166
279
|
const { port, info } = await ensureHub({ home: this.home });
|
|
167
280
|
if (this.closed) return; // close()가 ensureHub 대기 중에 호출됨
|
|
168
|
-
const ws = await tryConnect(port, 3000);
|
|
281
|
+
const ws = await tryConnect(port, 3000, info.token);
|
|
169
282
|
if (this.closed) {
|
|
170
283
|
ws?.terminate(); // close()가 tryConnect 대기 중에 호출됨: 새 소켓을 붙이지 않는다
|
|
171
284
|
return;
|
|
172
285
|
}
|
|
173
286
|
if (ws) {
|
|
174
|
-
this.#attach(ws);
|
|
287
|
+
this.#attach(ws, info.token);
|
|
175
288
|
this.stale = staleFrom(info, port);
|
|
176
289
|
// emit 대신 리스너를 직접 호출해 반환 프라미스를 기다린다: 이렇게 하면
|
|
177
290
|
// this.reconnecting은 리스너(도구 계층의 채널 재join)가 끝난 뒤에야
|
|
@@ -187,22 +300,38 @@ export class HubClient extends EventEmitter {
|
|
|
187
300
|
// 계속 non-null로 남아 #onLost가 이후의 모든 끊김을 무시하게 된다 —
|
|
188
301
|
// BARRIER_TIMEOUT_MS로 상한을 둬서 그 사태를 막는다(리스너 자체는
|
|
189
302
|
// 백그라운드에서 계속 돌아가지만 결과는 기다리지 않는다).
|
|
190
|
-
await
|
|
303
|
+
await raceSleep(
|
|
191
304
|
Promise.allSettled(
|
|
192
305
|
this.rawListeners("reconnected").map((fn) =>
|
|
193
306
|
Promise.resolve().then(() => fn({ port })),
|
|
194
307
|
),
|
|
195
308
|
),
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
309
|
+
BARRIER_TIMEOUT_MS,
|
|
310
|
+
);
|
|
311
|
+
const verdict = this.#afterBarrier();
|
|
312
|
+
if (verdict === "ok") {
|
|
313
|
+
// Plan 4g: 배리어 도중 새 소켓이 닫히면 #onLost 는 this.reconnecting 때문에 그냥
|
|
314
|
+
// 돌아간다. 여기서 "ok" 로 끝내면 CLOSED 소켓만 남아 dead 도 재접속도 아닌 채
|
|
315
|
+
// 모든 요청이 실패한다 — 실패한 시도로 보고 루프를 잇는다(다음 회차가 백오프 대기).
|
|
316
|
+
if (this.ws.readyState === WebSocket.OPEN) return;
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
if (verdict === "dead") {
|
|
320
|
+
if (!this.closed) {
|
|
321
|
+
this.dead = true;
|
|
322
|
+
this.emit("dead");
|
|
323
|
+
}
|
|
324
|
+
// 허브는 인증 못 한 연결을 열어 둔다(Plan 4f) — 도구 재시작까지 소켓이 남지 않게 닫는다.
|
|
325
|
+
// dead 를 먼저 세웠으므로 이 끊김의 #onLost 는 재접속하지 않는다.
|
|
326
|
+
this.ws.terminate();
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
retryNow = true; // "retry": 허브가 막 교체됐다 — 대기 없이 한 번 더
|
|
330
|
+
continue;
|
|
199
331
|
}
|
|
200
332
|
} catch {
|
|
201
333
|
// 허브가 아직 없음: 재시도
|
|
202
334
|
}
|
|
203
|
-
// 다음 시도까지의 대기는 close()가 즉시 깨울 수 있어야 한다
|
|
204
|
-
await Promise.race([sleep(delay), this.#closedSignal]);
|
|
205
|
-
delay = Math.min(delay * 2, RECONNECT_MAX_DELAY_MS);
|
|
206
335
|
}
|
|
207
336
|
if (!this.closed) {
|
|
208
337
|
this.dead = true;
|
|
@@ -210,15 +339,46 @@ export class HubClient extends EventEmitter {
|
|
|
210
339
|
}
|
|
211
340
|
}
|
|
212
341
|
|
|
342
|
+
/**
|
|
343
|
+
* 재접속 직후(배리어 뒤) 이 연결을 쓸 수 있는지 판정한다. Plan 4g.
|
|
344
|
+
* @returns {"ok"|"retry"|"dead"} retry: 락의 토큰이 이미 바뀌었다(허브가 막 교체됨) —
|
|
345
|
+
* 대기 없이 한 번 더 돈다. dead: 같은 토큰이 그대로 거절됐다 — 기다려도 같다.
|
|
346
|
+
*/
|
|
347
|
+
#afterBarrier() {
|
|
348
|
+
const unauth = this.lastUnauthorized;
|
|
349
|
+
if (!unauth || unauth.token !== this.attachedToken) {
|
|
350
|
+
this.retriedOnce = false; // 정상 연결: 재시도 기회를 되찾는다
|
|
351
|
+
return "ok";
|
|
352
|
+
}
|
|
353
|
+
this.lastUnauthorized = null;
|
|
354
|
+
const lockToken = readLock(this.home)?.token;
|
|
355
|
+
// 허브 교체로 보이는 첫 거절만 재시도한다 — 토큰이 또 바뀌어도 두 번째부터는 포기
|
|
356
|
+
if (lockToken && lockToken !== unauth.token && !this.retriedOnce) {
|
|
357
|
+
this.retriedOnce = true;
|
|
358
|
+
return "retry";
|
|
359
|
+
}
|
|
360
|
+
this.deadReason = unauth.message;
|
|
361
|
+
return "dead";
|
|
362
|
+
}
|
|
363
|
+
|
|
213
364
|
/**
|
|
214
365
|
* 허브에 접속한다. 허브 프로토콜이 커넥터보다 낮으면 `stale`에 기록하되 접속은 유지한다.
|
|
215
|
-
* @param {{home?: string, reconnectTotalMs?: number}} [opts] @returns {Promise<HubClient>}
|
|
366
|
+
* @param {{home?: string, reconnectTotalMs?: number, stableMs?: number}} [opts] @returns {Promise<HubClient>}
|
|
216
367
|
*/
|
|
217
|
-
static async connect({
|
|
368
|
+
static async connect({
|
|
369
|
+
home = pluriplyHome(),
|
|
370
|
+
reconnectTotalMs,
|
|
371
|
+
stableMs,
|
|
372
|
+
} = {}) {
|
|
218
373
|
const { port, info } = await ensureHub({ home });
|
|
219
|
-
const ws = await tryConnect(port, 3000);
|
|
374
|
+
const ws = await tryConnect(port, 3000, info.token);
|
|
220
375
|
if (!ws) throw new Error("could not connect to pluriply hub");
|
|
221
|
-
const client = new HubClient(ws, {
|
|
376
|
+
const client = new HubClient(ws, {
|
|
377
|
+
home,
|
|
378
|
+
reconnectTotalMs,
|
|
379
|
+
stableMs,
|
|
380
|
+
token: info.token,
|
|
381
|
+
});
|
|
222
382
|
client.stale = staleFrom(info, port);
|
|
223
383
|
return client;
|
|
224
384
|
}
|
|
@@ -241,13 +401,18 @@ export class HubClient extends EventEmitter {
|
|
|
241
401
|
payload = {},
|
|
242
402
|
{ duringReconnect = false, timeoutMs } = {},
|
|
243
403
|
) {
|
|
244
|
-
if (this.dead)
|
|
404
|
+
if (this.dead)
|
|
405
|
+
throw new Error(
|
|
406
|
+
this.deadReason
|
|
407
|
+
? `hub unreachable; restart the tool (${this.deadReason})`
|
|
408
|
+
: "hub unreachable; restart the tool",
|
|
409
|
+
);
|
|
245
410
|
// readyState만으로는 부족하다: #reconnect가 #attach로 소켓을 OPEN 상태로
|
|
246
411
|
// 바꾼 뒤에도 "reconnected" 리스너(채널 재join)가 끝날 때까지 this.reconnecting은
|
|
247
412
|
// non-null로 남아있다. 그 틈에 나간 request()가 재join보다 먼저 허브에 도착하는
|
|
248
413
|
// 것을 막으려면 readyState와 무관하게 reconnecting이 있으면 기다려야 한다.
|
|
249
414
|
if (this.reconnecting && !duringReconnect) {
|
|
250
|
-
await
|
|
415
|
+
await raceSleep(this.reconnecting, REQUEST_WAIT_MS);
|
|
251
416
|
}
|
|
252
417
|
if (this.ws.readyState !== WebSocket.OPEN)
|
|
253
418
|
throw new Error("hub connection closed");
|
|
@@ -268,7 +433,7 @@ export class HubClient extends EventEmitter {
|
|
|
268
433
|
this.reconnecting &&
|
|
269
434
|
!duringReconnect
|
|
270
435
|
) {
|
|
271
|
-
await
|
|
436
|
+
await raceSleep(this.reconnecting, REQUEST_WAIT_MS);
|
|
272
437
|
if (this.ws.readyState === WebSocket.OPEN)
|
|
273
438
|
return this.#send(type, payload, timeoutMs);
|
|
274
439
|
}
|
|
@@ -319,9 +484,9 @@ export class HubClient extends EventEmitter {
|
|
|
319
484
|
export async function connectIfLive({ home = pluriplyHome() } = {}) {
|
|
320
485
|
const live = await liveHub(home);
|
|
321
486
|
if (!live) return null;
|
|
322
|
-
const ws = await tryConnect(live.port, 3000);
|
|
487
|
+
const ws = await tryConnect(live.port, 3000, live.token);
|
|
323
488
|
if (!ws) return null;
|
|
324
|
-
const client = new HubClient(ws, {});
|
|
489
|
+
const client = new HubClient(ws, { token: live.token });
|
|
325
490
|
client.stale = staleFrom(live, live.port);
|
|
326
491
|
return client;
|
|
327
492
|
}
|
package/src/connector/tools.js
CHANGED
|
@@ -8,7 +8,6 @@ function ok(data) {
|
|
|
8
8
|
};
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
-
|
|
12
11
|
/**
|
|
13
12
|
* MCP 도구 annotations. 클라이언트(특히 codex)는 annotations 가 없는 도구를 "파괴적·외부 접근"으로
|
|
14
13
|
* 간주해 비대화 실행에서 승인을 요구한다(readOnlyHint=false, destructiveHint=true 가 기본값).
|
|
@@ -181,17 +180,20 @@ export function registerTools(server, hub, { agent, instanceId }) {
|
|
|
181
180
|
};
|
|
182
181
|
}
|
|
183
182
|
|
|
184
|
-
// 허브가 재시작되면
|
|
183
|
+
// 허브가 재시작되면 정체성을 다시 알리고, 채널이 있으면 다시 참여한다 (hub-client가 reconnected를 낸다)
|
|
185
184
|
if (typeof hub.on === "function") {
|
|
186
185
|
hub.on("reconnected", async () => {
|
|
187
|
-
if (!state.currentChannel) return;
|
|
188
186
|
try {
|
|
189
187
|
// duringReconnect: true — 이 리스너 자체가 hub-client의 재접속 배리어이므로,
|
|
190
188
|
// 여기서 나가는 request()가 this.reconnecting을 기다리면 자기 자신을
|
|
191
189
|
// 기다리는 교착 상태가 된다.
|
|
192
|
-
// 새 소켓은 정체성이 없으므로
|
|
193
|
-
// ID를 다시 인정받아야
|
|
190
|
+
// 새 소켓은 정체성이 없으므로 채널 유무와 관계없이 먼저 hello로 인스턴스
|
|
191
|
+
// ID를 다시 인정받아야 한다. 채널이 없다고 건너뛰면 이후의 자동 복귀·
|
|
192
|
+
// join_channel이 도구를 다시 시작할 때까지 "say hello first"로 막힌다.
|
|
193
|
+
// (hello는 인증이 필요한 요청이라, 토큰이 틀린 연결은 여기서 unauthorized를
|
|
194
|
+
// 받아 hub-client의 재접속 판정이 그것을 본다.)
|
|
194
195
|
await hello(hub, { agent, worker, instanceId, duringReconnect: true });
|
|
196
|
+
if (!state.currentChannel) return;
|
|
195
197
|
await hub.request(
|
|
196
198
|
"channel.join",
|
|
197
199
|
{ channelCode: state.currentChannel },
|
|
@@ -410,7 +412,13 @@ export function registerTools(server, hub, { agent, instanceId }) {
|
|
|
410
412
|
hint: created.hint ?? `dispatch: ${created.dispatch}`,
|
|
411
413
|
};
|
|
412
414
|
}
|
|
413
|
-
return waitForTask({
|
|
415
|
+
return waitForTask({
|
|
416
|
+
code,
|
|
417
|
+
taskId,
|
|
418
|
+
waitS,
|
|
419
|
+
extra,
|
|
420
|
+
label: `${to} worker`,
|
|
421
|
+
});
|
|
414
422
|
},
|
|
415
423
|
),
|
|
416
424
|
);
|
|
@@ -429,7 +437,9 @@ export function registerTools(server, hub, { agent, instanceId }) {
|
|
|
429
437
|
inputSchema: {
|
|
430
438
|
to: z
|
|
431
439
|
.string()
|
|
432
|
-
.describe(
|
|
440
|
+
.describe(
|
|
441
|
+
'Tool name (e.g. "codex") or instanceId (e.g. "codex#k7pq")',
|
|
442
|
+
),
|
|
433
443
|
request: z
|
|
434
444
|
.string()
|
|
435
445
|
.optional()
|
package/src/hub/index.js
CHANGED
|
@@ -1,14 +1,21 @@
|
|
|
1
1
|
// Copyright (c) 2026 TQSoft. All rights reserved.
|
|
2
2
|
// Licensed under LICENSE-HUB.md — not open source.
|
|
3
|
-
import{WebSocketServer as He}from"ws";import{mkdirSync as Fe,writeFileSync as Ue,rmSync as xt,readFileSync as Be}from"node:fs";import{join as At,isAbsolute as Ot}from"node:path";import{mkdirSync as qt,readFileSync as ct,writeFileSync as lt,renameSync as ut,existsSync as ht,readdirSync as U,rmSync as dt}from"node:fs";import{join as T}from"node:path";import{pluriplyHome as Kt}from"../shared/paths.js";var B=/^plp-[a-z0-9]{4}-[a-z0-9]{4}$/,D=class{constructor(e=Kt()){this.root=e,this.dir=T(e,"channels"),qt(this.dir,{recursive:!0}),this.#t()}#t(){for(let e of U(this.dir))e.endsWith(".tmp")&&dt(T(this.dir,e),{force:!0});for(let e of U(this.root))e.startsWith("agents.json.")&&e.endsWith(".tmp")&&dt(T(this.root,e),{force:!0})}loadChannel(e){if(!B.test(e))throw new Error(`invalid channel code: ${e}`);let t=T(this.dir,`${e}.json`);return ht(t)?JSON.parse(ct(t,"utf8")):null}saveChannel(e,t){if(!B.test(e))throw new Error(`invalid channel code: ${e}`);let n=T(this.dir,`${e}.json`),r=`${n}.${process.pid}.tmp`;lt(r,JSON.stringify(t,null,2)),ut(r,n)}listChannels(){return U(this.dir).filter(e=>e.endsWith(".json")).map(e=>e.slice(0,-5)).filter(e=>B.test(e))}loadAgents(){let e=T(this.root,"agents.json");if(!ht(e))return{};try{let t=JSON.parse(ct(e,"utf8"));return t&&typeof t=="object"&&!Array.isArray(t)?t:{}}catch{return{}}}saveAgents(e){let t=T(this.root,"agents.json"),n=`${t}.${process.pid}.tmp`;lt(n,JSON.stringify(e,null,2)),ut(n,t)}};import{channelCode as Ft}from"../shared/ids.js";var M=class{constructor(e){this.store=e}touch(e,t,n=new Date){try{let r=this.store.loadAgents();r[e]={lastChannel:t,lastSeenAt:n.toISOString()},this.store.saveAgents(r)}catch{}}resume(e,t=new Date){let n=this.store.loadAgents()[e];if(!n)return null;let r=t.getTime()-Date.parse(n.lastSeenAt);if(!(r>=0&&r<=432e5))return null;try{if(!this.store.loadChannel(n.lastChannel))return null}catch{return null}return n.lastChannel}};var Y=class extends Error{constructor(e){super(`channel not found: ${e}`),this.code=e}},N=class{constructor(e){this.store=e}create(){let e={channel:{code:Ft(),createdAt:new Date().toISOString(),peers:[]},tasks:[],context:[]};return this.store.saveChannel(e.channel.code,e),e}get(e){let t=this.store.loadChannel(e);if(!t)throw new Y(e);return t}save(e,t){this.store.saveChannel(e,t)}join(e,{instanceId:t,tool:n,worker:r=!1},{online:s=new Set,now:o=new Date}={}){let c=this.get(e);this.#t(c,s,o);let l=o.toISOString(),u=c.channel.peers.find(h=>h.instanceId===t);return u?u.lastSeenAt=l:c.channel.peers.push({instanceId:t,tool:n,worker:!!r,joinedAt:l,lastSeenAt:l}),this.save(e,c),{channel:c.channel,peers:c.channel.peers}}peers(e,{online:t=new Set,now:n=new Date}={}){let r=this.get(e);return this.#t(r,t,n)&&this.save(e,r),r.channel.peers}#t(e,t,n){let r=e.channel.peers.length;return e.channel.peers=e.channel.peers.filter(s=>{if(!s.instanceId)return!1;if(t.has(s.instanceId))return!0;let o=n.getTime()-Date.parse(s.lastSeenAt);return o>=0&&o<=432e5}),e.channel.peers.length!==r}};import{statSync as Ut,realpathSync as P}from"node:fs";import{sep as Bt,join as Yt,isAbsolute as Jt}from"node:path";import{taskId as zt}from"../shared/ids.js";import{parseTarget as ft,toolOf as gt,isInstanceId as Vt}from"../shared/identity.js";var J=new Set(["completed","failed","cancelled"]),C=class extends Error{constructor(e){super(`task not found: ${e}`)}},A=class extends Error{constructor(e,t){super(`invalid task transition: ${e} -> ${t}`)}},j=class extends Error{constructor(e,t,n){super(`task ${e} is addressed to "${t}", not "${n}"`)}},z=class extends Error{constructor(e,t,n){super(`only the sender "${t}" can cancel task ${e}, not "${n}"`)}},b=class extends Error{constructor(e){super(e)}},V=class extends Error{constructor(e){super(`delegation depth limit (${e}) exceeded`)}},Xt=["task","review"],mt=["approve","request_changes","comment"],pt=["critical","important","minor"],$=class extends Error{constructor(e){super(e)}},_=class extends Error{constructor(e){super(e)}},X=class extends Error{constructor(e){super(`use submit_review for review tasks (task ${e})`)}},Q=class extends Error{constructor(e){super(`task ${e} is not a review`)}},Z=class extends Error{constructor(e){super(`cwd is not an existing directory: ${e}`)}},tt=class extends Error{constructor(e){super(`cwd outside allowed roots: ${e}`)}},Qt=["auto","spawn","interactive"];function yt(i,e){return i===e||i.startsWith(e+Bt)}function Zt(i,e){if(!i||typeof i!="object"||Array.isArray(i))throw new $("review must be an object");let t={};if(i.gitRange!==void 0){if(typeof i.gitRange!="string"||i.gitRange.length===0||i.gitRange.length>200||/[\r\n]/.test(i.gitRange))throw new $("gitRange must be a single line of at most 200 characters");if(i.gitRange.startsWith("-"))throw new $("gitRange must not start with '-'");t.gitRange=i.gitRange}if(i.paths!==void 0){if(!Array.isArray(i.paths)||i.paths.some(n=>typeof n!="string"||n.length===0))throw new $("paths must be an array of strings");if(i.paths.length>0){if(e===void 0)throw new $("paths need a cwd to resolve against");let n=e;try{n=P(e)}catch{}t.paths=i.paths.map(r=>{let s=Jt(r)?r:Yt(e,r),o;try{o=P(s)}catch{throw new $(`path does not exist: ${r}`)}if(!yt(o,n))throw new $(`path outside cwd: ${r}`);return o})}}if(i.focus!==void 0){if(typeof i.focus!="string"||i.focus.length>500)throw new $("focus must be a string of at most 500 characters");i.focus.length>0&&(t.focus=i.focus)}return t}function te(i){return`Review ${i.gitRange?`git range ${i.gitRange}`:i.paths?.length?`files ${i.paths.join(", ")}`:"the uncommitted changes (git diff HEAD)"}${i.focus?`, focusing on ${i.focus}`:""}.`}function ee(i){if(!i||typeof i!="object"||Array.isArray(i))throw new _("review result must be an object");if(!mt.includes(i.verdict))throw new _(`verdict must be one of ${mt.join(", ")}`);if(typeof i.summary!="string"||i.summary.trim().length===0)throw new _("summary is required");let e=i.findings??[];if(!Array.isArray(e))throw new _("findings must be an array");if(e.length>200)throw new _("findings must have at most 200 items");let t=e.map((n,r)=>{if(!n||typeof n!="object"||Array.isArray(n))throw new _(`findings[${r}] must be an object`);if(!pt.includes(n.severity))throw new _(`findings[${r}].severity must be one of ${pt.join(", ")}`);if(typeof n.message!="string"||n.message.length===0)throw new _(`findings[${r}].message is required`);let s={severity:n.severity,message:n.message};if(n.file!==void 0){if(typeof n.file!="string")throw new _(`findings[${r}].file must be a string`);s.file=n.file}if(n.line!==void 0){if(!Number.isInteger(n.line)||n.line<1)throw new _(`findings[${r}].line must be a positive integer`);s.line=n.line}if(n.suggestion!==void 0){if(typeof n.suggestion!="string")throw new _(`findings[${r}].suggestion must be a string`);s.suggestion=n.suggestion}return s});return{verdict:i.verdict,findings:t,summary:i.summary}}function wt(i,e){return i.toInstance?e===i.toInstance:gt(e)===(i.toTool??i.to)}function ne(i,e){return i.from===e?!0:!i.from.includes("#")&>(e)===i.from}var G=class{constructor(e){this.registry=e,this.waiters=new Map}create(e,{from:t,to:n,request:r,attachments:s=[],depth:o=0,cwd:c,origin:l,allowedRoots:u=[],mode:h="auto",maxDepth:d=2,online:p=new Set,kind:w="task",review:y,fromCwdKey:k}){if(typeof n!="string"||n.length===0)throw new b("target agent name is required");if(n.includes("#")&&!Vt(n))throw new b(`no peer "${n}" on this channel`);let g=ft(n);if(g.instance!==null&&g.instance===t)throw new b("cannot delegate a task to yourself");if((!Number.isInteger(o)||o<0)&&(o=0),o>d)throw new V(d);if(!Qt.includes(h))throw new Error(`invalid mode: ${h}`);if(c!==void 0){let S=!1;try{S=Ut(c).isDirectory()}catch{S=!1}if(!S)throw new Z(c);let x=P(c),H=[];if(l!==void 0)try{H.push(P(l))}catch{}for(let F of u)try{H.push(P(F))}catch{}if(!H.some(F=>yt(x,F)))throw new tt(c);c=x}if(!Xt.includes(w))throw new Error(`invalid kind: ${w}`);let v;if(w==="review")v=Zt(y??{},c??l),(typeof r!="string"||r.length===0)&&(r=te(v));else if(y!==void 0)throw new $('review is only valid for kind "review"');let f=this.registry.get(e),a=f.channel.peers,m=a.filter(S=>S.tool===g.tool),I;if(g.instance===null){if(I=m.length>0,!I){let S=a.find(x=>x.tool?.toLowerCase()===g.tool.toLowerCase());if(S)throw new b(`no peer named "${n}" on this channel; did you mean "${S.tool}"?`)}}else if(I=m.some(S=>S.instanceId===g.instance),!I&&m.length>0){let S=m.find(x=>p.has(x.instanceId))??m[0];throw new b(`no peer "${n}" on this channel; did you mean "${S.instanceId}"?`)}let at=new Date().toISOString(),R={taskId:zt(),from:t,to:n,toTool:g.tool,request:r,attachments:s,depth:o,mode:h,kind:w,status:"submitted",result:null,createdAt:at,updatedAt:at};g.instance!==null&&(R.toInstance=g.instance),c!==void 0&&(R.cwd=c),typeof k=="string"&&k.length>0&&(R.fromCwdKey=k),w==="review"&&(R.review=v),f.tasks.push(R),this.registry.save(e,f);let O={task:R,targetJoined:I};return g.instance!==null&&(O.targetOnline=p.has(g.instance),I&&!O.targetOnline&&(O.warning=`"${n}" is not online; the task will wait until it reconnects.`)),I||(O.warning=`"${n}" has not joined this channel yet; the task will wait until it joins.`),O}list(e,{to:t,from:n,status:r,kind:s}={}){let o=this.registry.get(e).tasks;if(t){let c=ft(t);o=o.filter(l=>c.instance===null?(l.toTool??l.to)===c.tool:l.toInstance===c.instance||!l.toInstance&&(l.toTool??l.to)===c.tool)}return n&&(o=o.filter(c=>c.from===n)),r&&(o=o.filter(c=>c.status===r)),s&&(o=o.filter(c=>(c.kind??"task")===s)),o}get(e,t){let n=this.registry.get(e).tasks.find(r=>r.taskId===t);if(!n)throw new C(t);return n}#t(e,t,n){let r=this.registry.get(e),s=r.tasks.find(c=>c.taskId===t);if(!s)throw new C(t);let o=s.status;return n(s),s.updatedAt=new Date().toISOString(),this.registry.save(e,r),s.status!==o&&this.#r(e,t,s),s}#r(e,t,n){let r=this.waiters.get(`${e}/${t}`);if(r){this.waiters.delete(`${e}/${t}`);for(let s of r)s(n)}}waitFor(e,t,n,{signal:r}={}){let s=this.get(e,t);if(J.has(s.status))return Promise.resolve(s);if(r?.aborted)return Promise.resolve(null);let o=`${e}/${t}`;return new Promise(c=>{let l,u=this.waiters.get(o)??new Set;this.waiters.set(o,u);let h=p=>{clearTimeout(l),r?.removeEventListener("abort",d),u.delete(h),u.size===0&&this.waiters.get(o)===u&&this.waiters.delete(o),c(p)},d=()=>h(null);u.add(h),r?.addEventListener("abort",d,{once:!0}),l=setTimeout(()=>{let p=s;try{p=this.get(e,t)}catch{}h(p)},n)})}claim(e,t,n){return this.#t(e,t,r=>{if(!wt(r,n))throw new j(t,r.to,n);if(r.status!=="submitted")throw new A(r.status,"working");r.status="working"})}complete(e,t,{from:n,result:r,status:s="completed",worker:o=!1,review:c}){if(s!=="completed"&&s!=="failed")throw new A("?",s);return this.#t(e,t,l=>{if(!wt(l,n))throw new j(t,l.to,n);if(J.has(l.status))throw new A(l.status,s);let u=(l.kind??"task")==="review";if(!u&&c!==void 0)throw new Q(t);if(u&&s==="completed"){if(c===void 0)throw new X(t);r=ee(c)}l.status=s,l.result=r,l.completedBy=n,o?l.completedByWorker=!0:delete l.completedByWorker})}cancel(e,t,{agent:n,reason:r}){return this.#t(e,t,s=>{if(!ne(s,n))throw new z(t,s.from,n);if(s.status!=="submitted"&&s.status!=="working")throw new A(s.status,"cancelled");s.status="cancelled",s.result=r??null,s.cancelledBy=n})}markHookDelivered(e,t,n,r=new Date){let s=this.registry.get(e),o=s.tasks.find(c=>c.taskId===t);if(!o)throw new C(t);return o.hookDelivered={...o.hookDelivered??{},[n]:r.toISOString()},this.registry.save(e,s),o}setWorker(e,t,n){return this.#t(e,t,r=>{r.worker={...r.worker??{},...n}})}failIfOpen(e,t,{result:n,by:r}){return this.#t(e,t,s=>{J.has(s.status)||(s.status="failed",s.result=n,s.completedBy=r)})}};import{entryId as re}from"../shared/ids.js";var W=class{constructor(e){this.registry=e}add(e,{from:t,summary:n,artifacts:r=[]}){let s=this.registry.get(e),o={entryId:re(),from:t,summary:n,artifacts:r,at:new Date().toISOString()};return s.context.push(o),this.registry.save(e,s),o}list(e,{limit:t}={}){let n=this.registry.get(e).context;return Number.isInteger(t)&&t>0?n.slice(-t):n}};import{spawn as Te}from"node:child_process";import{mkdirSync as Rt,openSync as be,closeSync as Re,readFileSync as xe,appendFileSync as nt,readdirSync as Ae,rmSync as Oe}from"node:fs";import{mkdir as Pe,rm as rt}from"node:fs/promises";import{join as E}from"node:path";import{workerEnabled as Ce,TEMPLATE_AGENTS as Le}from"../shared/config.js";import{join as kt}from"node:path";import{fileURLToPath as se}from"node:url";import{agyCommand as ie}from"../shared/agy.js";import{DEFAULT_LIMITS as oe}from"../shared/config.js";var ae=se(new URL("../../bin/pluriply.js",import.meta.url)),ce=["acceptEdits","bypassPermissions"],le=["mcp__pluriply__join_channel","mcp__pluriply__channel_status","mcp__pluriply__list_peers","mcp__pluriply__list_tasks","mcp__pluriply__get_task_result","mcp__pluriply__submit_review","mcp__pluriply__submit_result","mcp__pluriply__share_update","mcp__pluriply__get_channel_context"];function et(){if(process.env.PLURIPLY_ALLOW_TEMPLATE_OVERRIDE!=="1")return null;let i=process.env.PLURIPLY_WORKER_TEMPLATE_OVERRIDE;if(!i)return null;try{return JSON.parse(i)}catch(e){throw new Error(`PLURIPLY_WORKER_TEMPLATE_OVERRIDE is not valid JSON: ${e.message}`)}}function ue(i,e){let t=et()?.[i];if(!t)return;let n=r=>r.replace(/\{(taskId|channelCode|home|cwd|prompt|readOnly)\}/g,(s,o)=>String(e[o]));return{command:t.command,args:t.args.map(n)}}function It(i,{home:e,cwd:t,prompt:n,logDir:r,taskId:s,channelCode:o,taskDir:c=kt(r,s),permissionMode:l="acceptEdits",timeoutMs:u=oe.timeoutMs,readOnly:h=!1}){let d=ue(i,{taskId:s,channelCode:o,home:e,cwd:t,prompt:n,readOnly:h});if(d)return d;switch(i){case"codex":return{command:"codex",args:["exec","-C",t,"--skip-git-repo-check","-s",h?"read-only":"workspace-write","-c",'approval_policy="never"',"-o",kt(r,`${s}.last.md`),n]};case"claude-code":{if(!ce.includes(l))throw new Error(`invalid permissionMode "${l}" for claude-code worker`);let p=JSON.stringify({mcpServers:{pluriply:{command:process.execPath,args:[ae,"connector","--agent","claude-code"],env:{PLURIPLY_HOME:e}}}});return{command:"claude",args:["-p",...h?["--allowedTools",...le,"--permission-mode","default","--add-dir",c]:["--permission-mode",l],"--mcp-config",p,"--strict-mcp-config","--output-format","json",n]}}case"antigravity":return{command:ie(),args:["-p",n,...h?["--mode","plan"]:[],"--dangerously-skip-permissions","--output-format","text","--print-timeout",`${Math.ceil(u/1e3)}s`]};default:return null}}import{pidAlive as De}from"../shared/probe.js";import{writeFile as ye}from"node:fs/promises";import{execFile as he}from"node:child_process";import{promisify as de}from"node:util";var fe=de(he),me=[/^filter\..+\.(clean|smudge|process|required)$/,/^diff\..+\.(command|textconv)$/,/^merge\..+\.driver$/,/^core\.(hookspath|fsmonitor|sshcommand|pager|editor|askpass|gitproxy)$/,/^credential\.(.+\.)?helper$/,/^alias\..+$/,/^sequence\.editor$/,/^gpg\.(.+\.)?program$/],pe=["GIT_DIR","GIT_WORK_TREE","GIT_INDEX_FILE","GIT_CONFIG_PARAMETERS","GIT_CONFIG_COUNT","GIT_EXTERNAL_DIFF","GIT_SSH","GIT_SSH_COMMAND","GIT_ASKPASS","GIT_EDITOR","GIT_PAGER"];function we(){let i={...process.env};for(let e of pe)delete i[e];return i.GIT_CONFIG_GLOBAL=process.platform==="win32"?"NUL":"/dev/null",i.GIT_CONFIG_NOSYSTEM="1",i.GIT_ATTR_NOSYSTEM="1",i.GIT_TERMINAL_PROMPT="0",i}function ge(i){if(!i)return"";let e=String(i).trim().split(`
|
|
4
|
-
`).map(t=>t.trim()).filter(Boolean);return e.length===0?"":e.find(t=>/^(fatal|error):/i.test(t))??e.at(-1)}async function
|
|
5
|
-
`,1)[0];if(s.has(l))continue;let u=l.toLowerCase();
|
|
3
|
+
import{WebSocketServer as Fe}from"ws";import{mkdirSync as Be,writeFileSync as Ye,rmSync as Wt,readFileSync as ze,chmodSync as Je}from"node:fs";import{EventEmitter as Ve}from"node:events";import{connect as Xe}from"node:net";import{randomBytes as Qe,timingSafeEqual as Ze}from"node:crypto";import{join as it,isAbsolute as Pt}from"node:path";import{mkdirSync as Ht,readFileSync as ht,writeFileSync as dt,renameSync as ft,existsSync as mt,readdirSync as B,rmSync as pt}from"node:fs";import{join as $}from"node:path";import{pluriplyHome as Ut}from"../shared/paths.js";var Y=/^plp-[a-z0-9]{4}-[a-z0-9]{4}$/,D=class{constructor(e=Ut()){this.root=e,this.dir=$(e,"channels"),Ht(this.dir,{recursive:!0}),this.#t()}#t(){for(let e of B(this.dir))e.endsWith(".tmp")&&pt($(this.dir,e),{force:!0});for(let e of B(this.root))e.startsWith("agents.json.")&&e.endsWith(".tmp")&&pt($(this.root,e),{force:!0})}loadChannel(e){if(!Y.test(e))throw new Error(`invalid channel code: ${e}`);let t=$(this.dir,`${e}.json`);return mt(t)?JSON.parse(ht(t,"utf8")):null}saveChannel(e,t){if(!Y.test(e))throw new Error(`invalid channel code: ${e}`);let n=$(this.dir,`${e}.json`),r=`${n}.${process.pid}.tmp`;dt(r,JSON.stringify(t,null,2)),ft(r,n)}listChannels(){return B(this.dir).filter(e=>e.endsWith(".json")).map(e=>e.slice(0,-5)).filter(e=>Y.test(e))}loadAgents(){let e=$(this.root,"agents.json");if(!mt(e))return{};try{let t=JSON.parse(ht(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`;dt(n,JSON.stringify(e,null,2)),ft(n,t)}};import{channelCode as Bt}from"../shared/ids.js";var N=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 z=class extends Error{constructor(e){super(`channel not found: ${e}`),this.code=e}},j=class{constructor(e){this.store=e}create(){let e={channel:{code:Bt(),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 z(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 Yt,realpathSync as C}from"node:fs";import{sep as zt,join as Jt,isAbsolute as Vt}from"node:path";import{taskId as Xt}from"../shared/ids.js";import{parseTarget as wt,toolOf as It,isInstanceId as Qt}from"../shared/identity.js";var J=new Set(["completed","failed","cancelled"]),L=class extends Error{constructor(e){super(`task not found: ${e}`)}},O=class extends Error{constructor(e,t){super(`invalid task transition: ${e} -> ${t}`)}},W=class extends Error{constructor(e,t,n){super(`task ${e} is addressed to "${t}", not "${n}"`)}},V=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)}},X=class extends Error{constructor(e){super(`delegation depth limit (${e}) exceeded`)}},Zt=["task","review"],gt=["approve","request_changes","comment"],yt=["critical","important","minor"],T=class extends Error{constructor(e){super(e)}},_=class extends Error{constructor(e){super(e)}},Q=class extends Error{constructor(e){super(`use submit_review for review tasks (task ${e})`)}},Z=class extends Error{constructor(e){super(`task ${e} is not a review`)}},tt=class extends Error{constructor(e){super(`cwd is not an existing directory: ${e}`)}},et=class extends Error{constructor(e){super(`cwd outside allowed roots: ${e}`)}},te=["auto","spawn","interactive"];function _t(i,e){return i===e||i.startsWith(e+zt)}function ee(i,e){if(!i||typeof i!="object"||Array.isArray(i))throw new T("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 T("gitRange must be a single line of at most 200 characters");if(i.gitRange.startsWith("-"))throw new T("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 T("paths must be an array of strings");if(i.paths.length>0){if(e===void 0)throw new T("paths need a cwd to resolve against");let n=e;try{n=C(e)}catch{}t.paths=i.paths.map(r=>{let s=Vt(r)?r:Jt(e,r),o;try{o=C(s)}catch{throw new T(`path does not exist: ${r}`)}if(!_t(o,n))throw new T(`path outside cwd: ${r}`);return o})}}if(i.focus!==void 0){if(typeof i.focus!="string"||i.focus.length>500)throw new T("focus must be a string of at most 500 characters");i.focus.length>0&&(t.focus=i.focus)}return t}function ne(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 re(i){if(!i||typeof i!="object"||Array.isArray(i))throw new _("review result must be an object");if(!gt.includes(i.verdict))throw new _(`verdict must be one of ${gt.join(", ")}`);if(typeof i.summary!="string"||i.summary.trim().length===0)throw new _("summary is required");let e=i.findings??[];if(!Array.isArray(e))throw new _("findings must be an array");if(e.length>200)throw new _("findings must have at most 200 items");let t=e.map((n,r)=>{if(!n||typeof n!="object"||Array.isArray(n))throw new _(`findings[${r}] must be an object`);if(!yt.includes(n.severity))throw new _(`findings[${r}].severity must be one of ${yt.join(", ")}`);if(typeof n.message!="string"||n.message.length===0)throw new _(`findings[${r}].message is required`);let s={severity:n.severity,message:n.message};if(n.file!==void 0){if(typeof n.file!="string")throw new _(`findings[${r}].file must be a string`);s.file=n.file}if(n.line!==void 0){if(!Number.isInteger(n.line)||n.line<1)throw new _(`findings[${r}].line must be a positive integer`);s.line=n.line}if(n.suggestion!==void 0){if(typeof n.suggestion!="string")throw new _(`findings[${r}].suggestion must be a string`);s.suggestion=n.suggestion}return s});return{verdict:i.verdict,findings:t,summary:i.summary}}function kt(i,e){return i.toInstance?e===i.toInstance:It(e)===(i.toTool??i.to)}function se(i,e){return i.from===e?!0:!i.from.includes("#")&&It(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("#")&&!Qt(n))throw new b(`no peer "${n}" on this channel`);let g=wt(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 X(d);if(!te.includes(h))throw new Error(`invalid mode: ${h}`);if(c!==void 0){let S=!1;try{S=Yt(c).isDirectory()}catch{S=!1}if(!S)throw new tt(c);let R=C(c),U=[];if(l!==void 0)try{U.push(C(l))}catch{}for(let F of u)try{U.push(C(F))}catch{}if(!U.some(F=>_t(R,F)))throw new et(c);c=R}if(!Zt.includes(w))throw new Error(`invalid kind: ${w}`);let v;if(w==="review")v=ee(y??{},c??l),(typeof r!="string"||r.length===0)&&(r=ne(v));else if(y!==void 0)throw new T('review is only valid for kind "review"');let f=this.registry.get(e),a=f.channel.peers,m=a.filter(S=>S.tool===g.tool),I;if(g.instance===null){if(I=m.length>0,!I){let S=a.find(R=>R.tool?.toLowerCase()===g.tool.toLowerCase());if(S)throw new b(`no peer named "${n}" on this channel; did you mean "${S.tool}"?`)}}else if(I=m.some(S=>S.instanceId===g.instance),!I&&m.length>0){let S=m.find(R=>p.has(R.instanceId))??m[0];throw new b(`no peer "${n}" on this channel; did you mean "${S.instanceId}"?`)}let ut=new Date().toISOString(),x={taskId:Xt(),from:t,to:n,toTool:g.tool,request:r,attachments:s,depth:o,mode:h,kind:w,status:"submitted",result:null,createdAt:ut,updatedAt:ut};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 P={task:x,targetJoined:I};return g.instance!==null&&(P.targetOnline=p.has(g.instance),I&&!P.targetOnline&&(P.warning=`"${n}" is not online; the task will wait until it reconnects.`)),I||(P.warning=`"${n}" has not joined this channel yet; the task will wait until it joins.`),P}list(e,{to:t,from:n,status:r,kind:s}={}){let o=this.registry.get(e).tasks;if(t){let c=wt(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 L(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 L(t);let o=s.status;return n(s),s.updatedAt=new Date().toISOString(),this.registry.save(e,r),s.status!==o&&this.#s(e,t,s),s}#s(e,t,n){let r=this.waiters.get(`${e}/${t}`);if(r){this.waiters.delete(`${e}/${t}`);for(let s of r)s(n)}}waitFor(e,t,n,{signal:r}={}){let s=this.get(e,t);if(J.has(s.status))return Promise.resolve(s);if(r?.aborted)return Promise.resolve(null);let o=`${e}/${t}`;return new Promise(c=>{let l,u=this.waiters.get(o)??new Set;this.waiters.set(o,u);let h=p=>{clearTimeout(l),r?.removeEventListener("abort",d),u.delete(h),u.size===0&&this.waiters.get(o)===u&&this.waiters.delete(o),c(p)},d=()=>h(null);u.add(h),r?.addEventListener("abort",d,{once:!0}),l=setTimeout(()=>{let p=s;try{p=this.get(e,t)}catch{}h(p)},n)})}claim(e,t,n){return this.#t(e,t,r=>{if(!kt(r,n))throw new W(t,r.to,n);if(r.status!=="submitted")throw new O(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 O("?",s);return this.#t(e,t,l=>{if(!kt(l,n))throw new W(t,l.to,n);if(J.has(l.status))throw new O(l.status,s);let u=(l.kind??"task")==="review";if(!u&&c!==void 0)throw new Z(t);if(u&&s==="completed"){if(c===void 0)throw new Q(t);r=re(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(!se(s,n))throw new V(t,s.from,n);if(s.status!=="submitted"&&s.status!=="working")throw new O(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 L(t);return o.hookDelivered={...o.hookDelivered??{},[n]:r.toISOString()},this.registry.save(e,s),o}setWorker(e,t,n){return this.#t(e,t,r=>{r.worker={...r.worker??{},...n}})}failIfOpen(e,t,{result:n,by:r}){return this.#t(e,t,s=>{J.has(s.status)||(s.status="failed",s.result=n,s.completedBy=r)})}};import{entryId as ie}from"../shared/ids.js";var q=class{constructor(e){this.registry=e}add(e,{from:t,summary:n,artifacts:r=[]}){let s=this.registry.get(e),o={entryId:ie(),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 Ae}from"node:child_process";import{mkdirSync as Ot,openSync as xe,closeSync as Re,readFileSync as Oe,appendFileSync as rt,readdirSync as Pe,rmSync as Ce}from"node:fs";import{mkdir as Le,rm as st}from"node:fs/promises";import{join as E}from"node:path";import{workerEnabled as Me,TEMPLATE_AGENTS as De}from"../shared/config.js";import{join as St}from"node:path";import{fileURLToPath as oe}from"node:url";import{agyCommand as ae}from"../shared/agy.js";import{DEFAULT_LIMITS as ce}from"../shared/config.js";var le=oe(new URL("../../bin/pluriply.js",import.meta.url)),ue=["acceptEdits","bypassPermissions"],he=["mcp__pluriply__join_channel","mcp__pluriply__channel_status","mcp__pluriply__list_peers","mcp__pluriply__list_tasks","mcp__pluriply__get_task_result","mcp__pluriply__submit_review","mcp__pluriply__submit_result","mcp__pluriply__share_update","mcp__pluriply__get_channel_context"];function nt(){if(process.env.PLURIPLY_ALLOW_TEMPLATE_OVERRIDE!=="1")return null;let 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 de(i,e){let t=nt()?.[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 Et(i,{home:e,cwd:t,prompt:n,logDir:r,taskId:s,channelCode:o,taskDir:c=St(r,s),permissionMode:l="acceptEdits",timeoutMs:u=ce.timeoutMs,readOnly:h=!1}){let d=de(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",St(r,`${s}.last.md`),n]};case"claude-code":{if(!ue.includes(l))throw new Error(`invalid permissionMode "${l}" for claude-code worker`);let p=JSON.stringify({mcpServers:{pluriply:{command:process.execPath,args:[le,"connector","--agent","claude-code"],env:{PLURIPLY_HOME:e}}}});return{command:"claude",args:["-p",...h?["--allowedTools",...he,"--permission-mode","default","--add-dir",c]:["--permission-mode",l],"--mcp-config",p,"--strict-mcp-config","--output-format","json",n]}}case"antigravity":return{command:ae(),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 Ne}from"../shared/probe.js";import{writeFile as Ie}from"node:fs/promises";import{execFile as fe}from"node:child_process";import{promisify as me}from"node:util";var pe=me(fe),we=[/^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$/],ge=["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 ye(){let i={...process.env};for(let e of ge)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 ke(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 M(i,{cwd:e,env:t,maxBuffer:n=4*1024*1024,timeout:r=2e4}){try{let{stdout:s}=await pe("git",i,{cwd:e,env:t,encoding:"utf8",maxBuffer:n,timeout:r,windowsHide:!0});return s}catch(s){throw new Error(ke(s.stderr)||s.message)}}async function vt(i){let e=ye(),t=o=>M(["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();we.some(h=>h.test(u))&&(s.add(l),r.push("-c",`${l}=`))}return{args:r,env:e}}var _e=20*1024*1024;async function Tt({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 M(r,{cwd:i,env:n.env,maxBuffer:_e,timeout:2e4})}catch(c){throw new Error(`${s} failed: ${c.message}`)}return await Ie(t,o),{file:t,bytes:Buffer.byteLength(o),target:s}}import{copyFile as Se,lstat as Ee,mkdir as $t,rm as bt}from"node:fs/promises";import{dirname as ve,isAbsolute as Te,join as At}from"node:path";var $e=512*1024*1024,be=16,xt=Object.freeze({recursive:!0,force:!0,maxRetries:5,retryDelay:100});async function Rt({repoDir:i,destDir:e,git:t,maxBytes:n=$e}){let s=(await M([...t.args,"ls-files","-z","-co","--exclude-standard"],{cwd:i,env:t.env,maxBuffer:67108864})).split("\0").filter(Boolean);await bt(e,xt),await $t(e,{recursive:!0});let o=0,c=0,l=0,u=0,h=async()=>{for(;u<s.length;){let w=s[u++];if(Te(w)||w.split("/").includes(".."))continue;let y=At(i,w),k;try{k=await Ee(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=At(e,w);await $t(ve(g),{recursive:!0}),await Se(y,g),o++}},p=(await Promise.allSettled(Array.from({length:Math.min(be,s.length)},h))).find(w=>w.status==="rejected");if(p)throw await bt(e,xt),p.reason;return{files:o,bytes:c,skippedSymlinks:l}}var K=Object.freeze({recursive:!0,force:!0,maxRetries:5,retryDelay:100}),je=i=>`run \`pluriply worker enable ${i}\` to let the hub process this automatically`,We=i=>`no worker template is configured for "${i}"; enable it via config or set PLURIPLY_WORKER_TEMPLATE_OVERRIDE`,Ge=new Set(["completed","failed","cancelled"]);function qe(i){return De.includes(i)?!0:!!nt()?.[i]}function Ke(i){try{return Oe(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{}
|
|
11
|
-
`,p=
|
|
7
|
+
`)}catch{return""}}function He({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 Ue({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(!Me(r,s))return{kind:"none",hint:je(s)};try{if(!qe(s))return{kind:"none",hint:We(s)};if(this.#t(s)>=r.limits.maxConcurrentPerAgent)return this.#s(s)>=r.limits.maxQueuedPerAgent?{kind:"none",hint:`worker queue full (${r.limits.maxQueuedPerAgent}) for ${s}`}:(this.queue.push({code:e,task:t,config:r}),{kind:"queued"});this.#i(e,t,r)}catch(o){return{kind:"none",hint:`worker spawn failed: ${o.message}`}}return{kind:"spawned"}}runningCount(e){let t=0;for(let n of this.running.values())for(let r of n)(e===void 0||r.code===e)&&t++;return t}onCancelled(e,t){for(let r of this.running.values())for(let s of r)s.code===e&&s.taskId===t&&(s.child?s.child.kill("SIGTERM"):s.cancelled=!0);let n=this.queue.findIndex(r=>r.code===e&&r.task.taskId===t);n!==-1&&this.queue.splice(n,1)}reconcile(e){for(let t of this.tasks.list(e)){let n=t.worker;!n||n.endedAt||!Number.isInteger(n.pid)||this.#r(e,t.taskId)||Ne(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.#c())}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{}st(E(this.home,"workers",o.taskId,"tree"),K).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}#s(e){let t=0;for(let n of this.queue)(n.task.toTool??n.task.to)===e&&t++;return t}#r(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}#a(e){for(let t of this.running.values())for(let n of t)if(n.taskId===e)return!0;return!1}#c(){let e=E(this.home,"workers"),t;try{t=Pe(e,{withFileTypes:!0})}catch{return}for(let n of t)if(!(!n.isDirectory()||this.#a(n.name)))try{Ce(E(e,n.name,"tree"),K)}catch{}}#i(e,t,n){let r=t.toTool??t.to,s={code:e,taskId:t.taskId,child:null,cancelled:!1};this.running.has(r)||this.running.set(r,new Set),this.running.get(r).add(s),this.#n(e,t,n,s).catch(o=>{this.#e(s,r,t,`worker spawn failed: ${o.message}`)})}#e(e,t,n,r){this.running.get(t)?.delete(e);let s=E(this.home,"workers",`${n.taskId}.log`);try{rt(s,`${r}
|
|
10
|
+
`)}catch{}try{this.tasks.failIfOpen(e.code,n.taskId,{result:r,by:`${t} worker`})}catch{}st(E(this.home,"workers",n.taskId,"tree"),K).catch(()=>{}),this.#o(t)}async#n(e,t,n,r){let s=t.toTool??t.to,o=E(this.home,"workers");Ot(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 Le(u,{recursive:!0});let f=await vt(t.cwd);d=E(u,"tree");let a=await Rt({repoDir:t.cwd,destDir:d,git:f}),m=await Tt({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=Ue({...h,cwd:d,origin:t.cwd,diff:m}),await this.#l()}else d=t.cwd??E(this.home,"workspaces",t.taskId),Ot(d,{recursive:!0}),p=He({...h,cwd:d})}catch(f){this.#e(r,s,t,`${l?"review preparation":"worker spawn"} failed: ${f.message}`);return}if(r.cancelled||this.stopping){this.#e(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=Et(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&&rt(c,w);let a=xe(c,"a");try{y=Ae(f.command,f.args,{cwd:d,shell:!1,stdio:["ignore",a,a],env:{...process.env,PLURIPLY_HOME:this.home,PLURIPLY_WORKER_TASK:t.taskId,PLURIPLY_WORKER_AGENT:s,PLURIPLY_DEPTH:String(t.depth??0)}})}finally{Re(a)}}catch(f){this.#e(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.#e(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{rt(c,`${I}
|
|
12
12
|
`)}catch{}}else k?I=`worker timed out after ${n.limits.timeoutMs/1e3}s`:I=`worker exited without submitting a result (exit ${f})
|
|
13
13
|
--- last log lines ---
|
|
14
|
-
${We(c)}`;this.tasks.failIfOpen(e,t.taskId,{result:I,by:`${s} worker`})}catch{}l&&rt(E(u,"tree"),q).catch(()=>{}),this.#a(s)};y.on("exit",(f,a)=>v(f??(a?-1:0))),y.on("error",f=>v(-1,f))}async#o(){if(process.env.PLURIPLY_ALLOW_TEMPLATE_OVERRIDE!=="1")return;let e=Number(process.env.PLURIPLY_TEST_PREPARE_DELAY_MS);e>0&&await new Promise(t=>setTimeout(t,e))}#a(e){if(!this.stopping)for(let t=0;t<this.queue.length;t++){let n=this.queue[t];if((n.task.toTool??n.task.to)!==e)continue;let r;try{r=this.tasks.get(n.code,n.task.taskId).status}catch{this.queue.splice(t,1),t--;continue}if(je.has(r)){this.queue.splice(t,1),t--;continue}if(this.#t(e)>=n.config.limits.maxConcurrentPerAgent)return;this.queue.splice(t,1);try{this.#i(n.code,n.task,n.config)}catch(s){try{this.tasks.failIfOpen(n.code,n.task.taskId,{result:`worker spawn failed: ${s.message}`,by:`${n.task.toTool??n.task.to} worker`})}catch{}}return}}};import{loadConfig as Ye}from"../shared/config.js";import{pluriplyHome as Je}from"../shared/paths.js";import{shortId as ze}from"../shared/ids.js";import{isValidAgentName as Pt,isInstanceId as Ve,makeInstanceId as Xe,toolOf as Qe,cwdKey as Ct}from"../shared/identity.js";import{PACKAGE_VERSION as Lt,PROTOCOL_VERSION as Dt}from"../shared/version.js";import{pingHub as Ze,pidAlive as Mt,homeId as Nt}from"../shared/probe.js";function st(i){try{let e=JSON.parse(Be(i,"utf8"));return Number.isInteger(e?.pid)&&Number.isInteger(e?.port)?e:null}catch{return null}}var it=class{constructor({home:e=Je(),port:t=0,verifyDelayMs:n=100}={}){this.home=e,this.requestedPort=t,this.verifyDelayMs=n;let r=new D(e);this.channels=new N(r),this.tasks=new G(this.channels),this.context=new W(this.channels),this.agents=new M(r),this.workers=new K({home:e,tasks:this.tasks}),this.wss=null,this.redundant=!1,this.redundantPort=null,this.connections=new Map,this.issued=new Set}get port(){return this.redundant?this.redundantPort:this.wss?.address()?.port}async start(){Fe(this.home,{recursive:!0}),await new Promise((t,n)=>{this.wss=new He({host:"127.0.0.1",port:this.requestedPort,verifyClient:(r,s)=>{"origin"in r.req.headers?s(!1,403,"Forbidden"):s(!0)}}),this.wss.on("listening",t),this.wss.on("error",n)}),this.wss.on("connection",t=>{this.connections.set(t,{instanceId:null,tool:null,worker:!1,cwdKey:null,channels:new Set,abort:new AbortController}),t.on("message",n=>this.#c(t,n)),t.on("close",()=>{this.connections.get(t)?.abort.abort(),this.connections.delete(t)})});let e=At(this.home,"hub.json");for(let t=0;t<2;t++){if(this.#r(e)){await new Promise(s=>setTimeout(s,this.verifyDelayMs));let r=st(e);return r&&r.pid!==process.pid?(await new Promise(s=>this.wss.close(s)),this.wss=null,this.redundant=!0,this.redundantPort=r.port,{port:r.port,redundant:!0}):{port:this.port}}let n=st(e);if(n&&await this.#t(n))return await new Promise(r=>this.wss.close(r)),this.wss=null,this.redundant=!0,this.redundantPort=n.port,{port:n.port,redundant:!0};xt(e,{force:!0})}throw await new Promise(t=>this.wss.close(t)),this.wss=null,new Error("could not acquire hub lock")}async#t(e){if(!Mt(e.pid))return!1;let t=Date.now()+2e3;for(;;){let n=await Ze(e.port,300);if(n)return!n.home||n.home===Nt(this.home);if(Date.now()>=t||!Mt(e.pid))return!1;await new Promise(r=>setTimeout(r,200))}}#r(e){let t=JSON.stringify({pid:process.pid,port:this.wss.address().port,version:Lt,protocol:Dt,startedAt:new Date().toISOString()},null,2);try{return Ue(e,t,{flag:"wx"}),!0}catch(n){if(n.code==="EEXIST")return!1;throw n}}async stop(){if(this.redundant||!this.wss)return;await this.workers.stopAll();let e=At(this.home,"hub.json");st(e)?.pid===process.pid&&xt(e,{force:!0});for(let t of this.connections.keys())t.terminate();await new Promise(t=>this.wss.close(t)),this.wss=null}async#c(e,t){let n;try{n=JSON.parse(t.toString())}catch{return}try{let r=await this.#a(n.type,n.payload??{},e);this.#s(e,{id:n.id,ok:!0,payload:r})}catch(r){this.#s(e,{id:n.id,ok:!1,error:{message:r.message}})}}#s(e,t){e.readyState===e.OPEN&&e.send(JSON.stringify(t))}#e(e){let t=this.connections.get(e);if(!t||!t.instanceId)throw new Error("say hello first");return t}onlineInstances(e){let t=new Set;for(let n of this.connections.values())n.instanceId&&n.channels.has(e)&&t.add(n.instanceId);return t}isInteractive(e,t){for(let n of this.connections.values())if(n.tool===t&&!n.worker&&n.channels.has(e))return!0;return!1}#i(e){let t=[],n=[];for(let r of this.connections.values())!r.instanceId||!r.channels.has(e)||(r.worker?n:t).push(r.instanceId);return{interactive:t,workers:n}}#n(e,t){let n=this.onlineInstances(e);return t.map(r=>({...r,online:n.has(r.instanceId)}))}#l(e){for(;;){let t=Xe(e,ze(4));if(!(this.issued.has(t)||[...this.connections.values()].some(r=>r.instanceId===t)))return this.issued.add(t),t}}#o(e,t,n){let r=this.onlineInstances(n);r.add(t.instanceId);let{peers:s}=this.channels.join(n,{instanceId:t.instanceId,tool:t.tool,worker:t.worker},{online:r});return this.agents.touch(t.cwdKey,n),t.channels.add(n),this.#n(n,s)}#a(e,t,n){switch(e){case"ping":return{pong:!0,version:Lt,protocol:Dt,pid:process.pid,home:Nt(this.home)};case"hook.poll":{let r=t.tool,s={channelCode:null,tool:r,incoming:[],results:[],more:0};if(!Pt(r)||typeof t.cwd!="string"||!Ot(t.cwd))return s;let o=Ct(r,t.cwd),c=[...this.connections.values()].filter(a=>a.cwdKey===o);if(c.length>0&&c.every(a=>a.worker))return s;let l=c.filter(a=>!a.worker),u=l.length>0?[...new Set(l.flatMap(a=>[...a.channels]))]:[this.agents.resume(o)].filter(Boolean);if(u.length===0)return s;for(let a of u)this.workers.reconcile(a);let h=new Set(l.map(a=>a.instanceId)),d=a=>!a.hookDelivered?.[o],p=u.flatMap(a=>this.tasks.list(a).map(m=>({t:m,code:a}))),w=p.filter(({t:a})=>a.status==="submitted"&&d(a)&&(a.toInstance?h.has(a.toInstance):(a.toTool??a.to)===r)),y=p.filter(({t:a})=>a.fromCwdKey===o&&(a.status==="completed"||a.status==="failed")&&d(a)),k=a=>{let m=String(a.request??"").replace(/\s+/g," ").trim();return m.length>80?`${m.slice(0,80)}\u2026`:m},g=[...w.map(({t:a,code:m})=>({t:a,code:m,at:a.createdAt,entry:{taskId:a.taskId,kind:a.kind??"task",from:a.from,summary:k(a)},side:"incoming"})),...y.map(({t:a,code:m})=>({t:a,code:m,at:a.updatedAt,entry:{taskId:a.taskId,status:a.status,to:a.completedBy??a.to,summary:k(a)},side:"results"}))].sort((a,m)=>a.at<m.at?-1:a.at>m.at?1:0),v=g.slice(0,10),f=new Date;for(let a of v)this.tasks.markHookDelivered(a.code,a.t.taskId,o,f);return{channelCode:u[0],tool:r,incoming:v.filter(a=>a.side==="incoming").map(a=>a.entry),results:v.filter(a=>a.side==="results").map(a=>a.entry),more:g.length-v.length}}case"channel.create":return{channelCode:this.channels.create().channel.code};case"agent.hello":{if(!Pt(t.tool))throw new Error(`invalid agent name: ${t.tool}`);if(typeof t.cwd!="string"||t.cwd.length===0||!Ot(t.cwd))throw new Error("cwd is required");let r=this.connections.get(n);if(r.instanceId)return{instanceId:r.instanceId};let s=t.instanceId;if(s!==void 0){if(!Ve(s)||Qe(s)!==t.tool)throw new Error(`invalid instanceId: ${s}`)}else s=this.#l(t.tool);return r.instanceId=s,r.tool=t.tool,r.worker=!!t.worker,r.cwdKey=Ct(t.tool,t.cwd),{instanceId:s}}case"channel.join":{let r=this.#e(n);this.channels.get(t.channelCode);let s=this.#o(n,r,t.channelCode);return{channelCode:t.channelCode,peers:s}}case"channel.peers":return{peers:this.#n(t.channelCode,this.channels.peers(t.channelCode,{online:this.onlineInstances(t.channelCode)}))};case"channel.presence":return this.channels.get(t.channelCode),this.#i(t.channelCode);case"agent.resume":{let r=this.#e(n);if(r.channels.size>0)return{channelCode:null,alreadyJoined:!0};let s=this.agents.resume(r.cwdKey);if(!s)return{channelCode:null};let o=this.#o(n,r,s);return{channelCode:s,peers:o}}case"task.create":{let r=this.#e(n),s=Ye(this.home),{task:o,targetJoined:c,targetOnline:l,warning:u}=this.tasks.create(t.channelCode,{...t,from:r.instanceId,fromCwdKey:r.cwdKey,depth:t.depth??0,mode:t.mode??"auto",maxDepth:s.limits.maxDepth,allowedRoots:s.allowedRoots,online:this.onlineInstances(t.channelCode)});this.agents.touch(r.cwdKey,t.channelCode);let h=this.workers.dispatch(t.channelCode,o,{interactive:this.isInteractive(t.channelCode,o.toTool),config:s}),d={taskId:o.taskId,targetJoined:c,dispatch:h.kind};return l!==void 0&&(d.targetOnline=l),u&&(d.warning=u),h.hint&&(d.hint=h.hint),d}case"task.list":return this.workers.reconcile(t.channelCode),{tasks:this.tasks.list(t.channelCode,{to:t.to,from:t.from,status:t.status,kind:t.kind})};case"task.get":return this.workers.reconcile(t.channelCode),{task:this.tasks.get(t.channelCode,t.taskId)};case"task.wait":{this.workers.reconcile(t.channelCode);let r=Math.min(Math.max(Number(t.timeoutMs)||0,1),15e3),s=this.connections.get(n)?.abort.signal;return this.tasks.waitFor(t.channelCode,t.taskId,r,{signal:s}).then(o=>{if(o===null)throw new Error("connection closed while waiting");return{task:o}})}case"task.claim":{let r=this.#e(n),s=this.tasks.claim(t.channelCode,t.taskId,r.instanceId);return this.agents.touch(r.cwdKey,t.channelCode),{task:s}}case"task.complete":{let r=this.#e(n),s=this.tasks.complete(t.channelCode,t.taskId,{from:r.instanceId,result:t.result,status:t.status,worker:r.worker,review:t.review});if(this.agents.touch(r.cwdKey,t.channelCode),(s.kind??"task")==="review"&&s.status==="completed")try{this.context.add(t.channelCode,{from:r.instanceId,summary:`[review] ${s.result.verdict} by ${r.instanceId}: ${s.result.summary}`,artifacts:[]})}catch{}return{task:s}}case"task.cancel":{let r=this.#e(n),s=this.tasks.cancel(t.channelCode,t.taskId,{agent:r.instanceId,reason:t.reason});return this.agents.touch(r.cwdKey,t.channelCode),this.workers.onCancelled(t.channelCode,t.taskId),{task:s}}case"worker.status":return t.channelCode!==void 0&&this.channels.get(t.channelCode),{running:this.workers.runningCount(t.channelCode)};case"context.add":{let r=this.#e(n),s=this.context.add(t.channelCode,{...t,from:r.instanceId});return this.agents.touch(r.cwdKey,t.channelCode),{entryId:s.entryId}}case"context.list":return{entries:this.context.list(t.channelCode,{limit:t.limit})};default:throw new Error(`unknown message type: ${e}`)}}};import{spawn as tn}from"node:child_process";import{existsSync as en,rmSync as ot}from"node:fs";import{join as nn}from"node:path";import{fileURLToPath as rn}from"node:url";import{pingHub as jt,homeId as sn,pidAlive as on}from"../shared/probe.js";import{readLock as Gt}from"../shared/lock.js";var an=rn(new URL("../../bin/pluriply.js",import.meta.url));async function Wt(i){let e=Gt(i);if(!e)return null;let t=await jt(e.port);return!t||t.home&&t.home!==sn(i)?null:{...t,port:e.port,lockPid:e.pid}}async function cn({home:i,timeoutMs:e=5e3}){tn(process.execPath,[an,"hub","start"],{detached:!0,stdio:"ignore",env:{...process.env,PLURIPLY_HOME:i}}).unref();let n=Date.now()+e;for(;Date.now()<n;){let r=await Wt(i);if(r)return r;await new Promise(s=>setTimeout(s,100))}throw new Error(`failed to start pluriply hub within ${e/1e3}s`)}async function ln({home:i,timeoutMs:e=5e3}){let t=Gt(i),n=nn(i,"hub.json");if(!t)return"not-running";let r=await jt(t.port,1e3),s=r&&Number.isInteger(r.pid)&&Number.isInteger(t.pid)?r.pid!==t.pid:!1;if(!r||s)return ot(n,{force:!0}),"not-running";try{process.kill(t.pid,"SIGTERM")}catch{return ot(n,{force:!0}),"stopped"}let o=Date.now()+e;for(;Date.now()<o;){if(!en(n))return"stopped";if(!on(t.pid))return ot(n,{force:!0}),"stopped";await new Promise(c=>setTimeout(c,100))}return"timeout"}import{readLock as vr}from"../shared/lock.js";import{loadConfig as Tr,saveConfig as br,setWorkerEnabled as Rr,TEMPLATE_AGENTS as xr}from"../shared/config.js";export{it as Hub,xr as TEMPLATE_AGENTS,Wt as liveHub,Tr as loadConfig,vr as readLock,br as saveConfig,Rr as setWorkerEnabled,cn as spawnHub,ln as stopHub};
|
|
14
|
+
${Ke(c)}`;this.tasks.failIfOpen(e,t.taskId,{result:I,by:`${s} worker`})}catch{}l&&st(E(u,"tree"),K).catch(()=>{}),this.#o(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))}#o(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(Ge.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 tn}from"../shared/config.js";import{pluriplyHome as en}from"../shared/paths.js";import{shortId as nn}from"../shared/ids.js";import{isValidAgentName as Ct,isInstanceId as rn,makeInstanceId as sn,toolOf as on,cwdKey as Lt}from"../shared/identity.js";import{PACKAGE_VERSION as Mt,PROTOCOL_VERSION as Dt}from"../shared/version.js";import{pingHub as an,pidAlive as ot,homeId as Nt}from"../shared/probe.js";function A(i){try{let e=JSON.parse(ze(i,"utf8"));return Number.isInteger(e?.pid)&&Number.isInteger(e?.port)?e:null}catch{return null}}function Gt(i,e){return!i||!e?!1:i.pid===e.pid&&i.port===e.port&&i.startedAt===e.startedAt&&i.token===e.token}function jt(i,e){let t=A(i);return(e?!Gt(t,e):t!==null)?!1:(Wt(i,{force:!0}),!0)}function cn(i,e=500){return new Promise(t=>{let n=Xe({host:"127.0.0.1",port:i}),r=o=>{clearTimeout(s),n.destroy(),t(o)},s=setTimeout(()=>r(!1),e);n.once("connect",()=>r(!1)),n.once("error",o=>r(o.code==="ECONNREFUSED"))})}var ln=1e4,un=3e4,hn=5e3,dn="unauthorized: hub requires a token \u2014 re-run `npx pluriply@latest setup` and restart your tool",at=class extends Ve{constructor({home:e=en(),port:t=0,verifyDelayMs:n=100,takeoverGraceMs:r=ln,lockWatchMs:s=un,log:o=c=>process.stderr.write(c)}={}){super(),this.home=e,this.requestedPort=t,this.verifyDelayMs=n,this.takeoverGraceMs=r,this.lockWatchMs=s,this.lockTimer=null,this.orphaned=!1,this.stopping=!1,this.stopPromise=null,this.log=o,this.token=null;let c=new D(e);this.channels=new j(c),this.tasks=new G(this.channels),this.context=new q(this.channels),this.agents=new N(c),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(){Be(this.home,{recursive:!0,mode:448});try{Je(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 Fe({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.#l(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.#i(t,r)),t.on("close",()=>{this.connections.get(t)?.abort.abort(),this.connections.delete(t)})});let e=it(this.home,"hub.json");for(let t=0;t<2;t++){if(this.#s(e)){await new Promise(s=>setTimeout(s,this.verifyDelayMs));let r=A(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}):(this.#r(),{port:this.port})}let n=A(e);if(n&&await this.#t(n,e))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};jt(e,n)}throw await new Promise(t=>this.wss.close(t)),this.wss=null,new Error("could not acquire hub lock")}async#t(e,t){if(!ot(e.pid))return!1;let n=Date.now()+this.takeoverGraceMs;for(;;){let r=await an(e.port,500);if(r)return!r.home||r.home===Nt(this.home);if(!Gt(A(t),e)||await cn(e.port)||Date.now()>=n||!ot(e.pid))return!1;await new Promise(s=>setTimeout(s,500))}}#s(e){let t=Qe(32).toString("hex"),n=JSON.stringify({pid:process.pid,port:this.wss.address().port,version:Mt,protocol:Dt,startedAt:new Date().toISOString(),token:t},null,2);try{return Ye(e,n,{flag:"wx",mode:384}),this.token=t,!0}catch(r){if(r.code==="EEXIST")return!1;throw r}}#r(){if(!this.lockWatchMs||this.stopping)return;let e=Math.min(hn,this.lockWatchMs/2),t=this.lockWatchMs+(Math.random()*2-1)*e;this.lockTimer=setTimeout(()=>{this.#a().catch(n=>{this.log(`hub: lock watch failed (${n.message})
|
|
16
|
+
`),this.#r()})},t),this.lockTimer.unref?.()}async#a(){if(this.stopping||this.redundant||!this.wss)return;let e=it(this.home,"hub.json"),t=A(e);if(!t){if(this.#s(e)){this.log(`hub: lock was missing; re-acquired (pid ${process.pid})
|
|
17
|
+
`),this.#r();return}if(t=A(e),!t){this.log(`hub: lock file is unreadable; keeping the hub running
|
|
18
|
+
`),this.#r();return}}if(t.pid===process.pid){this.#r();return}if(!ot(t.pid)){jt(e,t)&&this.#s(e)&&this.log(`hub: reclaimed stale lock of dead pid ${t.pid} (pid ${process.pid})
|
|
19
|
+
`),this.#r();return}this.log(`hub: lock taken by pid ${t.pid}; shutting down
|
|
20
|
+
`),this.orphaned=!0;try{await this.stop()}finally{this.emit("orphaned")}}stop(){return this.stopping=!0,this.lockTimer&&clearTimeout(this.lockTimer),this.lockTimer=null,this.redundant||!this.wss?Promise.resolve():(this.stopPromise??=this.#c().finally(()=>{this.stopPromise=null}),this.stopPromise)}async#c(){await this.workers.stopAll();let e=it(this.home,"hub.json");A(e)?.pid===process.pid&&Wt(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#i(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}
|
|
21
|
+
`)}this.#e(e,{id:n.id,ok:!1,error:{message:dn}});return}try{let s=await this.#f(n.type,n.payload??{},e);this.#e(e,{id:n.id,ok:!0,payload:s})}catch(s){this.#e(e,{id:n.id,ok:!1,error:{message:s.message}})}}#e(e,t){e.readyState===e.OPEN&&e.send(JSON.stringify(t))}#n(e){let t=this.connections.get(e);if(!t||!t.instanceId)throw new Error("say hello first");return t}#l(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&&Ze(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}#o(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}}#u(e,t){let n=this.onlineInstances(e);return t.map(r=>({...r,online:n.has(r.instanceId)}))}#d(e){for(;;){let t=sn(e,nn(4));if(!(this.issued.has(t)||[...this.connections.values()].some(r=>r.instanceId===t)))return this.issued.add(t),t}}#h(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.#u(n,s)}#f(e,t,n){switch(e){case"ping":return{pong:!0,version:Mt,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(!Ct(r)||typeof t.cwd!="string"||!Pt(t.cwd))return s;let o=Lt(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(!Ct(t.tool))throw new Error(`invalid agent name: ${t.tool}`);if(typeof t.cwd!="string"||t.cwd.length===0||!Pt(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(!rn(s)||on(s)!==t.tool)throw new Error(`invalid instanceId: ${s}`)}else s=this.#d(t.tool);return r.instanceId=s,r.tool=t.tool,r.worker=!!t.worker,r.cwdKey=Lt(t.tool,t.cwd),{instanceId:s}}case"channel.join":{let r=this.#n(n);this.channels.get(t.channelCode);let s=this.#h(n,r,t.channelCode);return{channelCode:t.channelCode,peers:s}}case"channel.peers":return{peers:this.#u(t.channelCode,this.channels.peers(t.channelCode,{online:this.onlineInstances(t.channelCode)}))};case"channel.presence":return this.channels.get(t.channelCode),this.#o(t.channelCode);case"agent.resume":{let r=this.#n(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.#h(n,r,s);return{channelCode:s,peers:o}}case"task.create":{let r=this.#n(n),s=tn(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.#n(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.#n(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.#n(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.#n(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 fn}from"node:child_process";import{existsSync as mn,rmSync as ct}from"node:fs";import{join as pn}from"node:path";import{fileURLToPath as wn}from"node:url";import{pingHub as qt,homeId as gn,pidAlive as yn}from"../shared/probe.js";import{readLock as Kt}from"../shared/lock.js";var kn=wn(new URL("../../bin/pluriply.js",import.meta.url));async function lt(i){let e=Kt(i);if(!e)return null;let t=await qt(e.port);return!t||t.home&&t.home!==gn(i)?null:{...t,port:e.port,lockPid:e.pid,token:e.token}}var In=2e4;async function _n({home:i,timeoutMs:e=In}){let t=fn(process.execPath,[kn,"hub","start"],{detached:!0,stdio:"ignore",env:{...process.env,PLURIPLY_HOME:i}}),n=!1;t.on("exit",()=>{n=!0}),t.on("error",()=>{n=!0}),t.unref();let r=Date.now()+e;for(;Date.now()<r;){let s=await lt(i);if(s)return s;if(n){let o=await lt(i);if(o)return o;throw new Error("pluriply hub exited before it was ready")}await new Promise(o=>setTimeout(o,100))}throw new Error(`failed to start pluriply hub within ${e/1e3}s`)}async function Sn({home:i,timeoutMs:e=5e3}){let t=Kt(i),n=pn(i,"hub.json");if(!t)return"not-running";let r=await qt(t.port,1e3),s=r&&Number.isInteger(r.pid)&&Number.isInteger(t.pid)?r.pid!==t.pid:!1;if(!r||s)return ct(n,{force:!0}),"not-running";try{process.kill(t.pid,"SIGTERM")}catch{return ct(n,{force:!0}),"stopped"}let o=Date.now()+e;for(;Date.now()<o;){if(!mn(n))return"stopped";if(!yn(t.pid))return ct(n,{force:!0}),"stopped";await new Promise(c=>setTimeout(c,100))}return"timeout"}import{readLock as Gr}from"../shared/lock.js";import{loadConfig as Kr,saveConfig as Hr,setWorkerEnabled as Ur,TEMPLATE_AGENTS as Fr}from"../shared/config.js";export{at as Hub,Fr as TEMPLATE_AGENTS,lt as liveHub,Kr as loadConfig,Gr as readLock,Hr as saveConfig,Ur as setWorkerEnabled,_n as spawnHub,Sn as stopHub};
|
package/src/setup/run-setup.js
CHANGED
|
@@ -14,6 +14,10 @@ const hubClient = () => import("../connector/hub-client.js");
|
|
|
14
14
|
export const REMOVE_NOTE =
|
|
15
15
|
"note: close or restart open client sessions; their connectors may restart the hub";
|
|
16
16
|
|
|
17
|
+
/** `--hooks-only` 실행의 첫 줄(스펙 §4.3). MCP 표가 비어 있는 이유를 알려 준다. */
|
|
18
|
+
export const HOOKS_ONLY_NOTE =
|
|
19
|
+
"hooks only — MCP registration, workers and hub untouched";
|
|
20
|
+
|
|
17
21
|
/** @param {string[]|undefined} only */
|
|
18
22
|
function resolveTargets(only) {
|
|
19
23
|
if (!only) return CLIENTS;
|
|
@@ -108,7 +112,8 @@ function walkHooks({ targets, rows, e, dryRun, hooks, remove }) {
|
|
|
108
112
|
* env.stopHub / env.rm / env.lstat 은 테스트가 주입한다.
|
|
109
113
|
* `hooks`(기본 true)는 Claude Code·Codex 의 Stop 훅 등록 여부다(스펙 §6). `--remove` 는 이 값과
|
|
110
114
|
* 무관하게 항상 훅을 제거한다.
|
|
111
|
-
*
|
|
115
|
+
* `hooksOnly`(스펙 §4)는 MCP 등록·워커·허브를 건너뛰고 Stop 훅만 설치(`remove` 면 제거)한다.
|
|
116
|
+
* @param {{only?: string[], workers?: boolean, dryRun?: boolean, remove?: boolean, purge?: boolean, hooks?: boolean, hooksOnly?: boolean, env?: object, home: string}} opts
|
|
112
117
|
*/
|
|
113
118
|
export async function runSetup({
|
|
114
119
|
only,
|
|
@@ -117,11 +122,13 @@ export async function runSetup({
|
|
|
117
122
|
remove = false,
|
|
118
123
|
purge = false,
|
|
119
124
|
hooks = true,
|
|
125
|
+
hooksOnly = false,
|
|
120
126
|
env,
|
|
121
127
|
home,
|
|
122
128
|
}) {
|
|
123
129
|
const e = env ?? makeEnv();
|
|
124
130
|
const targets = resolveTargets(only);
|
|
131
|
+
if (hooksOnly) return runHooksOnly({ targets, e, dryRun, remove });
|
|
125
132
|
if (remove) return runRemove({ targets, only, dryRun, purge, e, home });
|
|
126
133
|
const enabledAgents = [];
|
|
127
134
|
const { rows, failed } = walkClients({
|
|
@@ -207,6 +214,29 @@ export function purgeRefusal(home, e = {}) {
|
|
|
207
214
|
return null;
|
|
208
215
|
}
|
|
209
216
|
|
|
217
|
+
/**
|
|
218
|
+
* `setup --hooks-only` / `setup --remove --hooks-only`(스펙 §4.2). walkHooks 는 도구 감지 여부를
|
|
219
|
+
* walkClients 의 행(`installed`)에서 읽으므로, 등록을 건드리지 않고 detect 만 돌려 같은 모양의 행을 만든다.
|
|
220
|
+
* 허브는 띄우지도 세우지도 않는다 — 훅은 발동 시점에 connectIfLive 로 허브를 찾는다(D7).
|
|
221
|
+
*/
|
|
222
|
+
function runHooksOnly({ targets, e, dryRun, remove }) {
|
|
223
|
+
const rows = targets.map((c) => ({
|
|
224
|
+
id: c.id,
|
|
225
|
+
label: c.label,
|
|
226
|
+
installed: Boolean(c.detect(e).installed),
|
|
227
|
+
result: "untouched",
|
|
228
|
+
}));
|
|
229
|
+
const hk = walkHooks({ targets, rows, e, dryRun, hooks: true, remove });
|
|
230
|
+
return {
|
|
231
|
+
mode: remove ? "hooks-only-remove" : "hooks-only",
|
|
232
|
+
rows: [],
|
|
233
|
+
hookRows: hk.hookRows,
|
|
234
|
+
failed: hk.failed,
|
|
235
|
+
workers: [],
|
|
236
|
+
workersDisabled: [],
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
|
|
210
240
|
async function runRemove({ targets, only, dryRun, purge, e, home }) {
|
|
211
241
|
const { rows, failed: rowsFailed } = walkClients({
|
|
212
242
|
targets,
|
|
@@ -297,16 +327,38 @@ async function runRemove({ targets, only, dryRun, purge, e, home }) {
|
|
|
297
327
|
return out;
|
|
298
328
|
}
|
|
299
329
|
|
|
330
|
+
/** @param {object[]} hookRows @returns {string[]} */
|
|
331
|
+
function hookLines(hookRows) {
|
|
332
|
+
return (hookRows ?? []).map(
|
|
333
|
+
(row) =>
|
|
334
|
+
`hooks ${row.id.padEnd(12)} ${row.installed ? "installed " : "not installed"} ${row.result}`,
|
|
335
|
+
);
|
|
336
|
+
}
|
|
337
|
+
/** Codex 는 새 훅을 다음 세션에서 신뢰 승인해야 한다 — 새로 쓰였을 때만 안내한다. @param {object[]} hookRows */
|
|
338
|
+
function codexTrustHint(hookRows) {
|
|
339
|
+
return (hookRows ?? []).some(
|
|
340
|
+
(x) =>
|
|
341
|
+
x.id === "codex" && (x.result === "registered" || x.result === "updated"),
|
|
342
|
+
)
|
|
343
|
+
? [
|
|
344
|
+
"hint: Codex asks to trust the new hook in its next session — approve it.",
|
|
345
|
+
]
|
|
346
|
+
: [];
|
|
347
|
+
}
|
|
348
|
+
|
|
300
349
|
/** @param {Awaited<ReturnType<typeof runSetup>>} r @returns {string[]} 사람이 읽는 표 */
|
|
301
350
|
export function formatSetup(r, { workers = false } = {}) {
|
|
351
|
+
if (r.mode === "hooks-only" || r.mode === "hooks-only-remove")
|
|
352
|
+
return [
|
|
353
|
+
HOOKS_ONLY_NOTE,
|
|
354
|
+
...hookLines(r.hookRows),
|
|
355
|
+
...codexTrustHint(r.hookRows),
|
|
356
|
+
];
|
|
302
357
|
const lines = r.rows.map(
|
|
303
358
|
(row) =>
|
|
304
359
|
`${row.id.padEnd(16)} ${row.installed ? "installed " : "not installed"} ${row.result}`,
|
|
305
360
|
);
|
|
306
|
-
|
|
307
|
-
lines.push(
|
|
308
|
-
`hooks ${row.id.padEnd(12)} ${row.installed ? "installed " : "not installed"} ${row.result}`,
|
|
309
|
-
);
|
|
361
|
+
lines.push(...hookLines(r.hookRows));
|
|
310
362
|
if (r.mode === "remove") {
|
|
311
363
|
if (r.workersDisabled.length)
|
|
312
364
|
lines.push(`workers disabled: ${r.workersDisabled.join(", ")}`);
|
|
@@ -336,15 +388,6 @@ export function formatSetup(r, { workers = false } = {}) {
|
|
|
336
388
|
`hint: run \`pluriply worker enable <${cli.join("|")}>\` to let the hub run that tool headlessly (or re-run setup --workers)`,
|
|
337
389
|
);
|
|
338
390
|
}
|
|
339
|
-
|
|
340
|
-
(r.hookRows ?? []).some(
|
|
341
|
-
(x) =>
|
|
342
|
-
x.id === "codex" &&
|
|
343
|
-
(x.result === "registered" || x.result === "updated"),
|
|
344
|
-
)
|
|
345
|
-
)
|
|
346
|
-
lines.push(
|
|
347
|
-
"hint: Codex asks to trust the new hook in its next session — approve it.",
|
|
348
|
-
);
|
|
391
|
+
lines.push(...codexTrustHint(r.hookRows));
|
|
349
392
|
return lines;
|
|
350
393
|
}
|
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;
|