pluriply 0.5.1 → 0.5.3

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.1",
3
+ "version": "0.5.3",
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,28 @@ function staleHubMessage(stale) {
51
51
  );
52
52
  }
53
53
 
54
+ /** 허브가 hello 하지 않은 연결의 요청을 거절할 때 쓰는 문구(server.js #conn) */
55
+ const UNIDENTIFIED = "say hello first";
56
+
57
+ /**
58
+ * hub-client 자신이 내는 연결 오류(허브의 거절이 아니다). 원인이 정체성이 아니므로 "정체성을
59
+ * 잃었다"로 감싸지 않고 원래 오류를 그대로 알린다: 끊김·시간 초과는 재접속 리스너가 곧
60
+ * 되살리고, unreachable(dead)은 이미 원인(예: unauthorized)과 재시작 안내를 담고 있다.
61
+ */
62
+ const HUB_CLIENT_ERROR =
63
+ /^hub (connection closed|connection error|request timed out|unreachable)/;
64
+
65
+ /** 정체성을 되살리지 못했다 — 도구를 다시 시작해야 한다(안내 문구를 다른 안내로 감싸지 않게 구분한다) */
66
+ class IdentityLostError extends Error {
67
+ /** @param {string} reason */
68
+ constructor(reason) {
69
+ super(
70
+ `Pluriply lost this session's identity on the hub connection and could not restore it (${reason}). ` +
71
+ "Restart this AI tool (or reload its MCP server) to reconnect.",
72
+ );
73
+ }
74
+ }
75
+
54
76
  /**
55
77
  * 허브에 정체성을 알리고 인스턴스 ID를 받는다. 재접속 때는 알고 있는 ID를 실어 그대로 인정받는다.
56
78
  * @param {import('./hub-client.js').HubClient} hub
@@ -90,12 +112,92 @@ export function registerTools(server, hub, { agent, instanceId }) {
90
112
  const delegationDepth = () =>
91
113
  worker ? Number(process.env.PLURIPLY_DEPTH ?? 0) + 1 : 0;
92
114
 
115
+ /** 이 소켓에 알고 있는 instanceId 로 정체성을 다시 알린다 @param {{duringReconnect?: boolean}} [opts] */
116
+ async function helloAgain(opts = {}) {
117
+ state.instanceId = await hello(hub, {
118
+ agent,
119
+ worker,
120
+ instanceId: state.instanceId,
121
+ ...opts,
122
+ });
123
+ }
124
+
125
+ /**
126
+ * 참여 중이던 채널에 이 소켓으로 다시 들어간다.
127
+ * @param {{duringReconnect?: boolean}} [opts] @returns {Promise<boolean>} 채널이 없으면 false
128
+ */
129
+ async function rejoin(opts = {}) {
130
+ if (!state.currentChannel) return false;
131
+ await hub.request(
132
+ "channel.join",
133
+ { channelCode: state.currentChannel },
134
+ opts,
135
+ );
136
+ return true;
137
+ }
138
+
139
+ /** 진행 중인 정체성 복구. 동시에 거절된 요청들이 hello 하나를 같이 기다린다. */
140
+ let recovering = null;
141
+ /**
142
+ * 재접속 리스너의 hello 가 실패해 이 소켓에 정체성이 없다고 알고 있는 상태. 조회 도구가 쓰는
143
+ * 허브 요청(channel.peers·task.list 등)은 정체성 없이도 통과하므로, 거절을 기다리면 복구가
144
+ * 일어나지 않고 이 인스턴스가 동료에게 offline 으로 남는다 — 그래서 요청 전에 먼저 복구한다.
145
+ */
146
+ let identityLost = false;
147
+
148
+ /**
149
+ * 정체성을 되살리고 참여 중이던 채널에 다시 들어간다(hello 는 연결당 멱등).
150
+ * 재접속 리스너는 이 공유 promise 를 쓰면 안 된다 — 여기서 나가는 요청은 재접속 배리어를
151
+ * 기다리고, 배리어는 리스너가 끝나기를 기다리므로 서로를 기다리는 교착이 된다.
152
+ * @returns {Promise<void>} hello 가 허브에 거절되면 IdentityLostError, 연결 오류면 그 오류 그대로
153
+ */
154
+ function recover() {
155
+ recovering ??= (async () => {
156
+ try {
157
+ await helloAgain();
158
+ } catch (err) {
159
+ if (HUB_CLIENT_ERROR.test(err.message)) throw err;
160
+ throw new IdentityLostError(err.message);
161
+ }
162
+ identityLost = false;
163
+ // 채널이 사라졌으면 다시 보낸 요청(또는 다음 호출)이 알려준다
164
+ await rejoin().catch(() => {});
165
+ })().finally(() => {
166
+ recovering = null;
167
+ });
168
+ return recovering;
169
+ }
170
+
171
+ /**
172
+ * hub.request 와 같되 이 소켓의 정체성을 지킨다. 정체성을 잃은 것을 알면 보내기 전에 복구하고,
173
+ * 허브가 "say hello first" 로 거절하면 복구한 뒤 한 번만 다시 보낸다. 허브는 정체성이 필요한
174
+ * 요청을 첫 줄(#conn)에서 거절하므로 다시 보내도 중복 부작용이 없다.
175
+ * 재접속 리스너의 duringReconnect 요청은 이 경로를 타지 않는다.
176
+ * @type {typeof hub.request}
177
+ */
178
+ async function hubRequest(type, payload, opts) {
179
+ if (identityLost) await recover();
180
+ try {
181
+ return await hub.request(type, payload, opts);
182
+ } catch (err) {
183
+ if (err.message !== UNIDENTIFIED) throw err;
184
+ }
185
+ await recover();
186
+ try {
187
+ return await hub.request(type, payload, opts);
188
+ } catch (err) {
189
+ if (err.message === UNIDENTIFIED)
190
+ throw new IdentityLostError(err.message);
191
+ throw err;
192
+ }
193
+ }
194
+
93
195
  /**
94
196
  * 채널이 없을 때 허브에 직전 채널 복귀를 요청한다.
95
197
  * @returns {Promise<string|null>} 복귀한 채널 코드
96
198
  */
97
199
  async function resumeChannel() {
98
- const { channelCode } = await hub.request("agent.resume", {});
200
+ const { channelCode } = await hubRequest("agent.resume", {});
99
201
  if (!channelCode) return null;
100
202
  if (state.currentChannel) return null; // join_channel이 경합에서 이겼으니 그대로 둔다
101
203
  state.currentChannel = channelCode;
@@ -113,6 +215,8 @@ export function registerTools(server, hub, { agent, instanceId }) {
113
215
  try {
114
216
  resumed = await resumeChannel();
115
217
  } catch (err) {
218
+ // join_channel 도 같은 이유로 실패하므로 그쪽을 권하지 않고 재시작 안내만 낸다
219
+ if (err instanceof IdentityLostError) return fail(err.message);
116
220
  return fail(
117
221
  `Join a channel first with join_channel. (auto-resume failed: ${err.message})`,
118
222
  );
@@ -147,7 +251,7 @@ export function registerTools(server, hub, { agent, instanceId }) {
147
251
  const remaining = deadline - Date.now();
148
252
  if (remaining <= 0) break;
149
253
  const timeoutMs = Math.min(ASK_CHUNK_MS, remaining);
150
- ({ task } = await hub.request(
254
+ ({ task } = await hubRequest(
151
255
  "task.wait",
152
256
  { channelCode: code, taskId, timeoutMs },
153
257
  { timeoutMs: timeoutMs + 5000 },
@@ -200,19 +304,17 @@ export function registerTools(server, hub, { agent, instanceId }) {
200
304
  // join_channel이 도구를 다시 시작할 때까지 "say hello first"로 막힌다.
201
305
  // (hello는 인증이 필요한 요청이라, 토큰이 틀린 연결은 여기서 unauthorized를
202
306
  // 받아 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);
307
+ // recover() 의 공유 promise 는 쓰지 않는다(교착 — recover 주석 참고).
308
+ await helloAgain({ duringReconnect: true });
309
+ identityLost = false;
310
+ } catch {
311
+ // 다음 도구 호출이 요청을 보내기 전에 hubRequest() 에서 복구한다
312
+ identityLost = true;
313
+ return;
314
+ }
315
+ try {
316
+ if (await rejoin({ duringReconnect: true }))
317
+ hub.emit("rejoined", state.currentChannel);
216
318
  } catch {
217
319
  // 채널이 사라졌으면 다음 도구 호출이 알려준다
218
320
  }
@@ -234,8 +336,8 @@ export function registerTools(server, hub, { agent, instanceId }) {
234
336
  if (hub.stale) return fail(staleHubMessage(hub.stale));
235
337
  try {
236
338
  const code =
237
- channel_code ?? (await hub.request("channel.create")).channelCode;
238
- const { peers } = await hub.request("channel.join", {
339
+ channel_code ?? (await hubRequest("channel.create")).channelCode;
340
+ const { peers } = await hubRequest("channel.join", {
239
341
  channelCode: code,
240
342
  });
241
343
  state.currentChannel = code;
@@ -255,7 +357,7 @@ export function registerTools(server, hub, { agent, instanceId }) {
255
357
  inputSchema: {},
256
358
  },
257
359
  needChannel((_args, code) =>
258
- hub.request("channel.peers", { channelCode: code }),
360
+ hubRequest("channel.peers", { channelCode: code }),
259
361
  ),
260
362
  );
261
363
 
@@ -268,15 +370,15 @@ export function registerTools(server, hub, { agent, instanceId }) {
268
370
  inputSchema: {},
269
371
  },
270
372
  needChannel(async (_args, code) => {
271
- const { peers } = await hub.request("channel.peers", {
373
+ const { peers } = await hubRequest("channel.peers", {
272
374
  channelCode: code,
273
375
  });
274
- const { tasks } = await hub.request("task.list", {
376
+ const { tasks } = await hubRequest("task.list", {
275
377
  channelCode: code,
276
378
  to: state.instanceId,
277
379
  status: "submitted",
278
380
  });
279
- const { running } = await hub.request("worker.status", {
381
+ const { running } = await hubRequest("worker.status", {
280
382
  channelCode: code,
281
383
  });
282
384
  return {
@@ -335,7 +437,7 @@ export function registerTools(server, hub, { agent, instanceId }) {
335
437
  { to, request, attachments = [], mode = "auto", cwd = process.cwd() },
336
438
  code,
337
439
  ) =>
338
- hub.request("task.create", {
440
+ hubRequest("task.create", {
339
441
  channelCode: code,
340
442
  to,
341
443
  request,
@@ -399,7 +501,7 @@ export function registerTools(server, hub, { agent, instanceId }) {
399
501
  `ask_agent takes a tool name (e.g. "codex"); use send_task to pin an instance like "${to}"`,
400
502
  );
401
503
  const waitS = clampWaitSeconds(wait_seconds);
402
- const created = await hub.request("task.create", {
504
+ const created = await hubRequest("task.create", {
403
505
  channelCode: code,
404
506
  to,
405
507
  request,
@@ -412,13 +514,11 @@ export function registerTools(server, hub, { agent, instanceId }) {
412
514
  const { taskId } = created;
413
515
  if (created.dispatch !== "spawned" && created.dispatch !== "queued") {
414
516
  // 워커가 뜨지 않았다: 쓰레기 submitted 태스크를 남기지 않는다
415
- await hub
416
- .request("task.cancel", {
417
- channelCode: code,
418
- taskId,
419
- reason: "ask_agent: worker not started",
420
- })
421
- .catch(() => {});
517
+ await hubRequest("task.cancel", {
518
+ channelCode: code,
519
+ taskId,
520
+ reason: "ask_agent: worker not started",
521
+ }).catch(() => {});
422
522
  return {
423
523
  status: "not_started",
424
524
  taskId,
@@ -510,7 +610,7 @@ export function registerTools(server, hub, { agent, instanceId }) {
510
610
  if (git_range !== undefined) review.gitRange = git_range;
511
611
  if (paths !== undefined) review.paths = paths;
512
612
  if (focus !== undefined) review.focus = focus;
513
- const created = await hub.request("task.create", {
613
+ const created = await hubRequest("task.create", {
514
614
  channelCode: code,
515
615
  to,
516
616
  request: request ?? "",
@@ -525,13 +625,11 @@ export function registerTools(server, hub, { agent, instanceId }) {
525
625
  if (wait_seconds === undefined) return created;
526
626
  const { taskId } = created;
527
627
  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(() => {});
628
+ await hubRequest("task.cancel", {
629
+ channelCode: code,
630
+ taskId,
631
+ reason: "request_review: worker not started",
632
+ }).catch(() => {});
535
633
  return {
536
634
  status: "not_started",
537
635
  taskId,
@@ -567,7 +665,7 @@ export function registerTools(server, hub, { agent, instanceId }) {
567
665
  },
568
666
  },
569
667
  needChannel(({ task_id, reason }, code) =>
570
- hub.request("task.cancel", {
668
+ hubRequest("task.cancel", {
571
669
  channelCode: code,
572
670
  taskId: task_id,
573
671
  reason,
@@ -602,7 +700,7 @@ export function registerTools(server, hub, { agent, instanceId }) {
602
700
  },
603
701
  needChannel(
604
702
  ({ status, mine_only = true, sent_by_me = false, kind }, code) =>
605
- hub.request("task.list", {
703
+ hubRequest("task.list", {
606
704
  channelCode: code,
607
705
  to: sent_by_me ? undefined : mine_only ? state.instanceId : undefined,
608
706
  from: sent_by_me ? state.instanceId : undefined,
@@ -621,7 +719,7 @@ export function registerTools(server, hub, { agent, instanceId }) {
621
719
  inputSchema: { task_id: z.string() },
622
720
  },
623
721
  needChannel(({ task_id }, code) =>
624
- hub.request("task.get", { channelCode: code, taskId: task_id }),
722
+ hubRequest("task.get", { channelCode: code, taskId: task_id }),
625
723
  ),
626
724
  );
627
725
 
@@ -645,17 +743,17 @@ export function registerTools(server, hub, { agent, instanceId }) {
645
743
  },
646
744
  },
647
745
  needChannel(async ({ task_id, result, failed = false }, code) => {
648
- const { task } = await hub.request("task.get", {
746
+ const { task } = await hubRequest("task.get", {
649
747
  channelCode: code,
650
748
  taskId: task_id,
651
749
  });
652
750
  if (task.status === "submitted") {
653
- await hub.request("task.claim", {
751
+ await hubRequest("task.claim", {
654
752
  channelCode: code,
655
753
  taskId: task_id,
656
754
  });
657
755
  }
658
- return hub.request("task.complete", {
756
+ return hubRequest("task.complete", {
659
757
  channelCode: code,
660
758
  taskId: task_id,
661
759
  result,
@@ -691,14 +789,14 @@ export function registerTools(server, hub, { agent, instanceId }) {
691
789
  },
692
790
  },
693
791
  needChannel(async ({ task_id, verdict, findings = [], summary }, code) => {
694
- const { task } = await hub.request("task.get", {
792
+ const { task } = await hubRequest("task.get", {
695
793
  channelCode: code,
696
794
  taskId: task_id,
697
795
  });
698
796
  if (task.status === "submitted") {
699
- await hub.request("task.claim", { channelCode: code, taskId: task_id });
797
+ await hubRequest("task.claim", { channelCode: code, taskId: task_id });
700
798
  }
701
- return hub.request("task.complete", {
799
+ return hubRequest("task.complete", {
702
800
  channelCode: code,
703
801
  taskId: task_id,
704
802
  status: "completed",
@@ -722,7 +820,7 @@ export function registerTools(server, hub, { agent, instanceId }) {
722
820
  },
723
821
  },
724
822
  needChannel(({ summary, artifacts = [] }, code) =>
725
- hub.request("context.add", {
823
+ hubRequest("context.add", {
726
824
  channelCode: code,
727
825
  summary,
728
826
  artifacts,
@@ -744,7 +842,7 @@ export function registerTools(server, hub, { agent, instanceId }) {
744
842
  },
745
843
  },
746
844
  needChannel(({ limit }, code) =>
747
- hub.request("context.list", { channelCode: code, limit }),
845
+ hubRequest("context.list", { channelCode: code, limit }),
748
846
  ),
749
847
  );
750
848
  }
@@ -310,7 +310,8 @@ function cliClient({
310
310
  // 안내만 한다 — 되돌리면 멀쩡한 등록을 지우게 된다.
311
311
  if (afterAdd) {
312
312
  const r = afterAdd(env);
313
- if (!r.ok) env.log(`hint: ${label}: ${r.reason}`);
313
+ if (!r.ok)
314
+ env.log(`hint: ${label}: ${r.reason}${r.fix ? ` — ${r.fix}` : ""}`);
314
315
  else if (r.changed)
315
316
  env.log(`updated ${label}: added missing timeout settings`);
316
317
  }
@@ -493,11 +494,14 @@ export const CLIENTS = [
493
494
  // codex mcp add 는 타임아웃 플래그가 없어 config.toml 을 직접 편집한다(스펙 §5)
494
495
  afterAdd(env) {
495
496
  const path = codexConfigPath(env);
497
+ // present 경로(이미 등록됨)에서 채우지 못했을 때 사용자에게 보여 줄 손 수정 방법
498
+ const fix = `add \`tool_timeout_sec = ${TOOL_TIMEOUT_SEC}\` and \`startup_timeout_sec = ${CODEX_STARTUP_TIMEOUT_SEC}\` under [mcp_servers.pluriply] by hand in ${path}`;
496
499
  try {
497
500
  if (!env.fs.existsSync(path))
498
501
  return {
499
502
  ok: false,
500
- reason: `tool_timeout_sec not written (${path} missing)`,
503
+ fix,
504
+ reason: `timeout settings not written (${path} missing)`,
501
505
  };
502
506
  const r = insertTomlKey(
503
507
  env.fs.readFileSync(path, "utf8"),
@@ -508,12 +512,14 @@ export const CLIENTS = [
508
512
  if (r.reason === "no-header")
509
513
  return {
510
514
  ok: false,
511
- reason: `tool_timeout_sec not written ([mcp_servers.pluriply] not found in ${path})`,
515
+ fix,
516
+ reason: `timeout settings not written ([mcp_servers.pluriply] not found in ${path})`,
512
517
  };
513
518
  if (r.reason === "unsupported")
514
519
  return {
515
520
  ok: false,
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)`,
521
+ fix,
522
+ reason: `timeout settings not written (${path} contains triple-quoted strings)`,
517
523
  };
518
524
  // 헤더·트리플쿼트 판정은 파일 단위라 위에서 통과했으면 두 번째 키도 같은 이유로는 실패하지 않는다.
519
525
  // 이미 있는 값(사용자가 고친 값 포함)은 건드리지 않는다.
@@ -529,7 +535,8 @@ export const CLIENTS = [
529
535
  } catch (err) {
530
536
  return {
531
537
  ok: false,
532
- reason: `tool_timeout_sec not written (${err.message})`,
538
+ fix,
539
+ reason: `timeout settings not written (${err.message})`,
533
540
  };
534
541
  }
535
542
  },
@@ -562,12 +569,14 @@ export const CLIENTS = [
562
569
  // agy mcp add 도 타임아웃 플래그가 없다. agy 는 JSONC 를 읽지만 우리는 JSON.parse 만 쓴다(스펙 §11).
563
570
  afterAdd(env) {
564
571
  const path = agyConfigPath(env);
572
+ const fix = `add \`"timeoutSeconds": ${TOOL_TIMEOUT_SEC}\` to mcpServers.pluriply by hand in ${path}`;
565
573
  try {
566
574
  const doc = readJsonDoc(env, path);
567
575
  const entry = doc.mcpServers?.pluriply;
568
576
  if (!entry || typeof entry !== "object")
569
577
  return {
570
578
  ok: false,
579
+ fix,
571
580
  reason: `timeoutSeconds not written (mcpServers.pluriply not found in ${path})`,
572
581
  };
573
582
  const changed = entry.timeoutSeconds === undefined;
@@ -579,6 +588,7 @@ export const CLIENTS = [
579
588
  } catch (err) {
580
589
  return {
581
590
  ok: false,
591
+ fix,
582
592
  reason: `timeoutSeconds not written (${err.message})`,
583
593
  };
584
594
  }