pluriply 0.4.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -32,7 +32,7 @@ Pluriply MCP connector with each of them (idempotent — run it again any time).
32
32
  - `npx pluriply setup --only claude-code,codex` — limit to specific tools.
33
33
  - `npx pluriply setup --remove` — unregister Pluriply from every tool, disable headless workers and stop the hub. Your channels and task history under `~/.pluriply` stay; add `--purge` to delete them too.
34
34
  - `npx pluriply setup --remove --hooks-only` — take out only the Stop hooks; MCP registration, headless workers and the hub stay as they are.
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
+ - 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). Codex also gets a 30 s MCP startup timeout (its default is 10 s; the connector may have to wait for the hub to start). If you registered with an earlier version, run `setup` again to pick up anything missing — values you already set are kept (the config file is rewritten once, with a `.bak` copy of the original).
36
36
  - `setup` also installs a Stop hook for Claude Code, Codex and Antigravity CLI so a live session notices new tasks and finished results at the end of its turn (see _Warm reception_). `--no-hooks` skips it; `setup --remove` takes it out again.
37
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.
38
38
 
package/bin/pluriply.js CHANGED
@@ -63,9 +63,19 @@ if (cmd === "hub" && sub === "start") {
63
63
  process.exit(0);
64
64
  }
65
65
  console.log(`pluriply hub listening on ${port}`);
66
- const shutdown = async () => {
67
- await hub.stop();
66
+ // Plan 4g: 락이 다른 허브로 넘어가면 허브가 스스로 물러난다(stop 완료 후 이 이벤트).
67
+ hub.on("orphaned", () => {
68
+ console.log("pluriply hub: lock taken by another hub; exiting");
68
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
+ }
69
79
  };
70
80
  process.on("SIGTERM", shutdown);
71
81
  process.on("SIGINT", shutdown);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pluriply",
3
- "version": "0.4.0",
3
+ "version": "0.5.1",
4
4
  "description": "Connect your AI coding tools into one collaboration channel",
5
5
  "type": "module",
6
6
  "license": "SEE LICENSE IN LICENSE.md",
@@ -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
- * 안 끝난 소켓으로 그대로 전송해버릴 수 있다. BARRIER_TIMEOUT_MS + 허브 재스폰
16
- * 한 번(초 단위)을 넉넉히 덮도록 15s로 둔다.
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 = 15_000;
33
+ export const REQUEST_WAIT_MS = 35_000;
19
34
  /**
20
35
  * 재접속 성공 뒤 "reconnected" 리스너(예: 채널 재join)를 기다리는 최대 시간.
21
36
  * 리스너가 절대 끝나지 않아도(응답 없는 hub.request 등) 이 시간이 지나면
@@ -42,7 +57,17 @@ const RETRYABLE = new Set([
42
57
  "context.list",
43
58
  ]);
44
59
 
45
- const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
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
72
  /**
48
73
  * @param {number} port @param {number} [timeoutMs] @param {string} [token] 허브 락의 연결 토큰(Plan 4f).
@@ -103,31 +128,78 @@ export class HubClient extends EventEmitter {
103
128
 
104
129
  /**
105
130
  * @param {WebSocket} ws
106
- * @param {{home?: string, reconnectTotalMs?: number}} [opts] home이 없으면 재접속하지 않는다.
107
- * reconnectTotalMs는 재접속을 포기하기까지의 총 시간(기본 RECONNECT_TOTAL_MS) — 테스트에서 dead 경로를 짧게 만드는 데 쓴다.
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)이다.
108
136
  */
109
- constructor(ws, { home, reconnectTotalMs = RECONNECT_TOTAL_MS } = {}) {
137
+ constructor(
138
+ ws,
139
+ {
140
+ home,
141
+ reconnectTotalMs = RECONNECT_TOTAL_MS,
142
+ stableMs = STABLE_MS,
143
+ token,
144
+ } = {},
145
+ ) {
110
146
  super();
111
147
  this.home = home;
112
148
  this.reconnectTotalMs = reconnectTotalMs;
149
+ this.stableMs = stableMs;
113
150
  this.pending = new Map();
114
151
  this.seq = 0;
115
152
  this.stale = null;
116
153
  this.closed = false;
117
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;
118
173
  /** @type {Promise<void>|null} 재접속 진행 중이면 그 프라미스 */
119
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;
120
184
  this.#closedSignal = new Promise((resolve) => {
121
185
  this.#resolveClosed = resolve;
122
186
  });
123
- this.#attach(ws);
187
+ this.#attach(ws, token);
124
188
  }
125
189
 
126
- #attach(ws) {
190
+ #attach(ws, token) {
127
191
  // 이전 소켓의 리스너를 떼어낸다: 늦게 도착하는 error/close가 새 연결의
128
192
  // pending 요청을 잘못 실패시키는 것을 막는다 (기존 소켓이 없으면 no-op).
129
193
  this.ws?.removeAllListeners();
194
+ // 허브는 인증 못 한 연결도 열어 둔다(Plan 4f) — 재시도로 갈아탈 때 옛 소켓이 새지 않게 닫는다
195
+ this.ws?.terminate();
130
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;
131
203
  ws.on("message", (raw) => {
132
204
  let msg;
133
205
  try {
@@ -138,9 +210,16 @@ export class HubClient extends EventEmitter {
138
210
  const entry = this.pending.get(msg.id);
139
211
  if (!entry) return;
140
212
  this.pending.delete(msg.id);
141
- msg.ok
142
- ? entry.resolve(msg.payload)
143
- : entry.reject(new Error(msg.error?.message ?? "hub error"));
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));
144
223
  });
145
224
  ws.on("close", () => this.#onLost(new Error("hub connection closed")));
146
225
  ws.on("error", (err) =>
@@ -156,6 +235,13 @@ export class HubClient extends EventEmitter {
156
235
  #onLost(err) {
157
236
  this.#failAll(err);
158
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
+ }
159
245
  this.reconnecting = this.#reconnect().finally(() => {
160
246
  this.reconnecting = null;
161
247
  });
@@ -167,9 +253,28 @@ export class HubClient extends EventEmitter {
167
253
  }
168
254
 
169
255
  async #reconnect() {
170
- const deadline = Date.now() + this.reconnectTotalMs;
171
- let delay = 250;
172
- while (Date.now() < deadline && !this.closed) {
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
+ }
173
278
  try {
174
279
  const { port, info } = await ensureHub({ home: this.home });
175
280
  if (this.closed) return; // close()가 ensureHub 대기 중에 호출됨
@@ -179,7 +284,7 @@ export class HubClient extends EventEmitter {
179
284
  return;
180
285
  }
181
286
  if (ws) {
182
- this.#attach(ws);
287
+ this.#attach(ws, info.token);
183
288
  this.stale = staleFrom(info, port);
184
289
  // emit 대신 리스너를 직접 호출해 반환 프라미스를 기다린다: 이렇게 하면
185
290
  // this.reconnecting은 리스너(도구 계층의 채널 재join)가 끝난 뒤에야
@@ -195,22 +300,38 @@ export class HubClient extends EventEmitter {
195
300
  // 계속 non-null로 남아 #onLost가 이후의 모든 끊김을 무시하게 된다 —
196
301
  // BARRIER_TIMEOUT_MS로 상한을 둬서 그 사태를 막는다(리스너 자체는
197
302
  // 백그라운드에서 계속 돌아가지만 결과는 기다리지 않는다).
198
- await Promise.race([
303
+ await raceSleep(
199
304
  Promise.allSettled(
200
305
  this.rawListeners("reconnected").map((fn) =>
201
306
  Promise.resolve().then(() => fn({ port })),
202
307
  ),
203
308
  ),
204
- sleep(BARRIER_TIMEOUT_MS),
205
- ]);
206
- return;
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;
207
331
  }
208
332
  } catch {
209
333
  // 허브가 아직 없음: 재시도
210
334
  }
211
- // 다음 시도까지의 대기는 close()가 즉시 깨울 수 있어야 한다
212
- await Promise.race([sleep(delay), this.#closedSignal]);
213
- delay = Math.min(delay * 2, RECONNECT_MAX_DELAY_MS);
214
335
  }
215
336
  if (!this.closed) {
216
337
  this.dead = true;
@@ -218,15 +339,46 @@ export class HubClient extends EventEmitter {
218
339
  }
219
340
  }
220
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
+
221
364
  /**
222
365
  * 허브에 접속한다. 허브 프로토콜이 커넥터보다 낮으면 `stale`에 기록하되 접속은 유지한다.
223
- * @param {{home?: string, reconnectTotalMs?: number}} [opts] @returns {Promise<HubClient>}
366
+ * @param {{home?: string, reconnectTotalMs?: number, stableMs?: number}} [opts] @returns {Promise<HubClient>}
224
367
  */
225
- static async connect({ home = pluriplyHome(), reconnectTotalMs } = {}) {
368
+ static async connect({
369
+ home = pluriplyHome(),
370
+ reconnectTotalMs,
371
+ stableMs,
372
+ } = {}) {
226
373
  const { port, info } = await ensureHub({ home });
227
374
  const ws = await tryConnect(port, 3000, info.token);
228
375
  if (!ws) throw new Error("could not connect to pluriply hub");
229
- const client = new HubClient(ws, { home, reconnectTotalMs });
376
+ const client = new HubClient(ws, {
377
+ home,
378
+ reconnectTotalMs,
379
+ stableMs,
380
+ token: info.token,
381
+ });
230
382
  client.stale = staleFrom(info, port);
231
383
  return client;
232
384
  }
@@ -249,13 +401,18 @@ export class HubClient extends EventEmitter {
249
401
  payload = {},
250
402
  { duringReconnect = false, timeoutMs } = {},
251
403
  ) {
252
- if (this.dead) throw new Error("hub unreachable; restart the tool");
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
+ );
253
410
  // readyState만으로는 부족하다: #reconnect가 #attach로 소켓을 OPEN 상태로
254
411
  // 바꾼 뒤에도 "reconnected" 리스너(채널 재join)가 끝날 때까지 this.reconnecting은
255
412
  // non-null로 남아있다. 그 틈에 나간 request()가 재join보다 먼저 허브에 도착하는
256
413
  // 것을 막으려면 readyState와 무관하게 reconnecting이 있으면 기다려야 한다.
257
414
  if (this.reconnecting && !duringReconnect) {
258
- await Promise.race([this.reconnecting, sleep(REQUEST_WAIT_MS)]);
415
+ await raceSleep(this.reconnecting, REQUEST_WAIT_MS);
259
416
  }
260
417
  if (this.ws.readyState !== WebSocket.OPEN)
261
418
  throw new Error("hub connection closed");
@@ -276,7 +433,7 @@ export class HubClient extends EventEmitter {
276
433
  this.reconnecting &&
277
434
  !duringReconnect
278
435
  ) {
279
- await Promise.race([this.reconnecting, sleep(REQUEST_WAIT_MS)]);
436
+ await raceSleep(this.reconnecting, REQUEST_WAIT_MS);
280
437
  if (this.ws.readyState === WebSocket.OPEN)
281
438
  return this.#send(type, payload, timeoutMs);
282
439
  }
@@ -329,7 +486,7 @@ export async function connectIfLive({ home = pluriplyHome() } = {}) {
329
486
  if (!live) return null;
330
487
  const ws = await tryConnect(live.port, 3000, live.token);
331
488
  if (!ws) return null;
332
- const client = new HubClient(ws, {});
489
+ const client = new HubClient(ws, { token: live.token });
333
490
  client.stale = staleFrom(live, live.port);
334
491
  return client;
335
492
  }
@@ -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 가 기본값).
@@ -55,7 +54,7 @@ function staleHubMessage(stale) {
55
54
  /**
56
55
  * 허브에 정체성을 알리고 인스턴스 ID를 받는다. 재접속 때는 알고 있는 ID를 실어 그대로 인정받는다.
57
56
  * @param {import('./hub-client.js').HubClient} hub
58
- * @param {{agent: string, worker?: boolean, instanceId?: string, duringReconnect?: boolean}} opts
57
+ * @param {{agent: string, worker?: boolean, instanceId?: string|null, duringReconnect?: boolean}} opts
59
58
  * @returns {Promise<string>}
60
59
  */
61
60
  export async function hello(
@@ -64,7 +63,14 @@ export async function hello(
64
63
  ) {
65
64
  const r = await hub.request(
66
65
  "agent.hello",
67
- { tool: agent, cwd: process.cwd(), worker, instanceId },
66
+ // null 을 그대로 보내면 허브가 "invalid instanceId: null" 로 거절한다(undefined 만 "새로 발급"이다).
67
+ // 구버전 허브로 시작해 정체성이 없던 커넥터가 재접속 때 새 id 를 받을 수 있게 비운다.
68
+ {
69
+ tool: agent,
70
+ cwd: process.cwd(),
71
+ worker,
72
+ instanceId: instanceId ?? undefined,
73
+ },
68
74
  { duringReconnect },
69
75
  );
70
76
  return r.instanceId;
@@ -74,10 +80,11 @@ export async function hello(
74
80
  * Pluriply MCP 도구를 등록한다.
75
81
  * @param {import('@modelcontextprotocol/sdk/server/mcp.js').McpServer} server
76
82
  * @param {import('./hub-client.js').HubClient} hub
77
- * @param {{agent: string, instanceId: string}} identity
83
+ * @param {{agent: string, instanceId: string|null}} identity instanceId 는 구버전 허브로 시작하면 null 이다.
78
84
  */
79
85
  export function registerTools(server, hub, { agent, instanceId }) {
80
- const state = { currentChannel: null };
86
+ /** instanceId 는 재접속 hello 가 돌려준 값으로 갱신된다(구버전 허브로 시작해 null 이었던 경우). */
87
+ const state = { currentChannel: null, instanceId };
81
88
  const worker = Boolean(process.env.PLURIPLY_WORKER_TASK);
82
89
  /** 워커는 자기 태스크 깊이 + 1, 대화형 세션은 0 */
83
90
  const delegationDepth = () =>
@@ -181,17 +188,25 @@ export function registerTools(server, hub, { agent, instanceId }) {
181
188
  };
182
189
  }
183
190
 
184
- // 허브가 재시작되면 현재 채널에 다시 참여한다 (hub-client가 reconnected를 낸다)
191
+ // 허브가 재시작되면 정체성을 다시 알리고, 채널이 있으면 다시 참여한다 (hub-client가 reconnected를 낸다)
185
192
  if (typeof hub.on === "function") {
186
193
  hub.on("reconnected", async () => {
187
- if (!state.currentChannel) return;
188
194
  try {
189
195
  // duringReconnect: true — 이 리스너 자체가 hub-client의 재접속 배리어이므로,
190
196
  // 여기서 나가는 request()가 this.reconnecting을 기다리면 자기 자신을
191
197
  // 기다리는 교착 상태가 된다.
192
- // 새 소켓은 정체성이 없으므로 channel.join보다 먼저 hello로 인스턴스
193
- // ID를 다시 인정받아야 한다(그렇지 않으면 "say hello first").
194
- await hello(hub, { agent, worker, instanceId, duringReconnect: true });
198
+ // 새 소켓은 정체성이 없으므로 채널 유무와 관계없이 먼저 hello로 인스턴스
199
+ // ID를 다시 인정받아야 한다. 채널이 없다고 건너뛰면 이후의 자동 복귀·
200
+ // join_channel이 도구를 다시 시작할 때까지 "say hello first"로 막힌다.
201
+ // (hello는 인증이 필요한 요청이라, 토큰이 틀린 연결은 여기서 unauthorized를
202
+ // 받아 hub-client의 재접속 판정이 그것을 본다.)
203
+ state.instanceId = await hello(hub, {
204
+ agent,
205
+ worker,
206
+ instanceId: state.instanceId,
207
+ duringReconnect: true,
208
+ });
209
+ if (!state.currentChannel) return;
195
210
  await hub.request(
196
211
  "channel.join",
197
212
  { channelCode: state.currentChannel },
@@ -224,7 +239,7 @@ export function registerTools(server, hub, { agent, instanceId }) {
224
239
  channelCode: code,
225
240
  });
226
241
  state.currentChannel = code;
227
- return ok({ channelCode: code, peers, me: instanceId });
242
+ return ok({ channelCode: code, peers, me: state.instanceId });
228
243
  } catch (err) {
229
244
  return fail(err.message);
230
245
  }
@@ -258,7 +273,7 @@ export function registerTools(server, hub, { agent, instanceId }) {
258
273
  });
259
274
  const { tasks } = await hub.request("task.list", {
260
275
  channelCode: code,
261
- to: instanceId,
276
+ to: state.instanceId,
262
277
  status: "submitted",
263
278
  });
264
279
  const { running } = await hub.request("worker.status", {
@@ -410,7 +425,13 @@ export function registerTools(server, hub, { agent, instanceId }) {
410
425
  hint: created.hint ?? `dispatch: ${created.dispatch}`,
411
426
  };
412
427
  }
413
- return waitForTask({ code, taskId, waitS, extra, label: `${to} worker` });
428
+ return waitForTask({
429
+ code,
430
+ taskId,
431
+ waitS,
432
+ extra,
433
+ label: `${to} worker`,
434
+ });
414
435
  },
415
436
  ),
416
437
  );
@@ -429,7 +450,9 @@ export function registerTools(server, hub, { agent, instanceId }) {
429
450
  inputSchema: {
430
451
  to: z
431
452
  .string()
432
- .describe('Tool name (e.g. "codex") or instanceId (e.g. "codex#k7pq")'),
453
+ .describe(
454
+ 'Tool name (e.g. "codex") or instanceId (e.g. "codex#k7pq")',
455
+ ),
433
456
  request: z
434
457
  .string()
435
458
  .optional()
@@ -581,8 +604,8 @@ export function registerTools(server, hub, { agent, instanceId }) {
581
604
  ({ status, mine_only = true, sent_by_me = false, kind }, code) =>
582
605
  hub.request("task.list", {
583
606
  channelCode: code,
584
- to: sent_by_me ? undefined : mine_only ? instanceId : undefined,
585
- from: sent_by_me ? instanceId : undefined,
607
+ to: sent_by_me ? undefined : mine_only ? state.instanceId : undefined,
608
+ from: sent_by_me ? state.instanceId : undefined,
586
609
  status,
587
610
  kind,
588
611
  }),
package/src/hub/index.js CHANGED
@@ -1,16 +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 Ke}from"ws";import{mkdirSync as Ue,writeFileSync as Fe,rmSync as Rt,readFileSync as Be,chmodSync as Ye}from"node:fs";import{randomBytes as ze,timingSafeEqual as Je}from"node:crypto";import{join as At,isAbsolute as Ot}from"node:path";import{mkdirSync as qt,readFileSync as ct,writeFileSync as lt,renameSync as ut,existsSync as ht,readdirSync as F,rmSync as dt}from"node:fs";import{join as T}from"node:path";import{pluriplyHome as Ht}from"../shared/paths.js";var B=/^plp-[a-z0-9]{4}-[a-z0-9]{4}$/,D=class{constructor(e=Ht()){this.root=e,this.dir=T(e,"channels"),qt(this.dir,{recursive:!0}),this.#t()}#t(){for(let e of F(this.dir))e.endsWith(".tmp")&&dt(T(this.dir,e),{force:!0});for(let e of F(this.root))e.startsWith("agents.json.")&&e.endsWith(".tmp")&&dt(T(this.root,e),{force:!0})}loadChannel(e){if(!B.test(e))throw new Error(`invalid channel code: ${e}`);let t=T(this.dir,`${e}.json`);return ht(t)?JSON.parse(ct(t,"utf8")):null}saveChannel(e,t){if(!B.test(e))throw new Error(`invalid channel code: ${e}`);let n=T(this.dir,`${e}.json`),r=`${n}.${process.pid}.tmp`;lt(r,JSON.stringify(t,null,2)),ut(r,n)}listChannels(){return F(this.dir).filter(e=>e.endsWith(".json")).map(e=>e.slice(0,-5)).filter(e=>B.test(e))}loadAgents(){let e=T(this.root,"agents.json");if(!ht(e))return{};try{let t=JSON.parse(ct(e,"utf8"));return t&&typeof t=="object"&&!Array.isArray(t)?t:{}}catch{return{}}}saveAgents(e){let t=T(this.root,"agents.json"),n=`${t}.${process.pid}.tmp`;lt(n,JSON.stringify(e,null,2)),ut(n,t)}};import{channelCode as Ut}from"../shared/ids.js";var M=class{constructor(e){this.store=e}touch(e,t,n=new Date){try{let r=this.store.loadAgents();r[e]={lastChannel:t,lastSeenAt:n.toISOString()},this.store.saveAgents(r)}catch{}}resume(e,t=new Date){let n=this.store.loadAgents()[e];if(!n)return null;let r=t.getTime()-Date.parse(n.lastSeenAt);if(!(r>=0&&r<=432e5))return null;try{if(!this.store.loadChannel(n.lastChannel))return null}catch{return null}return n.lastChannel}};var Y=class extends Error{constructor(e){super(`channel not found: ${e}`),this.code=e}},N=class{constructor(e){this.store=e}create(){let e={channel:{code:Ut(),createdAt:new Date().toISOString(),peers:[]},tasks:[],context:[]};return this.store.saveChannel(e.channel.code,e),e}get(e){let t=this.store.loadChannel(e);if(!t)throw new Y(e);return t}save(e,t){this.store.saveChannel(e,t)}join(e,{instanceId:t,tool:n,worker:r=!1},{online:s=new Set,now:o=new Date}={}){let c=this.get(e);this.#t(c,s,o);let l=o.toISOString(),u=c.channel.peers.find(h=>h.instanceId===t);return u?u.lastSeenAt=l:c.channel.peers.push({instanceId:t,tool:n,worker:!!r,joinedAt:l,lastSeenAt:l}),this.save(e,c),{channel:c.channel,peers:c.channel.peers}}peers(e,{online:t=new Set,now:n=new Date}={}){let r=this.get(e);return this.#t(r,t,n)&&this.save(e,r),r.channel.peers}#t(e,t,n){let r=e.channel.peers.length;return e.channel.peers=e.channel.peers.filter(s=>{if(!s.instanceId)return!1;if(t.has(s.instanceId))return!0;let o=n.getTime()-Date.parse(s.lastSeenAt);return o>=0&&o<=432e5}),e.channel.peers.length!==r}};import{statSync as Ft,realpathSync as P}from"node:fs";import{sep as Bt,join as Yt,isAbsolute as zt}from"node:path";import{taskId as Jt}from"../shared/ids.js";import{parseTarget as ft,toolOf as gt,isInstanceId as Vt}from"../shared/identity.js";var z=new Set(["completed","failed","cancelled"]),C=class extends Error{constructor(e){super(`task not found: ${e}`)}},A=class extends Error{constructor(e,t){super(`invalid task transition: ${e} -> ${t}`)}},j=class extends Error{constructor(e,t,n){super(`task ${e} is addressed to "${t}", not "${n}"`)}},J=class extends Error{constructor(e,t,n){super(`only the sender "${t}" can cancel task ${e}, not "${n}"`)}},b=class extends Error{constructor(e){super(e)}},V=class extends Error{constructor(e){super(`delegation depth limit (${e}) exceeded`)}},Xt=["task","review"],mt=["approve","request_changes","comment"],pt=["critical","important","minor"],$=class extends Error{constructor(e){super(e)}},S=class extends Error{constructor(e){super(e)}},X=class extends Error{constructor(e){super(`use submit_review for review tasks (task ${e})`)}},Q=class extends Error{constructor(e){super(`task ${e} is not a review`)}},Z=class extends Error{constructor(e){super(`cwd is not an existing directory: ${e}`)}},tt=class extends Error{constructor(e){super(`cwd outside allowed roots: ${e}`)}},Qt=["auto","spawn","interactive"];function yt(i,e){return i===e||i.startsWith(e+Bt)}function Zt(i,e){if(!i||typeof i!="object"||Array.isArray(i))throw new $("review must be an object");let t={};if(i.gitRange!==void 0){if(typeof i.gitRange!="string"||i.gitRange.length===0||i.gitRange.length>200||/[\r\n]/.test(i.gitRange))throw new $("gitRange must be a single line of at most 200 characters");if(i.gitRange.startsWith("-"))throw new $("gitRange must not start with '-'");t.gitRange=i.gitRange}if(i.paths!==void 0){if(!Array.isArray(i.paths)||i.paths.some(n=>typeof n!="string"||n.length===0))throw new $("paths must be an array of strings");if(i.paths.length>0){if(e===void 0)throw new $("paths need a cwd to resolve against");let n=e;try{n=P(e)}catch{}t.paths=i.paths.map(r=>{let s=zt(r)?r:Yt(e,r),o;try{o=P(s)}catch{throw new $(`path does not exist: ${r}`)}if(!yt(o,n))throw new $(`path outside cwd: ${r}`);return o})}}if(i.focus!==void 0){if(typeof i.focus!="string"||i.focus.length>500)throw new $("focus must be a string of at most 500 characters");i.focus.length>0&&(t.focus=i.focus)}return t}function te(i){return`Review ${i.gitRange?`git range ${i.gitRange}`:i.paths?.length?`files ${i.paths.join(", ")}`:"the uncommitted changes (git diff HEAD)"}${i.focus?`, focusing on ${i.focus}`:""}.`}function ee(i){if(!i||typeof i!="object"||Array.isArray(i))throw new S("review result must be an object");if(!mt.includes(i.verdict))throw new S(`verdict must be one of ${mt.join(", ")}`);if(typeof i.summary!="string"||i.summary.trim().length===0)throw new S("summary is required");let e=i.findings??[];if(!Array.isArray(e))throw new S("findings must be an array");if(e.length>200)throw new S("findings must have at most 200 items");let t=e.map((n,r)=>{if(!n||typeof n!="object"||Array.isArray(n))throw new S(`findings[${r}] must be an object`);if(!pt.includes(n.severity))throw new S(`findings[${r}].severity must be one of ${pt.join(", ")}`);if(typeof n.message!="string"||n.message.length===0)throw new S(`findings[${r}].message is required`);let s={severity:n.severity,message:n.message};if(n.file!==void 0){if(typeof n.file!="string")throw new S(`findings[${r}].file must be a string`);s.file=n.file}if(n.line!==void 0){if(!Number.isInteger(n.line)||n.line<1)throw new S(`findings[${r}].line must be a positive integer`);s.line=n.line}if(n.suggestion!==void 0){if(typeof n.suggestion!="string")throw new S(`findings[${r}].suggestion must be a string`);s.suggestion=n.suggestion}return s});return{verdict:i.verdict,findings:t,summary:i.summary}}function wt(i,e){return i.toInstance?e===i.toInstance:gt(e)===(i.toTool??i.to)}function ne(i,e){return i.from===e?!0:!i.from.includes("#")&&gt(e)===i.from}var G=class{constructor(e){this.registry=e,this.waiters=new Map}create(e,{from:t,to:n,request:r,attachments:s=[],depth:o=0,cwd:c,origin:l,allowedRoots:u=[],mode:h="auto",maxDepth:d=2,online:p=new Set,kind:w="task",review:y,fromCwdKey:k}){if(typeof n!="string"||n.length===0)throw new b("target agent name is required");if(n.includes("#")&&!Vt(n))throw new b(`no peer "${n}" on this channel`);let g=ft(n);if(g.instance!==null&&g.instance===t)throw new b("cannot delegate a task to yourself");if((!Number.isInteger(o)||o<0)&&(o=0),o>d)throw new V(d);if(!Qt.includes(h))throw new Error(`invalid mode: ${h}`);if(c!==void 0){let _=!1;try{_=Ft(c).isDirectory()}catch{_=!1}if(!_)throw new Z(c);let R=P(c),K=[];if(l!==void 0)try{K.push(P(l))}catch{}for(let U of u)try{K.push(P(U))}catch{}if(!K.some(U=>yt(R,U)))throw new tt(c);c=R}if(!Xt.includes(w))throw new Error(`invalid kind: ${w}`);let v;if(w==="review")v=Zt(y??{},c??l),(typeof r!="string"||r.length===0)&&(r=te(v));else if(y!==void 0)throw new $('review is only valid for kind "review"');let f=this.registry.get(e),a=f.channel.peers,m=a.filter(_=>_.tool===g.tool),I;if(g.instance===null){if(I=m.length>0,!I){let _=a.find(R=>R.tool?.toLowerCase()===g.tool.toLowerCase());if(_)throw new b(`no peer named "${n}" on this channel; did you mean "${_.tool}"?`)}}else if(I=m.some(_=>_.instanceId===g.instance),!I&&m.length>0){let _=m.find(R=>p.has(R.instanceId))??m[0];throw new b(`no peer "${n}" on this channel; did you mean "${_.instanceId}"?`)}let at=new Date().toISOString(),x={taskId:Jt(),from:t,to:n,toTool:g.tool,request:r,attachments:s,depth:o,mode:h,kind:w,status:"submitted",result:null,createdAt:at,updatedAt:at};g.instance!==null&&(x.toInstance=g.instance),c!==void 0&&(x.cwd=c),typeof k=="string"&&k.length>0&&(x.fromCwdKey=k),w==="review"&&(x.review=v),f.tasks.push(x),this.registry.save(e,f);let O={task:x,targetJoined:I};return g.instance!==null&&(O.targetOnline=p.has(g.instance),I&&!O.targetOnline&&(O.warning=`"${n}" is not online; the task will wait until it reconnects.`)),I||(O.warning=`"${n}" has not joined this channel yet; the task will wait until it joins.`),O}list(e,{to:t,from:n,status:r,kind:s}={}){let o=this.registry.get(e).tasks;if(t){let c=ft(t);o=o.filter(l=>c.instance===null?(l.toTool??l.to)===c.tool:l.toInstance===c.instance||!l.toInstance&&(l.toTool??l.to)===c.tool)}return n&&(o=o.filter(c=>c.from===n)),r&&(o=o.filter(c=>c.status===r)),s&&(o=o.filter(c=>(c.kind??"task")===s)),o}get(e,t){let n=this.registry.get(e).tasks.find(r=>r.taskId===t);if(!n)throw new C(t);return n}#t(e,t,n){let r=this.registry.get(e),s=r.tasks.find(c=>c.taskId===t);if(!s)throw new C(t);let o=s.status;return n(s),s.updatedAt=new Date().toISOString(),this.registry.save(e,r),s.status!==o&&this.#r(e,t,s),s}#r(e,t,n){let r=this.waiters.get(`${e}/${t}`);if(r){this.waiters.delete(`${e}/${t}`);for(let s of r)s(n)}}waitFor(e,t,n,{signal:r}={}){let s=this.get(e,t);if(z.has(s.status))return Promise.resolve(s);if(r?.aborted)return Promise.resolve(null);let o=`${e}/${t}`;return new Promise(c=>{let l,u=this.waiters.get(o)??new Set;this.waiters.set(o,u);let h=p=>{clearTimeout(l),r?.removeEventListener("abort",d),u.delete(h),u.size===0&&this.waiters.get(o)===u&&this.waiters.delete(o),c(p)},d=()=>h(null);u.add(h),r?.addEventListener("abort",d,{once:!0}),l=setTimeout(()=>{let p=s;try{p=this.get(e,t)}catch{}h(p)},n)})}claim(e,t,n){return this.#t(e,t,r=>{if(!wt(r,n))throw new j(t,r.to,n);if(r.status!=="submitted")throw new A(r.status,"working");r.status="working"})}complete(e,t,{from:n,result:r,status:s="completed",worker:o=!1,review:c}){if(s!=="completed"&&s!=="failed")throw new A("?",s);return this.#t(e,t,l=>{if(!wt(l,n))throw new j(t,l.to,n);if(z.has(l.status))throw new A(l.status,s);let u=(l.kind??"task")==="review";if(!u&&c!==void 0)throw new Q(t);if(u&&s==="completed"){if(c===void 0)throw new X(t);r=ee(c)}l.status=s,l.result=r,l.completedBy=n,o?l.completedByWorker=!0:delete l.completedByWorker})}cancel(e,t,{agent:n,reason:r}){return this.#t(e,t,s=>{if(!ne(s,n))throw new J(t,s.from,n);if(s.status!=="submitted"&&s.status!=="working")throw new A(s.status,"cancelled");s.status="cancelled",s.result=r??null,s.cancelledBy=n})}markHookDelivered(e,t,n,r=new Date){let s=this.registry.get(e),o=s.tasks.find(c=>c.taskId===t);if(!o)throw new C(t);return o.hookDelivered={...o.hookDelivered??{},[n]:r.toISOString()},this.registry.save(e,s),o}setWorker(e,t,n){return this.#t(e,t,r=>{r.worker={...r.worker??{},...n}})}failIfOpen(e,t,{result:n,by:r}){return this.#t(e,t,s=>{z.has(s.status)||(s.status="failed",s.result=n,s.completedBy=r)})}};import{entryId as re}from"../shared/ids.js";var W=class{constructor(e){this.registry=e}add(e,{from:t,summary:n,artifacts:r=[]}){let s=this.registry.get(e),o={entryId:re(),from:t,summary:n,artifacts:r,at:new Date().toISOString()};return s.context.push(o),this.registry.save(e,s),o}list(e,{limit:t}={}){let n=this.registry.get(e).context;return Number.isInteger(t)&&t>0?n.slice(-t):n}};import{spawn as Te}from"node:child_process";import{mkdirSync as xt,openSync as be,closeSync as xe,readFileSync as Re,appendFileSync as nt,readdirSync as Ae,rmSync as Oe}from"node:fs";import{mkdir as Pe,rm as rt}from"node:fs/promises";import{join as E}from"node:path";import{workerEnabled as Ce,TEMPLATE_AGENTS as Le}from"../shared/config.js";import{join as kt}from"node:path";import{fileURLToPath as se}from"node:url";import{agyCommand as ie}from"../shared/agy.js";import{DEFAULT_LIMITS as oe}from"../shared/config.js";var ae=se(new URL("../../bin/pluriply.js",import.meta.url)),ce=["acceptEdits","bypassPermissions"],le=["mcp__pluriply__join_channel","mcp__pluriply__channel_status","mcp__pluriply__list_peers","mcp__pluriply__list_tasks","mcp__pluriply__get_task_result","mcp__pluriply__submit_review","mcp__pluriply__submit_result","mcp__pluriply__share_update","mcp__pluriply__get_channel_context"];function et(){if(process.env.PLURIPLY_ALLOW_TEMPLATE_OVERRIDE!=="1")return null;let i=process.env.PLURIPLY_WORKER_TEMPLATE_OVERRIDE;if(!i)return null;try{return JSON.parse(i)}catch(e){throw new Error(`PLURIPLY_WORKER_TEMPLATE_OVERRIDE is not valid JSON: ${e.message}`)}}function ue(i,e){let t=et()?.[i];if(!t)return;let n=r=>r.replace(/\{(taskId|channelCode|home|cwd|prompt|readOnly)\}/g,(s,o)=>String(e[o]));return{command:t.command,args:t.args.map(n)}}function It(i,{home:e,cwd:t,prompt:n,logDir:r,taskId:s,channelCode:o,taskDir:c=kt(r,s),permissionMode:l="acceptEdits",timeoutMs:u=oe.timeoutMs,readOnly:h=!1}){let d=ue(i,{taskId:s,channelCode:o,home:e,cwd:t,prompt:n,readOnly:h});if(d)return d;switch(i){case"codex":return{command:"codex",args:["exec","-C",t,"--skip-git-repo-check","-s",h?"read-only":"workspace-write","-c",'approval_policy="never"',"-o",kt(r,`${s}.last.md`),n]};case"claude-code":{if(!ce.includes(l))throw new Error(`invalid permissionMode "${l}" for claude-code worker`);let p=JSON.stringify({mcpServers:{pluriply:{command:process.execPath,args:[ae,"connector","--agent","claude-code"],env:{PLURIPLY_HOME:e}}}});return{command:"claude",args:["-p",...h?["--allowedTools",...le,"--permission-mode","default","--add-dir",c]:["--permission-mode",l],"--mcp-config",p,"--strict-mcp-config","--output-format","json",n]}}case"antigravity":return{command:ie(),args:["-p",n,...h?["--mode","plan"]:[],"--dangerously-skip-permissions","--output-format","text","--print-timeout",`${Math.ceil(u/1e3)}s`]};default:return null}}import{pidAlive as De}from"../shared/probe.js";import{writeFile as ye}from"node:fs/promises";import{execFile as he}from"node:child_process";import{promisify as de}from"node:util";var fe=de(he),me=[/^filter\..+\.(clean|smudge|process|required)$/,/^diff\..+\.(command|textconv)$/,/^merge\..+\.driver$/,/^core\.(hookspath|fsmonitor|sshcommand|pager|editor|askpass|gitproxy)$/,/^credential\.(.+\.)?helper$/,/^alias\..+$/,/^sequence\.editor$/,/^gpg\.(.+\.)?program$/],pe=["GIT_DIR","GIT_WORK_TREE","GIT_INDEX_FILE","GIT_CONFIG_PARAMETERS","GIT_CONFIG_COUNT","GIT_EXTERNAL_DIFF","GIT_SSH","GIT_SSH_COMMAND","GIT_ASKPASS","GIT_EDITOR","GIT_PAGER"];function we(){let i={...process.env};for(let e of pe)delete i[e];return i.GIT_CONFIG_GLOBAL=process.platform==="win32"?"NUL":"/dev/null",i.GIT_CONFIG_NOSYSTEM="1",i.GIT_ATTR_NOSYSTEM="1",i.GIT_TERMINAL_PROMPT="0",i}function ge(i){if(!i)return"";let e=String(i).trim().split(`
4
- `).map(t=>t.trim()).filter(Boolean);return e.length===0?"":e.find(t=>/^(fatal|error):/i.test(t))??e.at(-1)}async function L(i,{cwd:e,env:t,maxBuffer:n=4*1024*1024,timeout:r=2e4}){try{let{stdout:s}=await fe("git",i,{cwd:e,env:t,encoding:"utf8",maxBuffer:n,timeout:r,windowsHide:!0});return s}catch(s){throw new Error(ge(s.stderr)||s.message)}}async function St(i){let e=we(),t=o=>L(["config","--list",o,"--includes","-z"],{cwd:i,env:e,timeout:1e4}),n=[await t("--local")];try{n.push(await t("--worktree"))}catch(o){if(!/cannot be used with multiple working trees|unable to read config file/i.test(o.message))throw o}let r=["-c","core.fsmonitor=false"],s=new Set(["core.fsmonitor"]);for(let o of n)for(let c of o.split("\0")){if(!c)continue;let l=c.split(`
5
- `,1)[0];if(s.has(l))continue;let u=l.toLowerCase();me.some(h=>h.test(u))&&(s.add(l),r.push("-c",`${l}=`))}return{args:r,env:e}}var ke=20*1024*1024;async function _t({cwd:i,review:e,outFile:t,git:n}){if(e.gitRange?.startsWith("-"))throw new Error('gitRange must not start with "-"');let r=[...n.args,"diff","--no-color","--no-ext-diff","--no-textconv"],s;e.gitRange?(r.push(e.gitRange,"--"),e.paths?.length&&r.push(...e.paths),s=`git diff ${e.gitRange}`):e.paths?.length?(r.push("HEAD","--",...e.paths),s=`git diff HEAD -- ${e.paths.join(" ")}`):(r.push("HEAD"),s="git diff HEAD");let o;try{o=await L(r,{cwd:i,env:n.env,maxBuffer:ke,timeout:2e4})}catch(c){throw new Error(`${s} failed: ${c.message}`)}return await ye(t,o),{file:t,bytes:Buffer.byteLength(o),target:s}}import{copyFile as Ie,lstat as Se,mkdir as Et,rm as vt}from"node:fs/promises";import{dirname as _e,isAbsolute as Ee,join as $t}from"node:path";var ve=512*1024*1024,$e=16,Tt=Object.freeze({recursive:!0,force:!0,maxRetries:5,retryDelay:100});async function bt({repoDir:i,destDir:e,git:t,maxBytes:n=ve}){let s=(await L([...t.args,"ls-files","-z","-co","--exclude-standard"],{cwd:i,env:t.env,maxBuffer:67108864})).split("\0").filter(Boolean);await vt(e,Tt),await Et(e,{recursive:!0});let o=0,c=0,l=0,u=0,h=async()=>{for(;u<s.length;){let w=s[u++];if(Ee(w)||w.split("/").includes(".."))continue;let y=$t(i,w),k;try{k=await Se(y)}catch{continue}if(k.isSymbolicLink()){l++;continue}if(!k.isFile())continue;if(c+=k.size,c>n)throw new Error(`snapshot exceeds ${Math.floor(n/1024/1024)}MB`);let g=$t(e,w);await Et(_e(g),{recursive:!0}),await Ie(y,g),o++}},p=(await Promise.allSettled(Array.from({length:Math.min($e,s.length)},h))).find(w=>w.status==="rejected");if(p)throw await vt(e,Tt),p.reason;return{files:o,bytes:c,skippedSymlinks:l}}var q=Object.freeze({recursive:!0,force:!0,maxRetries:5,retryDelay:100}),Me=i=>`run \`pluriply worker enable ${i}\` to let the hub process this automatically`,Ne=i=>`no worker template is configured for "${i}"; enable it via config or set PLURIPLY_WORKER_TEMPLATE_OVERRIDE`,je=new Set(["completed","failed","cancelled"]);function Ge(i){return Le.includes(i)?!0:!!et()?.[i]}function We(i){try{return Re(i,"utf8").trimEnd().split(`
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 qe({agent:i,channelCode:e,taskId:t,cwd:n,from:r}){return[`\uB2F9\uC2E0\uC740 Pluriply \uCC44\uB110 ${e}\uC5D0\uC11C \uC704\uC784\uBC1B\uC740 \uC791\uC5C5\uC744 \uCC98\uB9AC\uD558\uB294 ${i} \uC6CC\uCEE4\uC785\uB2C8\uB2E4.`,`\uC694\uCCAD\uC790\uB294 ${r} \uC785\uB2C8\uB2E4. join_channel \uC751\uB2F5\uC758 me \uAC00 \uB2F9\uC2E0\uC758 \uC778\uC2A4\uD134\uC2A4 ID\uC785\uB2C8\uB2E4.`,`1. pluriply MCP \uB3C4\uAD6C join_channel \uB85C \uCC44\uB110 ${e} \uC5D0 \uCC38\uC5EC\uD558\uC138\uC694.`,`2. get_task_result \uB85C \uD0DC\uC2A4\uD06C ${t} \uB97C \uC77D\uC73C\uC138\uC694. \uC694\uCCAD \uBCF8\uBB38\uACFC \uCCA8\uBD80 \uACBD\uB85C\uAC00 \uC788\uC2B5\uB2C8\uB2E4.`,`3. \uC791\uC5C5\uC744 \uC218\uD589\uD558\uC138\uC694. \uC791\uC5C5 \uD3F4\uB354\uB294 ${n} \uC785\uB2C8\uB2E4. \uADF8 \uBC16\uC758 \uD30C\uC77C\uC740 \uC218\uC815\uD558\uC9C0 \uB9C8\uC138\uC694.`,"4. \uC911\uAC04\uC5D0 share_update \uB85C \uC9C4\uD589 \uC0C1\uD669\uC744 \uD55C \uBC88 \uC774\uC0C1 \uB0A8\uAE30\uC138\uC694.","5. \uB05D\uB098\uBA74 submit_result \uB85C \uACB0\uACFC\uB97C \uC81C\uCD9C\uD558\uC138\uC694. \uD560 \uC218 \uC5C6\uC73C\uBA74 failed: true \uB85C \uC774\uC720\uB97C \uC81C\uCD9C\uD558\uC138\uC694.","6. \uB2E4\uB978 \uB3C4\uAD6C\uC5D0 \uC704\uC784\uC774 \uAF2D \uD544\uC694\uD560 \uB54C\uB9CC send_task \uB97C \uC4F0\uC138\uC694. \uC704\uC784 \uAE4A\uC774 \uC81C\uD55C\uC774 \uC788\uC2B5\uB2C8\uB2E4."].join(`
8
- `)}function He({agent:i,channelCode:e,taskId:t,cwd:n,origin:r,from:s,diff:o}){return[`\uB2F9\uC2E0\uC740 Pluriply \uCC44\uB110 ${e}\uC5D0\uC11C \uCF54\uB4DC \uB9AC\uBDF0\uB97C \uC704\uC784\uBC1B\uC740 ${i} \uB9AC\uBDF0\uC5B4\uC785\uB2C8\uB2E4.`,`\uC694\uCCAD\uC790\uB294 ${s} \uC785\uB2C8\uB2E4. join_channel \uC751\uB2F5\uC758 me \uAC00 \uB2F9\uC2E0\uC758 \uC778\uC2A4\uD134\uC2A4 ID\uC785\uB2C8\uB2E4.`,`1. pluriply MCP \uB3C4\uAD6C join_channel \uB85C \uCC44\uB110 ${e} \uC5D0 \uCC38\uC5EC\uD558\uC138\uC694.`,`2. get_task_result \uB85C \uD0DC\uC2A4\uD06C ${t} \uB97C \uC77D\uC73C\uC138\uC694. review.gitRange / review.paths \uAC00 \uB300\uC0C1, review.focus \uAC00 \uAD00\uC810, request \uC5D0 \uCD94\uAC00 \uC124\uBA85\uC774 \uC788\uC2B5\uB2C8\uB2E4.`,`3. \uC791\uC5C5 \uD3F4\uB354 ${n} \uB294 \uC6D0\uBCF8 \uC800\uC7A5\uC18C ${r} \uC758 \uC2A4\uB0C5\uC0F7 \uBCF5\uC0AC\uBCF8\uC774\uBA70 git \uC800\uC7A5\uC18C\uAC00 \uC544\uB2D9\uB2C8\uB2E4. \uD5C8\uBE0C\uAC00 \uB9CC\uB4E0 unified diff \uD30C\uC77C ${o.file} (${o.target}) \uC744 \uC77D\uACE0, \uD544\uC694\uD558\uBA74 \uC791\uC5C5 \uD3F4\uB354\uC758 \uD30C\uC77C\uC744 \uD568\uAED8 \uC77D\uC5B4 \uAC80\uD1A0\uD558\uC138\uC694. git \uC744 \uC2E4\uD589\uD558\uC9C0 \uB9D0\uACE0, \uD30C\uC77C\uC744 \uC218\uC815\uD558\uAC70\uB098 \uC0C1\uD0DC\uB97C \uBC14\uAFB8\uB294 \uBA85\uB839\uC744 \uC2E4\uD589\uD558\uC9C0 \uB9C8\uC138\uC694.`,"4. \uBC1C\uACAC\uC744 \uC2EC\uAC01\uB3C4(critical/important/minor)\uC640 file/line \uC73C\uB85C \uC815\uB9AC\uD574 submit_review \uB85C \uC81C\uCD9C\uD558\uC138\uC694. file \uC740 \uC800\uC7A5\uC18C \uB8E8\uD2B8 \uAE30\uC900 \uC0C1\uB300\uACBD\uB85C\uB85C \uC801\uC73C\uC138\uC694. critical \uC774\uB098 important \uAC00 \uD558\uB098\uB77C\uB3C4 \uC788\uC73C\uBA74 verdict \uB294 request_changes, \uC5C6\uC73C\uBA74 approve, \uD310\uB2E8\uC744 \uC720\uBCF4\uD558\uBA74 comment \uC785\uB2C8\uB2E4.","5. \uAC80\uD1A0\uAC00 \uBD88\uAC00\uB2A5\uD558\uBA74(diff \uAC00 \uBE44\uC5B4 \uC788\uAC70\uB098 \uAC80\uD1A0 \uBC94\uC704\uB97C \uD310\uB2E8\uD560 \uC218 \uC5C6\uC73C\uBA74) submit_result \uC5D0 failed: true \uB85C \uC0AC\uC720\uB97C \uC81C\uCD9C\uD558\uC138\uC694.","6. \uB2E4\uB978 \uB3C4\uAD6C\uC5D0 \uC704\uC784\uD558\uC9C0 \uB9C8\uC138\uC694."].join(`
9
- `)}var H=class{constructor({home:e,tasks:t}){this.home=e,this.tasks=t,this.running=new Map,this.swept=!1,this.queue=[],this.stopping=!1}dispatch(e,t,{interactive:n,config:r}){if(t.toInstance)return{kind:"pinned",hint:"pinned tasks are never handed to a worker"};let s=t.toTool??t.to;if(t.mode==="interactive")return{kind:"interactive"};if(t.mode==="auto"&&n)return{kind:"interactive"};if(this.stopping)return{kind:"none",hint:"hub is stopping"};if(!Ce(r,s))return{kind:"none",hint:Me(s)};try{if(!Ge(s))return{kind:"none",hint:Ne(s)};if(this.#t(s)>=r.limits.maxConcurrentPerAgent)return this.#r(s)>=r.limits.maxQueuedPerAgent?{kind:"none",hint:`worker queue full (${r.limits.maxQueuedPerAgent}) for ${s}`}:(this.queue.push({code:e,task:t,config:r}),{kind:"queued"});this.#o(e,t,r)}catch(o){return{kind:"none",hint:`worker spawn failed: ${o.message}`}}return{kind:"spawned"}}runningCount(e){let t=0;for(let n of this.running.values())for(let r of n)(e===void 0||r.code===e)&&t++;return t}onCancelled(e,t){for(let r of this.running.values())for(let s of r)s.code===e&&s.taskId===t&&(s.child?s.child.kill("SIGTERM"):s.cancelled=!0);let n=this.queue.findIndex(r=>r.code===e&&r.task.taskId===t);n!==-1&&this.queue.splice(n,1)}reconcile(e){for(let t of this.tasks.list(e)){let n=t.worker;!n||n.endedAt||!Number.isInteger(n.pid)||this.#c(e,t.taskId)||De(n.pid)||(this.tasks.setWorker(e,t.taskId,{endedAt:new Date().toISOString()}),this.tasks.failIfOpen(e,t.taskId,{result:"hub restarted while worker was running",by:`${n.agent} worker`}))}this.swept||(this.swept=!0,this.#e())}async stopAll(){this.stopping=!0;let e=[];for(let[r,s]of this.running)for(let o of s){if(!o.child){o.cancelled=!0,s.delete(o);try{this.tasks.failIfOpen(o.code,o.taskId,{result:"hub stopped while the review was being prepared",by:`${r} worker`})}catch{}rt(E(this.home,"workers",o.taskId,"tree"),q).catch(()=>{});continue}o.child.kill("SIGTERM"),e.push(o.child)}for(let r of this.queue)try{this.tasks.failIfOpen(r.code,r.task.taskId,{result:"hub stopped while the task was queued",by:`${r.task.toTool??r.task.to} worker`})}catch{}this.queue.length=0;let t=r=>r.exitCode===null&&r.signalCode===null,n=Date.now()+2e3;for(;e.some(t)&&Date.now()<n;)await new Promise(r=>setTimeout(r,100));for(let r of e)if(t(r))try{r.kill("SIGKILL")}catch{}}#t(e){return this.running.get(e)?.size??0}#r(e){let t=0;for(let n of this.queue)(n.task.toTool??n.task.to)===e&&t++;return t}#c(e,t){for(let n of this.running.values())for(let r of n)if(r.code===e&&r.taskId===t)return!0;return!1}#s(e){for(let t of this.running.values())for(let n of t)if(n.taskId===e)return!0;return!1}#e(){let e=E(this.home,"workers"),t;try{t=Ae(e,{withFileTypes:!0})}catch{return}for(let n of t)if(!(!n.isDirectory()||this.#s(n.name)))try{Oe(E(e,n.name,"tree"),q)}catch{}}#o(e,t,n){let r=t.toTool??t.to,s={code:e,taskId:t.taskId,child:null,cancelled:!1};this.running.has(r)||this.running.set(r,new Set),this.running.get(r).add(s),this.#a(e,t,n,s).catch(o=>{this.#n(s,r,t,`worker spawn failed: ${o.message}`)})}#n(e,t,n,r){this.running.get(t)?.delete(e);let s=E(this.home,"workers",`${n.taskId}.log`);try{nt(s,`${r}
10
- `)}catch{}try{this.tasks.failIfOpen(e.code,n.taskId,{result:r,by:`${t} worker`})}catch{}rt(E(this.home,"workers",n.taskId,"tree"),q).catch(()=>{}),this.#i(t)}async#a(e,t,n,r){let s=t.toTool??t.to,o=E(this.home,"workers");xt(o,{recursive:!0});let c=E(o,`${t.taskId}.log`),l=(t.kind??"task")==="review",u=E(o,t.taskId),h={agent:s,channelCode:e,taskId:t.taskId,from:t.from},d,p,w="";try{if(l){if(!t.cwd)throw new Error("review task has no cwd");await Pe(u,{recursive:!0});let f=await St(t.cwd);d=E(u,"tree");let a=await bt({repoDir:t.cwd,destDir:d,git:f}),m=await _t({cwd:t.cwd,review:t.review??{},outFile:E(u,"review.diff"),git:f});w=`snapshot: ${a.files} files, ${a.bytes} bytes, ${a.skippedSymlinks} symlinks skipped
11
- `,p=He({...h,cwd:d,origin:t.cwd,diff:m}),await this.#l()}else d=t.cwd??E(this.home,"workspaces",t.taskId),xt(d,{recursive:!0}),p=qe({...h,cwd:d})}catch(f){this.#n(r,s,t,`${l?"review preparation":"worker spawn"} failed: ${f.message}`);return}if(r.cancelled||this.stopping){this.#n(r,s,t,this.stopping?"hub stopped while the worker was being prepared":"worker cancelled while it was being prepared");return}let y;try{let f=It(s,{home:this.home,cwd:d,prompt:p,logDir:o,taskDir:u,taskId:t.taskId,channelCode:e,permissionMode:n.workers[s]?.permissionMode,timeoutMs:n.limits.timeoutMs,readOnly:l});w&&nt(c,w);let a=be(c,"a");try{y=Te(f.command,f.args,{cwd:d,shell:!1,stdio:["ignore",a,a],env:{...process.env,PLURIPLY_HOME:this.home,PLURIPLY_WORKER_TASK:t.taskId,PLURIPLY_WORKER_AGENT:s,PLURIPLY_DEPTH:String(t.depth??0)}})}finally{xe(a)}}catch(f){this.#n(r,s,t,`worker spawn failed: ${f.message}`);return}r.child=y;try{this.tasks.setWorker(e,t.taskId,{agent:s,pid:y.pid,startedAt:new Date().toISOString(),log:c})}catch(f){try{y.kill("SIGKILL")}catch{}this.#n(r,s,t,`worker spawn failed: ${f.message}`);return}let k=!1,g=setTimeout(()=>{k=!0,y.kill("SIGTERM"),setTimeout(()=>y.kill("SIGKILL"),5e3).unref()},n.limits.timeoutMs),v=(f,a)=>{clearTimeout(g),this.running.get(s)?.delete(r);let m={endedAt:new Date().toISOString(),exitCode:f};k&&(m.timedOut=!0);try{this.tasks.setWorker(e,t.taskId,m);let I;if(a){I=`worker failed to start: ${a.message}`;try{nt(c,`${I}
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.#i(s)};y.on("exit",(f,a)=>v(f??(a?-1:0))),y.on("error",f=>v(-1,f))}async#l(){if(process.env.PLURIPLY_ALLOW_TEMPLATE_OVERRIDE!=="1")return;let e=Number(process.env.PLURIPLY_TEST_PREPARE_DELAY_MS);e>0&&await new Promise(t=>setTimeout(t,e))}#i(e){if(!this.stopping)for(let t=0;t<this.queue.length;t++){let n=this.queue[t];if((n.task.toTool??n.task.to)!==e)continue;let r;try{r=this.tasks.get(n.code,n.task.taskId).status}catch{this.queue.splice(t,1),t--;continue}if(je.has(r)){this.queue.splice(t,1),t--;continue}if(this.#t(e)>=n.config.limits.maxConcurrentPerAgent)return;this.queue.splice(t,1);try{this.#o(n.code,n.task,n.config)}catch(s){try{this.tasks.failIfOpen(n.code,n.task.taskId,{result:`worker spawn failed: ${s.message}`,by:`${n.task.toTool??n.task.to} worker`})}catch{}}return}}};import{loadConfig as Ve}from"../shared/config.js";import{pluriplyHome as Xe}from"../shared/paths.js";import{shortId as Qe}from"../shared/ids.js";import{isValidAgentName as Pt,isInstanceId as Ze,makeInstanceId as tn,toolOf as en,cwdKey as Ct}from"../shared/identity.js";import{PACKAGE_VERSION as Lt,PROTOCOL_VERSION as Dt}from"../shared/version.js";import{pingHub as nn,pidAlive as Mt,homeId as Nt}from"../shared/probe.js";function st(i){try{let e=JSON.parse(Be(i,"utf8"));return Number.isInteger(e?.pid)&&Number.isInteger(e?.port)?e:null}catch{return null}}var rn="unauthorized: hub requires a token \u2014 re-run `npx pluriply@latest setup` and restart your tool",it=class{constructor({home:e=Xe(),port:t=0,verifyDelayMs:n=100,log:r=s=>process.stderr.write(s)}={}){this.home=e,this.requestedPort=t,this.verifyDelayMs=n,this.log=r,this.token=null;let s=new D(e);this.channels=new N(s),this.tasks=new G(this.channels),this.context=new W(this.channels),this.agents=new M(s),this.workers=new H({home:e,tasks:this.tasks}),this.wss=null,this.redundant=!1,this.redundantPort=null,this.connections=new Map,this.issued=new Set}get port(){return this.redundant?this.redundantPort:this.wss?.address()?.port}async start(){Ue(this.home,{recursive:!0,mode:448});try{Ye(this.home,448)}catch(t){this.log(`hub: could not chmod ${this.home} to 0700 (${t.code??t.message})
15
- `)}await new Promise((t,n)=>{this.wss=new Ke({host:"127.0.0.1",port:this.requestedPort,verifyClient:(r,s)=>{"origin"in r.req.headers?s(!1,403,"Forbidden"):s(!0)}}),this.wss.on("listening",t),this.wss.on("error",n)}),this.wss.on("connection",(t,n)=>{this.connections.set(t,{authed:this.#o(n),remote:n.socket?.remoteAddress??"?",warned:!1,instanceId:null,tool:null,worker:!1,cwdKey:null,channels:new Set,abort:new AbortController}),t.on("message",r=>this.#c(t,r)),t.on("close",()=>{this.connections.get(t)?.abort.abort(),this.connections.delete(t)})});let e=At(this.home,"hub.json");for(let t=0;t<2;t++){if(this.#r(e)){await new Promise(s=>setTimeout(s,this.verifyDelayMs));let r=st(e);return r&&r.pid!==process.pid?(await new Promise(s=>this.wss.close(s)),this.wss=null,this.redundant=!0,this.redundantPort=r.port,this.token=typeof r.token=="string"?r.token:null,{port:r.port,redundant:!0}):{port:this.port}}let n=st(e);if(n&&await this.#t(n))return await new Promise(r=>this.wss.close(r)),this.wss=null,this.redundant=!0,this.redundantPort=n.port,this.token=typeof n.token=="string"?n.token:null,{port:n.port,redundant:!0};Rt(e,{force:!0})}throw await new Promise(t=>this.wss.close(t)),this.wss=null,new Error("could not acquire hub lock")}async#t(e){if(!Mt(e.pid))return!1;let t=Date.now()+2e3;for(;;){let n=await nn(e.port,300);if(n)return!n.home||n.home===Nt(this.home);if(Date.now()>=t||!Mt(e.pid))return!1;await new Promise(r=>setTimeout(r,200))}}#r(e){let t=ze(32).toString("hex"),n=JSON.stringify({pid:process.pid,port:this.wss.address().port,version:Lt,protocol:Dt,startedAt:new Date().toISOString(),token:t},null,2);try{return Fe(e,n,{flag:"wx",mode:384}),this.token=t,!0}catch(r){if(r.code==="EEXIST")return!1;throw r}}async stop(){if(this.redundant||!this.wss)return;await this.workers.stopAll();let e=At(this.home,"hub.json");st(e)?.pid===process.pid&&Rt(e,{force:!0}),this.token=null;for(let t of this.connections.keys())t.terminate();await new Promise(t=>this.wss.close(t)),this.wss=null}async#c(e,t){let n;try{n=JSON.parse(t.toString())}catch{return}if(!n||typeof n!="object")return;let r=this.connections.get(e);if(n.type!=="ping"&&!r?.authed){if(r&&!r.warned){r.warned=!0;let s=String(n.type).slice(0,40).replace(/[^\w.-]/g,"?");this.log(`hub: rejected unauthenticated ${s} from ${r.remote}
16
- `)}this.#s(e,{id:n.id,ok:!1,error:{message:rn}});return}try{let s=await this.#u(n.type,n.payload??{},e);this.#s(e,{id:n.id,ok:!0,payload:s})}catch(s){this.#s(e,{id:n.id,ok:!1,error:{message:s.message}})}}#s(e,t){e.readyState===e.OPEN&&e.send(JSON.stringify(t))}#e(e){let t=this.connections.get(e);if(!t||!t.instanceId)throw new Error("say hello first");return t}#o(e){if(!this.token)return!1;let t=e.headers.authorization;if(typeof t!="string")return!1;let n=/^Bearer (\S+)$/.exec(t);if(!n)return!1;let r=Buffer.from(n[1]),s=Buffer.from(this.token);return r.length===s.length&&Je(r,s)}onlineInstances(e){let t=new Set;for(let n of this.connections.values())n.instanceId&&n.channels.has(e)&&t.add(n.instanceId);return t}isInteractive(e,t){for(let n of this.connections.values())if(n.tool===t&&!n.worker&&n.channels.has(e))return!0;return!1}#n(e){let t=[],n=[];for(let r of this.connections.values())!r.instanceId||!r.channels.has(e)||(r.worker?n:t).push(r.instanceId);return{interactive:t,workers:n}}#a(e,t){let n=this.onlineInstances(e);return t.map(r=>({...r,online:n.has(r.instanceId)}))}#l(e){for(;;){let t=tn(e,Qe(4));if(!(this.issued.has(t)||[...this.connections.values()].some(r=>r.instanceId===t)))return this.issued.add(t),t}}#i(e,t,n){let r=this.onlineInstances(n);r.add(t.instanceId);let{peers:s}=this.channels.join(n,{instanceId:t.instanceId,tool:t.tool,worker:t.worker},{online:r});return this.agents.touch(t.cwdKey,n),t.channels.add(n),this.#a(n,s)}#u(e,t,n){switch(e){case"ping":return{pong:!0,version:Lt,protocol:Dt,pid:process.pid,home:Nt(this.home)};case"hook.poll":{let r=t.tool,s={channelCode:null,tool:r,incoming:[],results:[],more:0};if(!Pt(r)||typeof t.cwd!="string"||!Ot(t.cwd))return s;let o=Ct(r,t.cwd),c=[...this.connections.values()].filter(a=>a.cwdKey===o);if(c.length>0&&c.every(a=>a.worker))return s;let l=c.filter(a=>!a.worker),u=l.length>0?[...new Set(l.flatMap(a=>[...a.channels]))]:[this.agents.resume(o)].filter(Boolean);if(u.length===0)return s;for(let a of u)this.workers.reconcile(a);let h=new Set(l.map(a=>a.instanceId)),d=a=>!a.hookDelivered?.[o],p=u.flatMap(a=>this.tasks.list(a).map(m=>({t:m,code:a}))),w=p.filter(({t:a})=>a.status==="submitted"&&d(a)&&(a.toInstance?h.has(a.toInstance):(a.toTool??a.to)===r)),y=p.filter(({t:a})=>a.fromCwdKey===o&&(a.status==="completed"||a.status==="failed")&&d(a)),k=a=>{let m=String(a.request??"").replace(/\s+/g," ").trim();return m.length>80?`${m.slice(0,80)}\u2026`:m},g=[...w.map(({t:a,code:m})=>({t:a,code:m,at:a.createdAt,entry:{taskId:a.taskId,kind:a.kind??"task",from:a.from,summary:k(a)},side:"incoming"})),...y.map(({t:a,code:m})=>({t:a,code:m,at:a.updatedAt,entry:{taskId:a.taskId,status:a.status,to:a.completedBy??a.to,summary:k(a)},side:"results"}))].sort((a,m)=>a.at<m.at?-1:a.at>m.at?1:0),v=g.slice(0,10),f=new Date;for(let a of v)this.tasks.markHookDelivered(a.code,a.t.taskId,o,f);return{channelCode:u[0],tool:r,incoming:v.filter(a=>a.side==="incoming").map(a=>a.entry),results:v.filter(a=>a.side==="results").map(a=>a.entry),more:g.length-v.length}}case"channel.create":return{channelCode:this.channels.create().channel.code};case"agent.hello":{if(!Pt(t.tool))throw new Error(`invalid agent name: ${t.tool}`);if(typeof t.cwd!="string"||t.cwd.length===0||!Ot(t.cwd))throw new Error("cwd is required");let r=this.connections.get(n);if(r.instanceId)return{instanceId:r.instanceId};let s=t.instanceId;if(s!==void 0){if(!Ze(s)||en(s)!==t.tool)throw new Error(`invalid instanceId: ${s}`)}else s=this.#l(t.tool);return r.instanceId=s,r.tool=t.tool,r.worker=!!t.worker,r.cwdKey=Ct(t.tool,t.cwd),{instanceId:s}}case"channel.join":{let r=this.#e(n);this.channels.get(t.channelCode);let s=this.#i(n,r,t.channelCode);return{channelCode:t.channelCode,peers:s}}case"channel.peers":return{peers:this.#a(t.channelCode,this.channels.peers(t.channelCode,{online:this.onlineInstances(t.channelCode)}))};case"channel.presence":return this.channels.get(t.channelCode),this.#n(t.channelCode);case"agent.resume":{let r=this.#e(n);if(r.channels.size>0)return{channelCode:null,alreadyJoined:!0};let s=this.agents.resume(r.cwdKey);if(!s)return{channelCode:null};let o=this.#i(n,r,s);return{channelCode:s,peers:o}}case"task.create":{let r=this.#e(n),s=Ve(this.home),{task:o,targetJoined:c,targetOnline:l,warning:u}=this.tasks.create(t.channelCode,{...t,from:r.instanceId,fromCwdKey:r.cwdKey,depth:t.depth??0,mode:t.mode??"auto",maxDepth:s.limits.maxDepth,allowedRoots:s.allowedRoots,online:this.onlineInstances(t.channelCode)});this.agents.touch(r.cwdKey,t.channelCode);let h=this.workers.dispatch(t.channelCode,o,{interactive:this.isInteractive(t.channelCode,o.toTool),config:s}),d={taskId:o.taskId,targetJoined:c,dispatch:h.kind};return l!==void 0&&(d.targetOnline=l),u&&(d.warning=u),h.hint&&(d.hint=h.hint),d}case"task.list":return this.workers.reconcile(t.channelCode),{tasks:this.tasks.list(t.channelCode,{to:t.to,from:t.from,status:t.status,kind:t.kind})};case"task.get":return this.workers.reconcile(t.channelCode),{task:this.tasks.get(t.channelCode,t.taskId)};case"task.wait":{this.workers.reconcile(t.channelCode);let r=Math.min(Math.max(Number(t.timeoutMs)||0,1),15e3),s=this.connections.get(n)?.abort.signal;return this.tasks.waitFor(t.channelCode,t.taskId,r,{signal:s}).then(o=>{if(o===null)throw new Error("connection closed while waiting");return{task:o}})}case"task.claim":{let r=this.#e(n),s=this.tasks.claim(t.channelCode,t.taskId,r.instanceId);return this.agents.touch(r.cwdKey,t.channelCode),{task:s}}case"task.complete":{let r=this.#e(n),s=this.tasks.complete(t.channelCode,t.taskId,{from:r.instanceId,result:t.result,status:t.status,worker:r.worker,review:t.review});if(this.agents.touch(r.cwdKey,t.channelCode),(s.kind??"task")==="review"&&s.status==="completed")try{this.context.add(t.channelCode,{from:r.instanceId,summary:`[review] ${s.result.verdict} by ${r.instanceId}: ${s.result.summary}`,artifacts:[]})}catch{}return{task:s}}case"task.cancel":{let r=this.#e(n),s=this.tasks.cancel(t.channelCode,t.taskId,{agent:r.instanceId,reason:t.reason});return this.agents.touch(r.cwdKey,t.channelCode),this.workers.onCancelled(t.channelCode,t.taskId),{task:s}}case"worker.status":return t.channelCode!==void 0&&this.channels.get(t.channelCode),{running:this.workers.runningCount(t.channelCode)};case"context.add":{let r=this.#e(n),s=this.context.add(t.channelCode,{...t,from:r.instanceId});return this.agents.touch(r.cwdKey,t.channelCode),{entryId:s.entryId}}case"context.list":return{entries:this.context.list(t.channelCode,{limit:t.limit})};default:throw new Error(`unknown message type: ${e}`)}}};import{spawn as sn}from"node:child_process";import{existsSync as on,rmSync as ot}from"node:fs";import{join as an}from"node:path";import{fileURLToPath as cn}from"node:url";import{pingHub as jt,homeId as ln,pidAlive as un}from"../shared/probe.js";import{readLock as Gt}from"../shared/lock.js";var hn=cn(new URL("../../bin/pluriply.js",import.meta.url));async function Wt(i){let e=Gt(i);if(!e)return null;let t=await jt(e.port);return!t||t.home&&t.home!==ln(i)?null:{...t,port:e.port,lockPid:e.pid,token:e.token}}async function dn({home:i,timeoutMs:e=5e3}){sn(process.execPath,[hn,"hub","start"],{detached:!0,stdio:"ignore",env:{...process.env,PLURIPLY_HOME:i}}).unref();let n=Date.now()+e;for(;Date.now()<n;){let r=await Wt(i);if(r)return r;await new Promise(s=>setTimeout(s,100))}throw new Error(`failed to start pluriply hub within ${e/1e3}s`)}async function fn({home:i,timeoutMs:e=5e3}){let t=Gt(i),n=an(i,"hub.json");if(!t)return"not-running";let r=await jt(t.port,1e3),s=r&&Number.isInteger(r.pid)&&Number.isInteger(t.pid)?r.pid!==t.pid:!1;if(!r||s)return ot(n,{force:!0}),"not-running";try{process.kill(t.pid,"SIGTERM")}catch{return ot(n,{force:!0}),"stopped"}let o=Date.now()+e;for(;Date.now()<o;){if(!on(n))return"stopped";if(!un(t.pid))return ot(n,{force:!0}),"stopped";await new Promise(c=>setTimeout(c,100))}return"timeout"}import{readLock as Rr}from"../shared/lock.js";import{loadConfig as Or,saveConfig as Pr,setWorkerEnabled as Cr,TEMPLATE_AGENTS as Lr}from"../shared/config.js";export{it as Hub,Lr as TEMPLATE_AGENTS,Wt as liveHub,Or as loadConfig,Rr as readLock,Pr as saveConfig,Cr as setWorkerEnabled,dn as spawnHub,fn as stopHub};
14
+ ${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};
@@ -104,6 +104,13 @@ export function isSkipped(env) {
104
104
  */
105
105
  export const TOOL_TIMEOUT_SEC = 600;
106
106
 
107
+ /**
108
+ * Codex MCP 서버 기동 타임아웃(초). Codex 기본값은 10초인데, 커넥터는 MCP 핸드셰이크 전에 허브를
109
+ * 기다린다 — 멎은 허브가 락을 쥐면 인수 유예(10초)를 포함해 spawnHub 가 최대 20초 남짓 걸려 기본값으로는
110
+ * 기동에 실패한다(Plan 4g 최종 리뷰). 스폰 대기보다 길어야 한다(test/hub/timing.test.js 가 고정).
111
+ */
112
+ export const CODEX_STARTUP_TIMEOUT_SEC = 30;
113
+
107
114
  /**
108
115
  * @param {"claude-desktop"|"antigravity-ide"} id
109
116
  * Antigravity 는 2.x 부터 IDE(`~/.gemini/antigravity-ide`) 와 허브(`~/.gemini/antigravity`) 로
@@ -297,7 +304,18 @@ function cliClient({
297
304
  register(env) {
298
305
  if (isSkipped(env)) return "skipped";
299
306
  const st = this.status(env);
300
- if (st === "present") return "present";
307
+ if (st === "present") {
308
+ // 예전 버전으로 등록돼 타임아웃 키가 빠졌을 수 있다(예: 0.5.1 에 생긴 Codex startup_timeout_sec).
309
+ // 없는 키만 채우고 있는 값은 보존한다. 이미 동작하는 등록이므로 채우지 못해도 되돌리지 않고
310
+ // 안내만 한다 — 되돌리면 멀쩡한 등록을 지우게 된다.
311
+ if (afterAdd) {
312
+ const r = afterAdd(env);
313
+ if (!r.ok) env.log(`hint: ${label}: ${r.reason}`);
314
+ else if (r.changed)
315
+ env.log(`updated ${label}: added missing timeout settings`);
316
+ }
317
+ return "present";
318
+ }
301
319
  if (typeof st === "object") {
302
320
  env.log(
303
321
  `hint: make sure ${label} has pluriply registered: ${hint(env.binPath)}`,
@@ -497,8 +515,17 @@ export const CLIENTS = [
497
515
  ok: false,
498
516
  reason: `tool_timeout_sec not written (${path} contains triple-quoted strings; add \`tool_timeout_sec = ${TOOL_TIMEOUT_SEC}\` under [mcp_servers.pluriply] by hand)`,
499
517
  };
500
- if (r.changed) writeFileAtomic(env, path, r.text);
501
- return { ok: true };
518
+ // 헤더·트리플쿼트 판정은 파일 단위라 위에서 통과했으면 두 번째 키도 같은 이유로는 실패하지 않는다.
519
+ // 이미 있는 값(사용자가 고친 값 포함)은 건드리지 않는다.
520
+ const s = insertTomlKey(
521
+ r.text,
522
+ "mcp_servers.pluriply",
523
+ "startup_timeout_sec",
524
+ String(CODEX_STARTUP_TIMEOUT_SEC),
525
+ );
526
+ const changed = r.changed || s.changed;
527
+ if (changed) writeFileAtomic(env, path, s.text);
528
+ return { ok: true, changed };
502
529
  } catch (err) {
503
530
  return {
504
531
  ok: false,
@@ -543,11 +570,12 @@ export const CLIENTS = [
543
570
  ok: false,
544
571
  reason: `timeoutSeconds not written (mcpServers.pluriply not found in ${path})`,
545
572
  };
546
- if (entry.timeoutSeconds === undefined) {
573
+ const changed = entry.timeoutSeconds === undefined;
574
+ if (changed) {
547
575
  entry.timeoutSeconds = TOOL_TIMEOUT_SEC;
548
576
  writeJsonAtomic(env, path, doc);
549
577
  }
550
- return { ok: true };
578
+ return { ok: true, changed };
551
579
  } catch (err) {
552
580
  return {
553
581
  ok: false,
@@ -109,7 +109,8 @@ function walkHooks({ targets, rows, e, dryRun, hooks, remove }) {
109
109
  * `remove` 면 반대로 등록을 풀고 워커 설정·허브·(purge 시) 데이터까지 정리한다.
110
110
  * `--purge` 는 pluriply 홈이 심볼릭 링크면 **링크만 끊고** 링크가 가리키는 디렉터리는 남긴다
111
111
  * (그 안의 내용까지 지우려면 실제 경로를 직접 지워야 한다).
112
- * env.stopHub / env.rm / env.lstat 은 테스트가 주입한다.
112
+ * env.stopHub / env.ensureHub / env.rm / env.lstat 은 테스트가 주입한다(주입하지 않은 ensureHub 는 진짜
113
+ * detached 허브를 띄운다 — 테스트가 넣지 않으면 임시 홈에 허브가 남는다).
113
114
  * `hooks`(기본 true)는 Claude Code·Codex 의 Stop 훅 등록 여부다(스펙 §6). `--remove` 는 이 값과
114
115
  * 무관하게 항상 훅을 제거한다.
115
116
  * `hooksOnly`(스펙 §4)는 MCP 등록·워커·허브를 건너뛰고 Stop 훅만 설치(`remove` 면 제거)한다.
@@ -153,7 +154,8 @@ export async function runSetup({
153
154
  };
154
155
  if (!dryRun) {
155
156
  try {
156
- out.hub = { port: (await (await hubClient()).ensureHub({ home })).port };
157
+ const ensure = e.ensureHub ?? (await hubClient()).ensureHub;
158
+ out.hub = { port: (await ensure({ home })).port };
157
159
  } catch (err) {
158
160
  out.hubError = err.message;
159
161
  }