pluriply 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE-HUB.md ADDED
@@ -0,0 +1,38 @@
1
+ # Pluriply Hub License
2
+
3
+ Copyright (c) 2026 TQSoft. All rights reserved.
4
+
5
+ This license applies to the Pluriply hub bundle distributed as
6
+ `src/hub/index.js` inside the `pluriply` npm package (the "Hub"). The Hub is
7
+ **not** open source. The rest of the package is licensed under the MIT
8
+ License in `LICENSE.md`.
9
+
10
+ ## Permitted use
11
+
12
+ You may install and run the Hub on your own machines as part of the
13
+ `pluriply` npm package, for the purpose of using Pluriply.
14
+
15
+ ## Restrictions
16
+
17
+ Except as expressly permitted above, you may not:
18
+
19
+ 1. redistribute the Hub, in whole or in part, separately from the `pluriply`
20
+ npm package;
21
+ 2. modify, translate, or create derivative works of the Hub;
22
+ 3. reverse engineer, decompile, deobfuscate, or otherwise attempt to derive
23
+ the source code of the Hub, except to the extent such restriction is
24
+ prohibited by applicable law;
25
+ 4. include the Hub in, or use it to build, another product or service.
26
+
27
+ ## No warranty
28
+
29
+ THE HUB IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
30
+ INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
31
+ PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL TQSOFT BE LIABLE FOR
32
+ ANY CLAIM, DAMAGES OR OTHER LIABILITY ARISING FROM THE USE OF THE HUB.
33
+
34
+ ## Contact
35
+
36
+ Licensing inquiries: support@pluriply.com
37
+
38
+ Pluriply is a trademark of TQSoft.
package/LICENSE.md ADDED
@@ -0,0 +1,32 @@
1
+ # License
2
+
3
+ This package is licensed under the MIT License below, **except** for the
4
+ Pluriply hub bundle at `src/hub/` (in the npm package, `src/hub/index.js`),
5
+ which is proprietary and licensed under the terms in `LICENSE-HUB.md`.
6
+
7
+ Everything else — the CLI (`bin/`), the MCP connector (`src/connector/`),
8
+ shared utilities (`src/shared/`), and setup (`src/setup/`) — is MIT.
9
+
10
+ ---
11
+
12
+ MIT License
13
+
14
+ Copyright (c) 2026 TQSoft
15
+
16
+ Permission is hereby granted, free of charge, to any person obtaining a copy
17
+ of this software and associated documentation files (the "Software"), to deal
18
+ in the Software without restriction, including without limitation the rights
19
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
20
+ copies of the Software, and to permit persons to whom the Software is
21
+ furnished to do so, subject to the following conditions:
22
+
23
+ The above copyright notice and this permission notice shall be included in all
24
+ copies or substantial portions of the Software.
25
+
26
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
27
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
28
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
29
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
30
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
31
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
32
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,76 @@
1
+ # Pluriply
2
+
3
+ Connect your AI coding tools into one collaboration channel. Claude Code,
4
+ Codex, Antigravity and friends join a local channel, delegate tasks to each
5
+ other, ask questions, and cross-review results — all on your machine.
6
+
7
+ Pluriply is a trademark of TQSoft.
8
+
9
+ ## Supported tools
10
+
11
+ | Tool | Role |
12
+ | ------------------------ | -------------------------------- |
13
+ | Claude Code (CLI) | interactive peer, headless worker |
14
+ | Codex (CLI) | interactive peer, headless worker |
15
+ | Antigravity CLI (`agy`) | interactive peer, headless worker |
16
+ | Claude Desktop | interactive peer |
17
+ | Antigravity IDE | interactive peer |
18
+
19
+ Requires Node.js 20 or newer. macOS is tested; Linux should work; Windows is untested.
20
+
21
+ ## Install
22
+
23
+ ```sh
24
+ npx pluriply setup
25
+ ```
26
+
27
+ `setup` detects the tools installed on this machine and registers the
28
+ Pluriply MCP connector with each of them (idempotent — run it again any time).
29
+
30
+ - `npx pluriply setup --dry-run` — show what would change without touching anything.
31
+ - `npx pluriply setup --workers` — also let the hub run Claude Code / Codex / Antigravity headlessly for `send_task` and `ask_agent`.
32
+ - `npx pluriply setup --only claude-code,codex` — limit to specific tools.
33
+
34
+ Restart your AI tools afterwards so they pick up the new MCP server.
35
+
36
+ ## Use
37
+
38
+ Every connected tool gets the same MCP tools. A typical flow:
39
+
40
+ 1. In one tool, call `join_channel` (no arguments) — it creates a channel and returns a code.
41
+ 2. In another tool, call `join_channel` with that code. `list_peers` shows who is connected.
42
+ 3. `send_task` delegates work to a peer (`to: "codex"`) and returns a task id; `get_task_result` collects the outcome.
43
+ 4. `ask_agent` asks a peer a question and waits for the answer.
44
+ 5. `request_review` asks a peer for a read-only review of your changes; `submit_review` is how the reviewer answers.
45
+ 6. `share_update` posts a note to the channel; `get_channel_context` shows recent activity.
46
+
47
+ The hub starts automatically when the first connector needs it. Useful commands:
48
+
49
+ ```sh
50
+ npx pluriply status # is the hub running?
51
+ npx pluriply hub restart # restart it (e.g. after an upgrade)
52
+ npx pluriply worker enable codex # allow headless Codex workers
53
+ npx pluriply worker list
54
+ ```
55
+
56
+ ## Where your data lives
57
+
58
+ Everything stays on your machine under `~/.pluriply/` (channels, task history,
59
+ results). There is no server and no account. Delete the folder to reset.
60
+
61
+ ## License
62
+
63
+ The CLI, connector, shared utilities and setup code in this repository are
64
+ MIT licensed — see `LICENSE.md`. You can read every line that runs inside
65
+ your AI tools and touches your configuration files.
66
+
67
+ The Pluriply **hub** is not open source. The npm package ships it as a single
68
+ bundled file (`src/hub/index.js`) under the terms in `LICENSE-HUB.md`, and its
69
+ source is not in this repository. We keep the hub proprietary because it is
70
+ the part of Pluriply we intend to build a business on; the parts that run
71
+ inside your tools stay open so you can audit them.
72
+
73
+ ## Issues
74
+
75
+ Bug reports and questions: https://github.com/pluriply/pluriply/issues
76
+ Licensing inquiries: support@pluriply.com
@@ -0,0 +1,189 @@
1
+ #!/usr/bin/env node
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import {
6
+ Hub,
7
+ stopHub,
8
+ spawnHub,
9
+ readLock,
10
+ loadConfig,
11
+ saveConfig,
12
+ TEMPLATE_AGENTS,
13
+ } from "../src/hub/index.js";
14
+ import { pluriplyHome } from "../src/shared/paths.js";
15
+ import { pingHub } from "../src/shared/probe.js";
16
+ import { connectIfLive } from "../src/connector/hub-client.js";
17
+ import { isValidAgentName } from "../src/shared/identity.js";
18
+ import { registerMcpServer } from "../src/shared/mcp-register.js";
19
+
20
+ const BIN_PATH = fileURLToPath(import.meta.url);
21
+
22
+ const [cmd, ...rest] = process.argv.slice(2);
23
+ const sub = rest[0];
24
+
25
+ /** --agent x 같은 플래그 파싱 */
26
+ function flag(name) {
27
+ const i = rest.indexOf(`--${name}`);
28
+ return i === -1 ? undefined : rest[i + 1];
29
+ }
30
+
31
+ if (cmd === "hub" && sub === "start") {
32
+ try {
33
+ const hub = new Hub();
34
+ const { port, redundant } = await hub.start();
35
+ if (redundant) {
36
+ console.log(`pluriply hub already running on ${port}`);
37
+ process.exit(0);
38
+ }
39
+ console.log(`pluriply hub listening on ${port}`);
40
+ const shutdown = async () => {
41
+ await hub.stop();
42
+ process.exit(0);
43
+ };
44
+ process.on("SIGTERM", shutdown);
45
+ process.on("SIGINT", shutdown);
46
+ } catch (err) {
47
+ console.error(`failed to start hub: ${err.message}`);
48
+ process.exit(1);
49
+ }
50
+ } else if (cmd === "hub" && sub === "stop") {
51
+ const home = pluriplyHome();
52
+ const result = await stopHub({ home });
53
+ if (result === "not-running") console.log("not running");
54
+ else if (result === "stopped") console.log("hub stopped");
55
+ else {
56
+ const lock = readLock(home);
57
+ console.error(`hub did not stop within 5s (pid ${lock?.pid ?? "unknown"})`);
58
+ process.exit(1);
59
+ }
60
+ } else if (cmd === "hub" && sub === "restart") {
61
+ const home = pluriplyHome();
62
+ try {
63
+ const stopped = await stopHub({ home });
64
+ if (stopped === "timeout") {
65
+ console.error("hub did not stop within 5s; not restarting");
66
+ process.exit(1);
67
+ }
68
+ const live = await spawnHub({ home });
69
+ console.log(`pluriply hub restarted on ${live.port}`);
70
+ } catch (err) {
71
+ console.error(`failed to restart hub: ${err.message}`);
72
+ process.exit(1);
73
+ }
74
+ } else if (cmd === "worker") {
75
+ const home = pluriplyHome();
76
+ const agent = rest[1];
77
+ if (sub === "list") {
78
+ const cfg = loadConfig(home);
79
+ // 살아 있는 허브에만 붙는다: 새로 띄우지 않는다. 전역 실행 중 워커 수는
80
+ // worker.status(payload {})로 물어보며, 허브가 없거나 질의가 실패/타임아웃되면
81
+ // 접미사 없이 조용히 넘어간다. 소켓은 받아들이지만 응답을 안 하는 허브에
82
+ // 무한정 매달리지 않도록 명시적 타임아웃을 둔다(연결 자체의 타임아웃과 별개).
83
+ let running = null;
84
+ const client = await connectIfLive({ home });
85
+ if (client) {
86
+ try {
87
+ ({ running } = await client.request(
88
+ "worker.status",
89
+ {},
90
+ { timeoutMs: 3000 },
91
+ ));
92
+ } catch {
93
+ running = null;
94
+ } finally {
95
+ client.close();
96
+ }
97
+ }
98
+ for (const a of TEMPLATE_AGENTS) {
99
+ const enabled = Boolean(cfg.workers[a]?.enabled);
100
+ const suffix = enabled && running ? ` (${running} running)` : "";
101
+ console.log(`${a}: ${enabled ? "enabled" : "disabled"}${suffix}`);
102
+ }
103
+ } else if ((sub === "enable" || sub === "disable") && agent) {
104
+ if (!TEMPLATE_AGENTS.includes(agent)) {
105
+ console.error(`no worker template for "${agent}"`);
106
+ process.exit(1);
107
+ }
108
+ const cfg = loadConfig(home);
109
+ const workers = { ...cfg.workers };
110
+ if (sub === "enable")
111
+ workers[agent] = { ...(workers[agent] ?? {}), enabled: true };
112
+ else delete workers[agent];
113
+ // 원본 config.json 문서를 그대로 보존한 채 workers만 갱신한다: loadConfig가
114
+ // 돌려주는 cfg는 allowedRoots·limits를 기본값으로 채워 넣은 파생값이라, 그걸
115
+ // 그대로 다시 쓰면 사용자가 직접 넣은 allowedRoots(Task 1의 cwd 경계 설정)나
116
+ // 손대지 않은 다른 키가 사라진다. 파일을 다시 읽어 병합한다(없거나 손상돼도 {}).
117
+ const file = join(home, "config.json");
118
+ let rawDoc = {};
119
+ if (existsSync(file)) {
120
+ try {
121
+ rawDoc = JSON.parse(readFileSync(file, "utf8"));
122
+ } catch {
123
+ rawDoc = {};
124
+ }
125
+ }
126
+ if (!rawDoc || typeof rawDoc !== "object" || Array.isArray(rawDoc))
127
+ rawDoc = {};
128
+ saveConfig(home, { ...rawDoc, workers });
129
+ if (sub === "enable") registerMcpServer(agent, { binPath: BIN_PATH });
130
+ console.log(`worker ${agent} ${sub}d`);
131
+ } else {
132
+ console.error(
133
+ "usage: pluriply worker <enable|disable> <agent> | worker list",
134
+ );
135
+ process.exit(1);
136
+ }
137
+ } else if (cmd === "setup") {
138
+ const { runSetup, formatSetup } = await import("../src/setup/run-setup.js");
139
+ const { makeEnv } = await import("../src/setup/clients.js");
140
+ const onlyArg = flag("only");
141
+ const workers = rest.includes("--workers");
142
+ const dryRun = rest.includes("--dry-run");
143
+ try {
144
+ const r = await runSetup({
145
+ only: onlyArg ? onlyArg.split(",").map((x) => x.trim()).filter(Boolean) : undefined,
146
+ workers,
147
+ dryRun,
148
+ env: makeEnv({ binPath: BIN_PATH }),
149
+ home: pluriplyHome(),
150
+ });
151
+ if (dryRun) console.log("(dry run — nothing was changed)");
152
+ for (const line of formatSetup(r, { workers })) console.log(line);
153
+ if (r.failed > 0) process.exit(1);
154
+ } catch (err) {
155
+ console.error(`setup failed: ${err.message}`);
156
+ process.exit(1);
157
+ }
158
+ } else if (cmd === "status") {
159
+ const lock = readLock(pluriplyHome());
160
+ if (!lock) {
161
+ console.log("not running");
162
+ } else {
163
+ const info = await pingHub(lock.port);
164
+ if (!info) {
165
+ console.log(`stale lockfile (pid ${lock.pid} not responding)`);
166
+ } else {
167
+ console.log(
168
+ `running (port ${lock.port}, pid ${info.pid ?? lock.pid}, version ${info.version ?? "unknown"}, protocol ${info.protocol ?? 1})`,
169
+ );
170
+ }
171
+ }
172
+ } else if (cmd === "connector") {
173
+ const agent = flag("agent");
174
+ if (!agent) {
175
+ console.error("usage: pluriply connector --agent <name>");
176
+ process.exit(1);
177
+ }
178
+ if (!isValidAgentName(agent)) {
179
+ console.error(`invalid agent name: ${agent}`);
180
+ process.exit(1);
181
+ }
182
+ const { startConnector } = await import("../src/connector/mcp-server.js");
183
+ await startConnector({ agent });
184
+ } else {
185
+ console.error(
186
+ "usage: pluriply <setup [--workers] [--dry-run] [--only a,b]|hub start|hub stop|hub restart|connector --agent <name>|status|worker enable|disable <codex|claude-code|antigravity>|worker list>",
187
+ );
188
+ process.exit(1);
189
+ }
package/package.json ADDED
@@ -0,0 +1,26 @@
1
+ {
2
+ "name": "pluriply",
3
+ "version": "0.1.0",
4
+ "description": "Connect your AI coding tools into one collaboration channel",
5
+ "type": "module",
6
+ "license": "SEE LICENSE IN LICENSE.md",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/pluriply/pluriply.git"
10
+ },
11
+ "homepage": "https://github.com/pluriply/pluriply#readme",
12
+ "bugs": {
13
+ "url": "https://github.com/pluriply/pluriply/issues"
14
+ },
15
+ "bin": {
16
+ "pluriply": "bin/pluriply.js"
17
+ },
18
+ "engines": {
19
+ "node": ">=20"
20
+ },
21
+ "dependencies": {
22
+ "@modelcontextprotocol/sdk": "^1.30.0",
23
+ "ws": "^8.21.3",
24
+ "zod": "^4.5.4"
25
+ }
26
+ }
@@ -0,0 +1,327 @@
1
+ import WebSocket from "ws";
2
+ import { EventEmitter } from "node:events";
3
+ import { pluriplyHome } from "../shared/paths.js";
4
+ import { liveHub, spawnHub } from "../hub/index.js";
5
+ import { PROTOCOL_VERSION } from "../shared/version.js";
6
+
7
+ const RECONNECT_TOTAL_MS = 60_000;
8
+ const RECONNECT_MAX_DELAY_MS = 5_000;
9
+ /**
10
+ * request()가 this.reconnecting을 기다리는 상한. BARRIER_TIMEOUT_MS(재접속 후
11
+ * "reconnected" 리스너를 기다리는 상한)보다 넉넉히 커야 한다 — this.reconnecting은
12
+ * 그 리스너뿐 아니라 허브 재스폰 전체(ensureHub → 없으면 spawnHub, 초 단위가 될
13
+ * 수 있다)까지 포함하므로, 이 값이 짧으면 재접속(허브 재스폰 포함)이 아직
14
+ * 끝나지 않았는데 request()가 먼저 포기하고 readyState===OPEN만 보고 재join이
15
+ * 안 끝난 소켓으로 그대로 전송해버릴 수 있다. BARRIER_TIMEOUT_MS + 허브 재스폰
16
+ * 한 번(초 단위)을 넉넉히 덮도록 15s로 둔다.
17
+ */
18
+ const REQUEST_WAIT_MS = 15_000;
19
+ /**
20
+ * 재접속 성공 뒤 "reconnected" 리스너(예: 채널 재join)를 기다리는 최대 시간.
21
+ * 리스너가 절대 끝나지 않아도(응답 없는 hub.request 등) 이 시간이 지나면
22
+ * 재접속 루프가 포기하고 넘어가, this.reconnecting이 영원히 non-null로
23
+ * 남아 이후의 모든 끊김을 무시하는 사태를 막는다.
24
+ */
25
+ const BARRIER_TIMEOUT_MS = 5_000;
26
+
27
+ /**
28
+ * 재접속 후 한 번만 재시도해도 안전한, 읽기 전용(부수효과 없는) 허브 연산.
29
+ * `task.create`/`channel.create`/`channel.join`/`task.cancel`/`task.claim`/`task.complete`/
30
+ * `context.add` 등은 여기 넣지 않는다: 허브 상태는 `<home>/channels/*.json`에 영속화되므로,
31
+ * 요청이 실제로는 허브에 도달해 처리된 뒤 응답만 유실된 경우 재전송이 같은 연산을
32
+ * 한 번 더(예: 태스크 중복 생성, 워커 중복 스폰) 실행할 수 있다.
33
+ */
34
+ const RETRYABLE = new Set([
35
+ "ping",
36
+ "task.get",
37
+ "task.wait",
38
+ "task.list",
39
+ "channel.peers",
40
+ "channel.presence",
41
+ "worker.status",
42
+ "context.list",
43
+ ]);
44
+
45
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
46
+
47
+ /** @returns {Promise<WebSocket|null>} 연결 실패 시 null */
48
+ function tryConnect(port, timeoutMs = 1000) {
49
+ return new Promise((resolve) => {
50
+ const ws = new WebSocket(`ws://127.0.0.1:${port}`);
51
+ const timer = setTimeout(() => {
52
+ ws.terminate();
53
+ resolve(null);
54
+ }, timeoutMs);
55
+ ws.on("open", () => {
56
+ clearTimeout(timer);
57
+ resolve(ws);
58
+ });
59
+ ws.on("error", () => {
60
+ clearTimeout(timer);
61
+ resolve(null);
62
+ });
63
+ });
64
+ }
65
+
66
+ /** ping 정보로 구형 허브 여부 판정 */
67
+ function staleFrom(info, port) {
68
+ if (info.protocol && info.protocol >= PROTOCOL_VERSION) return null;
69
+ return {
70
+ version: info.version ?? "unknown",
71
+ protocol: info.protocol ?? 1,
72
+ pid: info.pid ?? info.lockPid,
73
+ port,
74
+ };
75
+ }
76
+
77
+ /**
78
+ * 허브가 없으면 detached로 기동한다.
79
+ * @returns {Promise<{port: number, info: object}>} info는 ping 응답(+port, lockPid)
80
+ */
81
+ export async function ensureHub({ home = pluriplyHome() } = {}) {
82
+ const live = (await liveHub(home)) ?? (await spawnHub({ home }));
83
+ return { port: live.port, info: live };
84
+ }
85
+
86
+ /**
87
+ * 허브와의 요청/응답 클라이언트. id 상관관계로 동시 요청을 지원하고,
88
+ * 연결이 끊기면 백오프로 재접속한다. 이벤트: reconnected({port}), dead.
89
+ */
90
+ export class HubClient extends EventEmitter {
91
+ /** @type {() => void} close()가 호출되면 풀린다; #reconnect의 대기를 즉시 깨운다 */
92
+ #resolveClosed;
93
+ /** @type {Promise<void>} */
94
+ #closedSignal;
95
+
96
+ /**
97
+ * @param {WebSocket} ws
98
+ * @param {{home?: string, reconnectTotalMs?: number}} [opts] home이 없으면 재접속하지 않는다.
99
+ * reconnectTotalMs는 재접속을 포기하기까지의 총 시간(기본 RECONNECT_TOTAL_MS) — 테스트에서 dead 경로를 짧게 만드는 데 쓴다.
100
+ */
101
+ constructor(ws, { home, reconnectTotalMs = RECONNECT_TOTAL_MS } = {}) {
102
+ super();
103
+ this.home = home;
104
+ this.reconnectTotalMs = reconnectTotalMs;
105
+ this.pending = new Map();
106
+ this.seq = 0;
107
+ this.stale = null;
108
+ this.closed = false;
109
+ this.dead = false;
110
+ /** @type {Promise<void>|null} 재접속 진행 중이면 그 프라미스 */
111
+ this.reconnecting = null;
112
+ this.#closedSignal = new Promise((resolve) => {
113
+ this.#resolveClosed = resolve;
114
+ });
115
+ this.#attach(ws);
116
+ }
117
+
118
+ #attach(ws) {
119
+ // 이전 소켓의 리스너를 떼어낸다: 늦게 도착하는 error/close가 새 연결의
120
+ // pending 요청을 잘못 실패시키는 것을 막는다 (기존 소켓이 없으면 no-op).
121
+ this.ws?.removeAllListeners();
122
+ this.ws = ws;
123
+ ws.on("message", (raw) => {
124
+ let msg;
125
+ try {
126
+ msg = JSON.parse(raw.toString());
127
+ } catch {
128
+ return; // 허브가 보낸 비 JSON 프레임은 무시
129
+ }
130
+ const entry = this.pending.get(msg.id);
131
+ if (!entry) return;
132
+ this.pending.delete(msg.id);
133
+ msg.ok
134
+ ? entry.resolve(msg.payload)
135
+ : entry.reject(new Error(msg.error?.message ?? "hub error"));
136
+ });
137
+ ws.on("close", () => this.#onLost(new Error("hub connection closed")));
138
+ ws.on("error", (err) =>
139
+ this.#onLost(new Error(`hub connection error: ${err.message}`)),
140
+ );
141
+ }
142
+
143
+ #failAll(err) {
144
+ for (const { reject } of this.pending.values()) reject(err);
145
+ this.pending.clear();
146
+ }
147
+
148
+ #onLost(err) {
149
+ this.#failAll(err);
150
+ if (this.closed || this.dead || this.reconnecting || !this.home) return;
151
+ this.reconnecting = this.#reconnect().finally(() => {
152
+ this.reconnecting = null;
153
+ });
154
+ // close()가 이 재접속 도중에 호출되고 그 순간 아무도 request()에서
155
+ // this.reconnecting을 기다리고 있지 않으면, #reconnect가 정상 반환하므로
156
+ // 이 프라미스는 사실 거부되지 않는다. 그래도 향후 리스너(예: "reconnected"
157
+ // 구독자)가 동기적으로 던지는 경우까지 대비해 처리기를 미리 붙여 둔다.
158
+ this.reconnecting.catch(() => {});
159
+ }
160
+
161
+ async #reconnect() {
162
+ const deadline = Date.now() + this.reconnectTotalMs;
163
+ let delay = 250;
164
+ while (Date.now() < deadline && !this.closed) {
165
+ try {
166
+ const { port, info } = await ensureHub({ home: this.home });
167
+ if (this.closed) return; // close()가 ensureHub 대기 중에 호출됨
168
+ const ws = await tryConnect(port, 3000);
169
+ if (this.closed) {
170
+ ws?.terminate(); // close()가 tryConnect 대기 중에 호출됨: 새 소켓을 붙이지 않는다
171
+ return;
172
+ }
173
+ if (ws) {
174
+ this.#attach(ws);
175
+ this.stale = staleFrom(info, port);
176
+ // emit 대신 리스너를 직접 호출해 반환 프라미스를 기다린다: 이렇게 하면
177
+ // this.reconnecting은 리스너(도구 계층의 채널 재join)가 끝난 뒤에야
178
+ // 해소되고, request()가 reconnecting을 기다리는 로직(readyState와
179
+ // 무관하게 reconnecting이 있으면 기다린다) 덕분에 재접속 후 첫 요청은
180
+ // 재join 뒤에 나간다. 리스너 예외는 삼킨다(allSettled).
181
+ // rawListeners를 쓴다: listeners()는 .once() 래퍼를 풀어 원본 콜백을
182
+ // 돌려주므로 여기서 직접 호출하면 emit()과 달리 "한 번 호출 후 자동
183
+ // 해제"가 발동하지 않아 같은 .once 리스너가 재접속마다 다시 불린다.
184
+ // rawListeners가 돌려주는 래퍼를 그대로 호출해야 emit()과 동일하게
185
+ // once가 정확히 한 번만 불린다.
186
+ // 리스너가 응답 없이 멈춰도 이 대기가 영원히 끝나지 않으면 this.reconnecting이
187
+ // 계속 non-null로 남아 #onLost가 이후의 모든 끊김을 무시하게 된다 —
188
+ // BARRIER_TIMEOUT_MS로 상한을 둬서 그 사태를 막는다(리스너 자체는
189
+ // 백그라운드에서 계속 돌아가지만 결과는 기다리지 않는다).
190
+ await Promise.race([
191
+ Promise.allSettled(
192
+ this.rawListeners("reconnected").map((fn) =>
193
+ Promise.resolve().then(() => fn({ port })),
194
+ ),
195
+ ),
196
+ sleep(BARRIER_TIMEOUT_MS),
197
+ ]);
198
+ return;
199
+ }
200
+ } catch {
201
+ // 허브가 아직 없음: 재시도
202
+ }
203
+ // 다음 시도까지의 대기는 close()가 즉시 깨울 수 있어야 한다
204
+ await Promise.race([sleep(delay), this.#closedSignal]);
205
+ delay = Math.min(delay * 2, RECONNECT_MAX_DELAY_MS);
206
+ }
207
+ if (!this.closed) {
208
+ this.dead = true;
209
+ this.emit("dead");
210
+ }
211
+ }
212
+
213
+ /**
214
+ * 허브에 접속한다. 허브 프로토콜이 커넥터보다 낮으면 `stale`에 기록하되 접속은 유지한다.
215
+ * @param {{home?: string, reconnectTotalMs?: number}} [opts] @returns {Promise<HubClient>}
216
+ */
217
+ static async connect({ home = pluriplyHome(), reconnectTotalMs } = {}) {
218
+ const { port, info } = await ensureHub({ home });
219
+ const ws = await tryConnect(port, 3000);
220
+ if (!ws) throw new Error("could not connect to pluriply hub");
221
+ const client = new HubClient(ws, { home, reconnectTotalMs });
222
+ client.stale = staleFrom(info, port);
223
+ return client;
224
+ }
225
+
226
+ /**
227
+ * @param {string} type @param {object} [payload]
228
+ * @param {{duringReconnect?: boolean, timeoutMs?: number}} [opts] duringReconnect: true면 this.reconnecting을
229
+ * 기다리지 않고 곧장 보낸다. #reconnect가 소켓을 붙인(#attach) 직후 "reconnected"
230
+ * 리스너를 호출해 그 반환 프라미스를 기다리는 동안에는, readyState가 이미 OPEN이라도
231
+ * this.reconnecting은 아직 non-null이다 — 이 옵션 없이 그 리스너 자신이 request()를
232
+ * 부르면(예: tools.js의 채널 재join) this.reconnecting을 기다리다 자기 자신을
233
+ * 기다리는 교착 상태에 빠지므로, 리스너 안에서 나가는 요청에는 반드시 넘겨야 한다.
234
+ * timeoutMs: 있으면 그 시간 안에 응답이 없을 때 "hub request timed out: <type>"으로
235
+ * 거부하고 pending 항목을 지운다(허브가 소켓은 받아들이되 응답을 안 보내는 경우 대비 —
236
+ * 예: worker list가 죽은 허브를 붙잡고 무한정 기다리는 사고를 막는다).
237
+ * @returns {Promise<object>}
238
+ */
239
+ async request(
240
+ type,
241
+ payload = {},
242
+ { duringReconnect = false, timeoutMs } = {},
243
+ ) {
244
+ if (this.dead) throw new Error("hub unreachable; restart the tool");
245
+ // readyState만으로는 부족하다: #reconnect가 #attach로 소켓을 OPEN 상태로
246
+ // 바꾼 뒤에도 "reconnected" 리스너(채널 재join)가 끝날 때까지 this.reconnecting은
247
+ // non-null로 남아있다. 그 틈에 나간 request()가 재join보다 먼저 허브에 도착하는
248
+ // 것을 막으려면 readyState와 무관하게 reconnecting이 있으면 기다려야 한다.
249
+ if (this.reconnecting && !duringReconnect) {
250
+ await Promise.race([this.reconnecting, sleep(REQUEST_WAIT_MS)]);
251
+ }
252
+ if (this.ws.readyState !== WebSocket.OPEN)
253
+ throw new Error("hub connection closed");
254
+ try {
255
+ return await this.#send(type, payload, timeoutMs);
256
+ } catch (err) {
257
+ // readyState가 아직 OPEN으로 보이는 순간 보냈는데 그 직후 끊긴 경우:
258
+ // 재접속이 이미 시작됐다면 그걸 기다렸다가 한 번만 더 시도한다.
259
+ // 주의: 허브 상태는 인메모리가 아니라 `<home>/channels/*.json`에 영속화된다
260
+ // (store.js) — 재시작한 허브도 같은 파일을 그대로 읽는다. 즉 첫 전송이
261
+ // 실제로 허브에 도달해 처리된 뒤 응답만 유실됐다면, 재전송은 같은 연산을
262
+ // 두 번째로 실행한다(예: task.create가 태스크를 두 개 만들고 워커도
263
+ // 두 번 뜬다). 그래서 재시도는 부수효과가 없는 읽기 전용 연산으로만
264
+ // 한정한다 — RETRYABLE에 없는 타입은 원래 에러로 즉시 거부한다.
265
+ if (
266
+ RETRYABLE.has(type) &&
267
+ err.message === "hub connection closed" &&
268
+ this.reconnecting &&
269
+ !duringReconnect
270
+ ) {
271
+ await Promise.race([this.reconnecting, sleep(REQUEST_WAIT_MS)]);
272
+ if (this.ws.readyState === WebSocket.OPEN)
273
+ return this.#send(type, payload, timeoutMs);
274
+ }
275
+ throw err;
276
+ }
277
+ }
278
+
279
+ /**
280
+ * @param {string} type @param {object} payload @param {number} [timeoutMs]
281
+ * @returns {Promise<object>}
282
+ */
283
+ #send(type, payload, timeoutMs) {
284
+ const id = `req_${++this.seq}`;
285
+ return new Promise((resolve, reject) => {
286
+ let timer;
287
+ // resolve/reject 어느 쪽이 먼저 오든(정상 응답 vs 타임아웃) 남은 타이머를 지우고
288
+ // pending에서 항목을 지운다 — 메시지 핸들러의 delete와 겹쳐도 Map.delete는 멱등이다.
289
+ const settle = (fn) => (arg) => {
290
+ if (timer) clearTimeout(timer);
291
+ this.pending.delete(id);
292
+ fn(arg);
293
+ };
294
+ const entry = { resolve: settle(resolve), reject: settle(reject) };
295
+ this.pending.set(id, entry);
296
+ if (timeoutMs) {
297
+ timer = setTimeout(
298
+ () => entry.reject(new Error(`hub request timed out: ${type}`)),
299
+ timeoutMs,
300
+ );
301
+ timer.unref?.(); // 이 타이머만으로 프로세스가 살아있지 않게 한다(CLI 종료용)
302
+ }
303
+ this.ws.send(JSON.stringify({ id, type, payload }));
304
+ });
305
+ }
306
+
307
+ close() {
308
+ this.closed = true;
309
+ this.#resolveClosed();
310
+ this.ws.close();
311
+ }
312
+ }
313
+
314
+ /**
315
+ * 이미 떠 있는 허브에만 접속한다. 없으면 스폰하지 않고 null을 반환한다.
316
+ * home을 넘기지 않은 HubClient를 돌려주므로 끊겨도 재접속하지 않는다.
317
+ * @param {{home?: string}} [opts] @returns {Promise<HubClient|null>}
318
+ */
319
+ export async function connectIfLive({ home = pluriplyHome() } = {}) {
320
+ const live = await liveHub(home);
321
+ if (!live) return null;
322
+ const ws = await tryConnect(live.port, 3000);
323
+ if (!ws) return null;
324
+ const client = new HubClient(ws, {});
325
+ client.stale = staleFrom(live, live.port);
326
+ return client;
327
+ }