pluriply 0.5.2 → 0.5.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pluriply",
3
- "version": "0.5.2",
3
+ "version": "0.5.4",
4
4
  "description": "Connect your AI coding tools into one collaboration channel",
5
5
  "type": "module",
6
6
  "license": "SEE LICENSE IN LICENSE.md",
@@ -51,6 +51,31 @@ function staleHubMessage(stale) {
51
51
  );
52
52
  }
53
53
 
54
+ /** 허브가 hello 하지 않은 연결의 요청을 거절할 때 쓰는 문구(server.js #conn) */
55
+ const UNIDENTIFIED = "say hello first";
56
+
57
+ /** 채널 문서가 없을 때의 허브 오류(channels.js ChannelNotFound) */
58
+ const CHANNEL_GONE = /^channel not found: /;
59
+
60
+ /**
61
+ * hub-client 자신이 내는 연결 오류(허브의 거절이 아니다). 원인이 정체성이 아니므로 "정체성을
62
+ * 잃었다"로 감싸지 않고 원래 오류를 그대로 알린다: 끊김·시간 초과는 재접속 리스너가 곧
63
+ * 되살리고, unreachable(dead)은 이미 원인(예: unauthorized)과 재시작 안내를 담고 있다.
64
+ */
65
+ const HUB_CLIENT_ERROR =
66
+ /^hub (connection closed|connection error|request timed out|unreachable)/;
67
+
68
+ /** 정체성을 되살리지 못했다 — 도구를 다시 시작해야 한다(안내 문구를 다른 안내로 감싸지 않게 구분한다) */
69
+ class IdentityLostError extends Error {
70
+ /** @param {string} reason */
71
+ constructor(reason) {
72
+ super(
73
+ `Pluriply lost this session's identity on the hub connection and could not restore it (${reason}). ` +
74
+ "Restart this AI tool (or reload its MCP server) to reconnect.",
75
+ );
76
+ }
77
+ }
78
+
54
79
  /**
55
80
  * 허브에 정체성을 알리고 인스턴스 ID를 받는다. 재접속 때는 알고 있는 ID를 실어 그대로 인정받는다.
56
81
  * @param {import('./hub-client.js').HubClient} hub
@@ -90,12 +115,122 @@ export function registerTools(server, hub, { agent, instanceId }) {
90
115
  const delegationDepth = () =>
91
116
  worker ? Number(process.env.PLURIPLY_DEPTH ?? 0) + 1 : 0;
92
117
 
118
+ /** 이 소켓에 알고 있는 instanceId 로 정체성을 다시 알린다 @param {{duringReconnect?: boolean}} [opts] */
119
+ async function helloAgain(opts = {}) {
120
+ state.instanceId = await hello(hub, {
121
+ agent,
122
+ worker,
123
+ instanceId: state.instanceId,
124
+ ...opts,
125
+ });
126
+ }
127
+
128
+ /**
129
+ * 참여 중이던 채널에 이 소켓으로 다시 들어간다.
130
+ * @param {{duringReconnect?: boolean}} [opts] @returns {Promise<boolean>} 채널이 없으면 false
131
+ */
132
+ async function rejoin(opts = {}) {
133
+ if (!state.currentChannel) return false;
134
+ await hub.request(
135
+ "channel.join",
136
+ { channelCode: state.currentChannel },
137
+ opts,
138
+ );
139
+ return true;
140
+ }
141
+
142
+ /** 진행 중인 정체성 복구. 동시에 거절된 요청들이 hello 하나를 같이 기다린다. */
143
+ let recovering = null;
144
+ /**
145
+ * 이 소켓의 정체성이나 채널 참여가 빠졌다고 알고 있는 상태(재접속·복구의 hello 또는 재참여가
146
+ * 실패했다). 조회 도구가 쓰는 허브 요청(channel.peers·task.list 등)은 정체성·참여 없이도
147
+ * 통과하므로, 거절을 기다리면 복구가 일어나지 않고 이 인스턴스가 동료에게 offline 으로 남는다
148
+ * (이 도구로 오는 태스크도 대화형 세션 대신 워커로 갈 수 있다) — 그래서 요청 전에 먼저 복구한다.
149
+ * hello 와 재참여가 모두 끝나야 내린다: 끝나 가는 복구를 재사용하는 사이 재접속이 끼어도
150
+ * 표시가 잘못 내려가지 않는다.
151
+ */
152
+ let needsRecover = false;
153
+
154
+ /**
155
+ * 참여 중이던 채널에 다시 들어가고 결과를 상태에 반영한다.
156
+ * - 채널이 사라졌으면 currentChannel 을 비워 다음 호출이 자동 복귀·"Join a channel first" 로
157
+ * 알리게 한다(기다리는 사이 join_channel 이 다른 채널로 바꿨으면 그대로 둔다).
158
+ * - 연결 오류(끊김 등)나, 복구 도중 재접속이 끼어 아직 정체성 없는 새 소켓으로 나간 경우
159
+ * ("say hello first")는 다시 보내면 되므로 needsRecover 로 남겨 다음 호출이 다시 시도한다.
160
+ * - 그 밖의 거절(예: 손상된 채널 문서)은 다시 보내도 같으니 표시를 내린다 — 남기면 세션 내내
161
+ * 요청마다 왕복이 붙는다. 채널은 유지해 이후 요청이 실제 오류를 보이게 한다.
162
+ * @param {{duringReconnect?: boolean}} [opts] @returns {Promise<boolean>} 다시 들어갔으면 true
163
+ */
164
+ async function restoreChannel(opts) {
165
+ const code = state.currentChannel;
166
+ try {
167
+ const joined = await rejoin(opts);
168
+ needsRecover = false;
169
+ return joined;
170
+ } catch (err) {
171
+ if (CHANNEL_GONE.test(err.message)) {
172
+ if (state.currentChannel === code) state.currentChannel = null;
173
+ needsRecover = false;
174
+ } else
175
+ needsRecover =
176
+ HUB_CLIENT_ERROR.test(err.message) || err.message === UNIDENTIFIED;
177
+ return false;
178
+ }
179
+ }
180
+
181
+ /**
182
+ * 정체성을 되살리고 참여 중이던 채널에 다시 들어간다(hello 는 연결당 멱등).
183
+ * 재접속 리스너는 이 공유 promise 를 쓰면 안 된다 — 여기서 나가는 요청은 재접속 배리어를
184
+ * 기다리고, 배리어는 리스너가 끝나기를 기다리므로 서로를 기다리는 교착이 된다.
185
+ * @returns {Promise<void>} hello 가 허브에 거절되면 IdentityLostError, 연결 오류면 그 오류 그대로
186
+ */
187
+ function recover() {
188
+ recovering ??= (async () => {
189
+ try {
190
+ await helloAgain();
191
+ } catch (err) {
192
+ needsRecover = true;
193
+ if (HUB_CLIENT_ERROR.test(err.message)) throw err;
194
+ throw new IdentityLostError(err.message);
195
+ }
196
+ await restoreChannel();
197
+ })().finally(() => {
198
+ recovering = null;
199
+ });
200
+ return recovering;
201
+ }
202
+
203
+ /**
204
+ * hub.request 와 같되 이 소켓의 정체성을 지킨다. 정체성·참여가 빠진 것을 알면 보내기 전에
205
+ * 복구하고(재참여가 계속 실패하면 두 번까지만 — 요청은 그대로 보내고 다음 호출이 다시 시도한다),
206
+ * 허브가 "say hello first" 로 거절하면 복구한 뒤 한 번만 다시 보낸다. 허브는 정체성이 필요한
207
+ * 요청을 첫 줄(#conn)에서 거절하므로 다시 보내도 중복 부작용이 없다.
208
+ * 재접속 리스너의 duringReconnect 요청은 이 경로를 타지 않는다.
209
+ * @type {typeof hub.request}
210
+ */
211
+ async function hubRequest(type, payload, opts) {
212
+ for (let i = 0; needsRecover && i < 2; i++) await recover();
213
+ try {
214
+ return await hub.request(type, payload, opts);
215
+ } catch (err) {
216
+ if (err.message !== UNIDENTIFIED) throw err;
217
+ }
218
+ await recover();
219
+ try {
220
+ return await hub.request(type, payload, opts);
221
+ } catch (err) {
222
+ if (err.message === UNIDENTIFIED)
223
+ throw new IdentityLostError(err.message);
224
+ throw err;
225
+ }
226
+ }
227
+
93
228
  /**
94
229
  * 채널이 없을 때 허브에 직전 채널 복귀를 요청한다.
95
230
  * @returns {Promise<string|null>} 복귀한 채널 코드
96
231
  */
97
232
  async function resumeChannel() {
98
- const { channelCode } = await hub.request("agent.resume", {});
233
+ const { channelCode } = await hubRequest("agent.resume", {});
99
234
  if (!channelCode) return null;
100
235
  if (state.currentChannel) return null; // join_channel이 경합에서 이겼으니 그대로 둔다
101
236
  state.currentChannel = channelCode;
@@ -113,6 +248,8 @@ export function registerTools(server, hub, { agent, instanceId }) {
113
248
  try {
114
249
  resumed = await resumeChannel();
115
250
  } catch (err) {
251
+ // join_channel 도 같은 이유로 실패하므로 그쪽을 권하지 않고 재시작 안내만 낸다
252
+ if (err instanceof IdentityLostError) return fail(err.message);
116
253
  return fail(
117
254
  `Join a channel first with join_channel. (auto-resume failed: ${err.message})`,
118
255
  );
@@ -147,7 +284,7 @@ export function registerTools(server, hub, { agent, instanceId }) {
147
284
  const remaining = deadline - Date.now();
148
285
  if (remaining <= 0) break;
149
286
  const timeoutMs = Math.min(ASK_CHUNK_MS, remaining);
150
- ({ task } = await hub.request(
287
+ ({ task } = await hubRequest(
151
288
  "task.wait",
152
289
  { channelCode: code, taskId, timeoutMs },
153
290
  { timeoutMs: timeoutMs + 5000 },
@@ -200,22 +337,16 @@ export function registerTools(server, hub, { agent, instanceId }) {
200
337
  // join_channel이 도구를 다시 시작할 때까지 "say hello first"로 막힌다.
201
338
  // (hello는 인증이 필요한 요청이라, 토큰이 틀린 연결은 여기서 unauthorized를
202
339
  // 받아 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;
210
- await hub.request(
211
- "channel.join",
212
- { channelCode: state.currentChannel },
213
- { duringReconnect: true },
214
- );
215
- hub.emit("rejoined", state.currentChannel);
340
+ // recover() 의 공유 promise 는 쓰지 않는다(교착 — recover 주석 참고).
341
+ await helloAgain({ duringReconnect: true });
216
342
  } catch {
217
- // 채널이 사라졌으면 다음 도구 호출이 알려준다
343
+ // 다음 도구 호출이 요청을 보내기 전에 hubRequest() 에서 복구한다
344
+ needsRecover = true;
345
+ return;
218
346
  }
347
+ // 재참여 실패도 needsRecover 로 남기고, 채널이 사라졌으면 currentChannel 을 비운다
348
+ if (await restoreChannel({ duringReconnect: true }))
349
+ hub.emit("rejoined", state.currentChannel);
219
350
  });
220
351
  }
221
352
 
@@ -234,8 +365,8 @@ export function registerTools(server, hub, { agent, instanceId }) {
234
365
  if (hub.stale) return fail(staleHubMessage(hub.stale));
235
366
  try {
236
367
  const code =
237
- channel_code ?? (await hub.request("channel.create")).channelCode;
238
- const { peers } = await hub.request("channel.join", {
368
+ channel_code ?? (await hubRequest("channel.create")).channelCode;
369
+ const { peers } = await hubRequest("channel.join", {
239
370
  channelCode: code,
240
371
  });
241
372
  state.currentChannel = code;
@@ -255,7 +386,7 @@ export function registerTools(server, hub, { agent, instanceId }) {
255
386
  inputSchema: {},
256
387
  },
257
388
  needChannel((_args, code) =>
258
- hub.request("channel.peers", { channelCode: code }),
389
+ hubRequest("channel.peers", { channelCode: code }),
259
390
  ),
260
391
  );
261
392
 
@@ -268,15 +399,15 @@ export function registerTools(server, hub, { agent, instanceId }) {
268
399
  inputSchema: {},
269
400
  },
270
401
  needChannel(async (_args, code) => {
271
- const { peers } = await hub.request("channel.peers", {
402
+ const { peers } = await hubRequest("channel.peers", {
272
403
  channelCode: code,
273
404
  });
274
- const { tasks } = await hub.request("task.list", {
405
+ const { tasks } = await hubRequest("task.list", {
275
406
  channelCode: code,
276
407
  to: state.instanceId,
277
408
  status: "submitted",
278
409
  });
279
- const { running } = await hub.request("worker.status", {
410
+ const { running } = await hubRequest("worker.status", {
280
411
  channelCode: code,
281
412
  });
282
413
  return {
@@ -335,7 +466,7 @@ export function registerTools(server, hub, { agent, instanceId }) {
335
466
  { to, request, attachments = [], mode = "auto", cwd = process.cwd() },
336
467
  code,
337
468
  ) =>
338
- hub.request("task.create", {
469
+ hubRequest("task.create", {
339
470
  channelCode: code,
340
471
  to,
341
472
  request,
@@ -399,7 +530,7 @@ export function registerTools(server, hub, { agent, instanceId }) {
399
530
  `ask_agent takes a tool name (e.g. "codex"); use send_task to pin an instance like "${to}"`,
400
531
  );
401
532
  const waitS = clampWaitSeconds(wait_seconds);
402
- const created = await hub.request("task.create", {
533
+ const created = await hubRequest("task.create", {
403
534
  channelCode: code,
404
535
  to,
405
536
  request,
@@ -412,13 +543,11 @@ export function registerTools(server, hub, { agent, instanceId }) {
412
543
  const { taskId } = created;
413
544
  if (created.dispatch !== "spawned" && created.dispatch !== "queued") {
414
545
  // 워커가 뜨지 않았다: 쓰레기 submitted 태스크를 남기지 않는다
415
- await hub
416
- .request("task.cancel", {
417
- channelCode: code,
418
- taskId,
419
- reason: "ask_agent: worker not started",
420
- })
421
- .catch(() => {});
546
+ await hubRequest("task.cancel", {
547
+ channelCode: code,
548
+ taskId,
549
+ reason: "ask_agent: worker not started",
550
+ }).catch(() => {});
422
551
  return {
423
552
  status: "not_started",
424
553
  taskId,
@@ -510,7 +639,7 @@ export function registerTools(server, hub, { agent, instanceId }) {
510
639
  if (git_range !== undefined) review.gitRange = git_range;
511
640
  if (paths !== undefined) review.paths = paths;
512
641
  if (focus !== undefined) review.focus = focus;
513
- const created = await hub.request("task.create", {
642
+ const created = await hubRequest("task.create", {
514
643
  channelCode: code,
515
644
  to,
516
645
  request: request ?? "",
@@ -525,13 +654,11 @@ export function registerTools(server, hub, { agent, instanceId }) {
525
654
  if (wait_seconds === undefined) return created;
526
655
  const { taskId } = created;
527
656
  if (created.dispatch === "none") {
528
- await hub
529
- .request("task.cancel", {
530
- channelCode: code,
531
- taskId,
532
- reason: "request_review: worker not started",
533
- })
534
- .catch(() => {});
657
+ await hubRequest("task.cancel", {
658
+ channelCode: code,
659
+ taskId,
660
+ reason: "request_review: worker not started",
661
+ }).catch(() => {});
535
662
  return {
536
663
  status: "not_started",
537
664
  taskId,
@@ -567,7 +694,7 @@ export function registerTools(server, hub, { agent, instanceId }) {
567
694
  },
568
695
  },
569
696
  needChannel(({ task_id, reason }, code) =>
570
- hub.request("task.cancel", {
697
+ hubRequest("task.cancel", {
571
698
  channelCode: code,
572
699
  taskId: task_id,
573
700
  reason,
@@ -602,7 +729,7 @@ export function registerTools(server, hub, { agent, instanceId }) {
602
729
  },
603
730
  needChannel(
604
731
  ({ status, mine_only = true, sent_by_me = false, kind }, code) =>
605
- hub.request("task.list", {
732
+ hubRequest("task.list", {
606
733
  channelCode: code,
607
734
  to: sent_by_me ? undefined : mine_only ? state.instanceId : undefined,
608
735
  from: sent_by_me ? state.instanceId : undefined,
@@ -621,7 +748,7 @@ export function registerTools(server, hub, { agent, instanceId }) {
621
748
  inputSchema: { task_id: z.string() },
622
749
  },
623
750
  needChannel(({ task_id }, code) =>
624
- hub.request("task.get", { channelCode: code, taskId: task_id }),
751
+ hubRequest("task.get", { channelCode: code, taskId: task_id }),
625
752
  ),
626
753
  );
627
754
 
@@ -645,17 +772,17 @@ export function registerTools(server, hub, { agent, instanceId }) {
645
772
  },
646
773
  },
647
774
  needChannel(async ({ task_id, result, failed = false }, code) => {
648
- const { task } = await hub.request("task.get", {
775
+ const { task } = await hubRequest("task.get", {
649
776
  channelCode: code,
650
777
  taskId: task_id,
651
778
  });
652
779
  if (task.status === "submitted") {
653
- await hub.request("task.claim", {
780
+ await hubRequest("task.claim", {
654
781
  channelCode: code,
655
782
  taskId: task_id,
656
783
  });
657
784
  }
658
- return hub.request("task.complete", {
785
+ return hubRequest("task.complete", {
659
786
  channelCode: code,
660
787
  taskId: task_id,
661
788
  result,
@@ -691,14 +818,14 @@ export function registerTools(server, hub, { agent, instanceId }) {
691
818
  },
692
819
  },
693
820
  needChannel(async ({ task_id, verdict, findings = [], summary }, code) => {
694
- const { task } = await hub.request("task.get", {
821
+ const { task } = await hubRequest("task.get", {
695
822
  channelCode: code,
696
823
  taskId: task_id,
697
824
  });
698
825
  if (task.status === "submitted") {
699
- await hub.request("task.claim", { channelCode: code, taskId: task_id });
826
+ await hubRequest("task.claim", { channelCode: code, taskId: task_id });
700
827
  }
701
- return hub.request("task.complete", {
828
+ return hubRequest("task.complete", {
702
829
  channelCode: code,
703
830
  taskId: task_id,
704
831
  status: "completed",
@@ -722,7 +849,7 @@ export function registerTools(server, hub, { agent, instanceId }) {
722
849
  },
723
850
  },
724
851
  needChannel(({ summary, artifacts = [] }, code) =>
725
- hub.request("context.add", {
852
+ hubRequest("context.add", {
726
853
  channelCode: code,
727
854
  summary,
728
855
  artifacts,
@@ -744,7 +871,7 @@ export function registerTools(server, hub, { agent, instanceId }) {
744
871
  },
745
872
  },
746
873
  needChannel(({ limit }, code) =>
747
- hub.request("context.list", { channelCode: code, limit }),
874
+ hubRequest("context.list", { channelCode: code, limit }),
748
875
  ),
749
876
  );
750
877
  }