pluriply 0.5.4 → 0.6.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pluriply",
3
- "version": "0.5.4",
3
+ "version": "0.6.0",
4
4
  "description": "Connect your AI coding tools into one collaboration channel",
5
5
  "type": "module",
6
6
  "license": "SEE LICENSE IN LICENSE.md",
@@ -1,4 +1,5 @@
1
1
  import { z } from "zod";
2
+ import { makeWaitForActivity } from "./wait.js";
2
3
 
3
4
  /** @param {object} data @returns {object} MCP 텍스트 결과 */
4
5
  function ok(data) {
@@ -225,6 +226,15 @@ export function registerTools(server, hub, { agent, instanceId }) {
225
226
  }
226
227
  }
227
228
 
229
+ /** Claude Code 는 wait_for_activity 가 쉬는 세션을 깨우므로 위임 응답에서 안내한다(Plan 5a D5) */
230
+ const withNotify = (data) =>
231
+ agent === "claude-code"
232
+ ? {
233
+ ...data,
234
+ notify: "Call wait_for_activity to be woken when the result arrives.",
235
+ }
236
+ : data;
237
+
228
238
  /**
229
239
  * 채널이 없을 때 허브에 직전 채널 복귀를 요청한다.
230
240
  * @returns {Promise<string|null>} 복귀한 채널 코드
@@ -377,6 +387,28 @@ export function registerTools(server, hub, { agent, instanceId }) {
377
387
  },
378
388
  );
379
389
 
390
+ const waitForActivity = makeWaitForActivity({ hubRequest, agent });
391
+ const waitForActivityChannel = needChannel((args, code, extra) =>
392
+ waitForActivity(args, code, extra),
393
+ );
394
+ server.registerTool(
395
+ "wait_for_activity",
396
+ {
397
+ annotations: annotations({ readOnlyHint: true }),
398
+ description:
399
+ "Wait until something arrives for this session — tasks sent to you, results of tasks you sent, or notices that nobody picked up your task — and return a summary. " +
400
+ (agent === "claude-code"
401
+ ? "In Claude Code this call moves to the background after about 2 minutes and wakes this session when activity arrives: call it after delegating work or when the user asks you to listen, and call it again after handling what it returns. wait_seconds defaults to 43200 (max 86400)."
402
+ : "In this tool the call blocks the session, so keep it short: wait_seconds defaults to 50 (max 300). Stop hooks also report new activity at the end of each turn."),
403
+ inputSchema: { wait_seconds: z.number().optional() },
404
+ },
405
+ // worker 는 채널이 없어도 즉시 거절한다(needChannel 앞에서 검사 — 자동 복귀 왕복 없이).
406
+ (args, extra) =>
407
+ worker
408
+ ? fail("wait_for_activity is for interactive sessions")
409
+ : waitForActivityChannel(args, extra),
410
+ );
411
+
380
412
  server.registerTool(
381
413
  "list_peers",
382
414
  {
@@ -462,20 +494,22 @@ export function registerTools(server, hub, { agent, instanceId }) {
462
494
  },
463
495
  },
464
496
  needChannel(
465
- (
497
+ async (
466
498
  { to, request, attachments = [], mode = "auto", cwd = process.cwd() },
467
499
  code,
468
500
  ) =>
469
- hubRequest("task.create", {
470
- channelCode: code,
471
- to,
472
- request,
473
- attachments,
474
- mode,
475
- cwd,
476
- origin: process.cwd(),
477
- depth: delegationDepth(),
478
- }),
501
+ withNotify(
502
+ await hubRequest("task.create", {
503
+ channelCode: code,
504
+ to,
505
+ request,
506
+ attachments,
507
+ mode,
508
+ cwd,
509
+ origin: process.cwd(),
510
+ depth: delegationDepth(),
511
+ }),
512
+ ),
479
513
  ),
480
514
  );
481
515
 
@@ -651,7 +685,7 @@ export function registerTools(server, hub, { agent, instanceId }) {
651
685
  origin: process.cwd(),
652
686
  depth: delegationDepth(),
653
687
  });
654
- if (wait_seconds === undefined) return created;
688
+ if (wait_seconds === undefined) return withNotify(created);
655
689
  const { taskId } = created;
656
690
  if (created.dispatch === "none") {
657
691
  await hubRequest("task.cancel", {
@@ -735,6 +769,8 @@ export function registerTools(server, hub, { agent, instanceId }) {
735
769
  from: sent_by_me ? state.instanceId : undefined,
736
770
  status,
737
771
  kind,
772
+ // 모델이 목록을 보았으니 제 몫의 태스크는 전달된 것으로 기록한다(Plan 5a)
773
+ markDelivered: true,
738
774
  }),
739
775
  ),
740
776
  );
@@ -0,0 +1,143 @@
1
+ // wait_for_activity(Plan 5a 스펙 §4): 이 세션의 활동이 올 때까지 허브 agent.wait 를 짧게 반복한다.
2
+ // Claude Code 에서는 2분 뒤 백그라운드로 가서, 끝나면 알림으로 쉬는 세션을 깨운다(2026-09-27 스파이크).
3
+ export const CHUNK_MS = 15_000;
4
+ /** 잠깐 끊긴 연결 — 다음 요청이 재접속 배리어를 기다린다. hub unreachable(dead)은 여기에 없다. */
5
+ const TRANSIENT = /^hub (connection closed|connection error|request timed out)/;
6
+ const HANDLE =
7
+ "Handle the items above (submit_result / submit_review for incoming tasks; read results with get_task_result)";
8
+
9
+ /** @param {string} agent @returns {{def: number, max: number}} 초 */
10
+ export function waitLimits(agent) {
11
+ return agent === "claude-code"
12
+ ? { def: 43_200, max: 86_400 }
13
+ : { def: 50, max: 300 };
14
+ }
15
+
16
+ /** @param {string} agent @param {unknown} v @returns {number} 초. 1 미만·숫자 아님은 기본값, 상한 초과는 상한 */
17
+ export function clampWait(agent, v) {
18
+ const { def, max } = waitLimits(agent);
19
+ const n = typeof v === "number" ? v : Number(v);
20
+ if (!Number.isFinite(n) || n < 1) return def;
21
+ return Math.min(n, max);
22
+ }
23
+
24
+ /** @param {string} agent @param {"activity"|"idle"} status @returns {string} */
25
+ export function nextHint(agent, status) {
26
+ if (agent === "claude-code")
27
+ return status === "activity"
28
+ ? `${HANDLE}, then call wait_for_activity again to keep listening.`
29
+ : "Nothing arrived. Call wait_for_activity again to keep listening.";
30
+ const tail =
31
+ "Stop hooks will also report new activity at the end of each turn.";
32
+ return status === "activity"
33
+ ? `${HANDLE}. ${tail}`
34
+ : `Nothing arrived. ${tail}`;
35
+ }
36
+
37
+ const defaultSleep = (ms) => new Promise((r) => setTimeout(r, ms));
38
+
39
+ /**
40
+ * promise 를 extra.signal 중단과 경합한다. 중단이 이기면 promise 는 그대로 살려 둔다 —
41
+ * 호출한 쪽이 "다음 호출이 이어받는다" 규칙에 따라 계속 들고 있는다.
42
+ * @param {Promise<any>} promise @param {AbortSignal|undefined} signal
43
+ * @returns {Promise<{aborted: true}|{value: any}|{error: Error}>}
44
+ */
45
+ function raceAbort(promise, signal) {
46
+ const settled = promise.then(
47
+ (value) => ({ value }),
48
+ (error) => ({ error }),
49
+ );
50
+ if (!signal) return settled;
51
+ if (signal.aborted) return Promise.resolve({ aborted: true });
52
+ return new Promise((resolve) => {
53
+ const onAbort = () => resolve({ aborted: true });
54
+ signal.addEventListener("abort", onAbort, { once: true });
55
+ settled.then((result) => {
56
+ signal.removeEventListener("abort", onAbort);
57
+ resolve(result);
58
+ });
59
+ });
60
+ }
61
+
62
+ /**
63
+ * 커넥터 하나당 하나 만든다(세션당 대기 하나 — 진행 중이면 already_listening).
64
+ * @param {{hubRequest: (type: string, payload?: object, opts?: object) => Promise<any>, agent: string, sleep?: (ms: number) => Promise<void>, now?: () => number}} deps
65
+ * @returns {(args: {wait_seconds?: number}, code: string, extra?: object) => Promise<object>}
66
+ */
67
+ export function makeWaitForActivity({
68
+ hubRequest,
69
+ agent,
70
+ sleep = defaultSleep,
71
+ now = Date.now,
72
+ }) {
73
+ let listening = false;
74
+ // 진행 중인 agent.wait 요청. 중단돼도 버리지 않고 남겨 둔다: 허브는 응답을 쓰는 순간
75
+ // 이미 delivered 로 기록하므로(스펙 §13, 한 번만 전달), 이 요청이 마저 끝나면 다음 호출이
76
+ // 그 결과를 첫 조각으로 이어받는다 — 그러지 않으면 항목이 조용히 사라진다.
77
+ // 남는 한계: 이 orphan 요청이 끝나기 전에 커넥터 프로세스가 종료되면 그 항목은 그대로 유실된다
78
+ // (스펙 §13의 한 번만 전달 한계와 동일).
79
+ let inflight = null;
80
+ return async ({ wait_seconds } = {}, _code, extra) => {
81
+ if (listening) return { status: "already_listening" };
82
+ listening = true;
83
+ try {
84
+ const deadline = now() + clampWait(agent, wait_seconds) * 1000;
85
+ const signal = extra?.signal;
86
+ const progressToken = extra?._meta?.progressToken;
87
+ let ticks = 0;
88
+ for (;;) {
89
+ if (!inflight) {
90
+ if (signal?.aborted) return { status: "cancelled" };
91
+ const left = deadline - now();
92
+ if (left <= 0)
93
+ return { status: "idle", next: nextHint(agent, "idle") };
94
+ const timeoutMs = Math.max(1, Math.min(CHUNK_MS, left));
95
+ const p = hubRequest(
96
+ "agent.wait",
97
+ { timeoutMs },
98
+ { timeoutMs: timeoutMs + 5000 },
99
+ );
100
+ p.catch(() => {}); // 중단돼 버려져도 처리되지 않은 거부로 남지 않게 한다
101
+ inflight = p;
102
+ }
103
+ const settled = await raceAbort(inflight, signal);
104
+ if (settled.aborted) return { status: "cancelled" }; // inflight 는 그대로 남긴다
105
+ inflight = null; // 결과(또는 오류)를 소비하기 전에 비운다
106
+ if (settled.error) {
107
+ if (!TRANSIENT.test(settled.error.message)) throw settled.error;
108
+ await sleep(1000);
109
+ continue;
110
+ }
111
+ const r = settled.value;
112
+ const incoming = r.incoming ?? [];
113
+ const results = r.results ?? [];
114
+ const stalled = r.stalled ?? [];
115
+ if (incoming.length + results.length + stalled.length > 0)
116
+ return {
117
+ status: "activity",
118
+ channelCode: r.channelCode,
119
+ incoming,
120
+ results,
121
+ stalled,
122
+ more: r.more ?? 0,
123
+ next: nextHint(agent, "activity"),
124
+ };
125
+ if (progressToken !== undefined && extra?.sendNotification) {
126
+ ticks++;
127
+ await extra
128
+ .sendNotification({
129
+ method: "notifications/progress",
130
+ params: {
131
+ progressToken,
132
+ progress: ticks,
133
+ message: "waiting for pluriply activity",
134
+ },
135
+ })
136
+ .catch(() => {}); // 진행 알림은 최선 노력이라 실패해도 대기 자체는 계속한다
137
+ }
138
+ }
139
+ } finally {
140
+ listening = false;
141
+ }
142
+ };
143
+ }
package/src/hub/index.js CHANGED
@@ -1,21 +1,22 @@
1
1
  // Copyright (c) 2026 TQSoft. All rights reserved.
2
2
  // Licensed under LICENSE-HUB.md — not open source.
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(`
3
+ import{WebSocketServer as Je}from"ws";import{mkdirSync as Ve,writeFileSync as Xe,rmSync as qt,readFileSync as Qe,chmodSync as Ze}from"node:fs";import{EventEmitter as tn}from"node:events";import{connect as en}from"node:net";import{randomBytes as nn,timingSafeEqual as sn}from"node:crypto";import{join as ct,isAbsolute as Mt}from"node:path";import{mkdirSync as Yt,readFileSync as mt,writeFileSync as pt,renameSync as wt,existsSync as gt,readdirSync as z,rmSync as kt}from"node:fs";import{join as A}from"node:path";import{pluriplyHome as zt}from"../shared/paths.js";var J=/^plp-[a-z0-9]{4}-[a-z0-9]{4}$/,j=class{constructor(e=zt()){this.root=e,this.dir=A(e,"channels"),Yt(this.dir,{recursive:!0}),this.#t()}#t(){for(let e of z(this.dir))e.endsWith(".tmp")&&kt(A(this.dir,e),{force:!0});for(let e of z(this.root))e.startsWith("agents.json.")&&e.endsWith(".tmp")&&kt(A(this.root,e),{force:!0})}loadChannel(e){if(!J.test(e))throw new Error(`invalid channel code: ${e}`);let t=A(this.dir,`${e}.json`);return gt(t)?JSON.parse(mt(t,"utf8")):null}saveChannel(e,t){if(!J.test(e))throw new Error(`invalid channel code: ${e}`);let n=A(this.dir,`${e}.json`),s=`${n}.${process.pid}.tmp`;pt(s,JSON.stringify(t,null,2)),wt(s,n)}listChannels(){return z(this.dir).filter(e=>e.endsWith(".json")).map(e=>e.slice(0,-5)).filter(e=>J.test(e))}loadAgents(){let e=A(this.root,"agents.json");if(!gt(e))return{};try{let t=JSON.parse(mt(e,"utf8"));return t&&typeof t=="object"&&!Array.isArray(t)?t:{}}catch{return{}}}saveAgents(e){let t=A(this.root,"agents.json"),n=`${t}.${process.pid}.tmp`;pt(n,JSON.stringify(e,null,2)),wt(n,t)}};import{channelCode as Vt}from"../shared/ids.js";var W=class{constructor(e){this.store=e}touch(e,t,n=new Date){try{let s=this.store.loadAgents();s[e]={lastChannel:t,lastSeenAt:n.toISOString()},this.store.saveAgents(s)}catch{}}resume(e,t=new Date){let n=this.store.loadAgents()[e];if(!n)return null;let s=t.getTime()-Date.parse(n.lastSeenAt);if(!(s>=0&&s<=432e5))return null;try{if(!this.store.loadChannel(n.lastChannel))return null}catch{return null}return n.lastChannel}};var L=class extends Error{constructor(e){super(`channel not found: ${e}`),this.code=e}},G=class{constructor(e){this.store=e}create(){let e={channel:{code:Vt(),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 L(e);return t}save(e,t){this.store.saveChannel(e,t)}join(e,{instanceId:t,tool:n,worker:s=!1},{online:r=new Set,now:o=new Date}={}){let a=this.get(e);this.#t(a,r,o);let l=o.toISOString(),h=a.channel.peers.find(u=>u.instanceId===t);return h?h.lastSeenAt=l:a.channel.peers.push({instanceId:t,tool:n,worker:!!s,joinedAt:l,lastSeenAt:l}),this.save(e,a),{channel:a.channel,peers:a.channel.peers}}peers(e,{online:t=new Set,now:n=new Date}={}){let s=this.get(e);return this.#t(s,t,n)&&this.save(e,s),s.channel.peers}#t(e,t,n){let s=e.channel.peers.length;return e.channel.peers=e.channel.peers.filter(r=>{if(!r.instanceId)return!1;if(t.has(r.instanceId))return!0;let o=n.getTime()-Date.parse(r.lastSeenAt);return o>=0&&o<=432e5}),e.channel.peers.length!==s}};import{statSync as Xt,realpathSync as M}from"node:fs";import{sep as Qt,join as Zt,isAbsolute as te}from"node:path";import{taskId as ee}from"../shared/ids.js";import{parseTarget as yt,toolOf as vt,isInstanceId as ne}from"../shared/identity.js";var V=new Set(["completed","failed","cancelled"]),b=class extends Error{constructor(e){super(`task not found: ${e}`)}},O=class extends Error{constructor(e,t){super(`invalid task transition: ${e} -> ${t}`)}},K=class extends Error{constructor(e,t,n){super(`task ${e} is addressed to "${t}", not "${n}"`)}},X=class extends Error{constructor(e,t,n){super(`only the sender "${t}" can cancel task ${e}, not "${n}"`)}},x=class extends Error{constructor(e){super(e)}},Q=class extends Error{constructor(e){super(`delegation depth limit (${e}) exceeded`)}},se=["task","review"],It=["approve","request_changes","comment"],St=["critical","important","minor"],T=class extends Error{constructor(e){super(e)}},_=class extends Error{constructor(e){super(e)}},Z=class extends Error{constructor(e){super(`use submit_review for review tasks (task ${e})`)}},tt=class extends Error{constructor(e){super(`task ${e} is not a review`)}},et=class extends Error{constructor(e){super(`cwd is not an existing directory: ${e}`)}},nt=class extends Error{constructor(e){super(`cwd outside allowed roots: ${e}`)}},re=["auto","spawn","interactive"];function Et(i,e){return i===e||i.startsWith(e+Qt)}function ie(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=M(e)}catch{}t.paths=i.paths.map(s=>{let r=te(s)?s:Zt(e,s),o;try{o=M(r)}catch{throw new T(`path does not exist: ${s}`)}if(!Et(o,n))throw new T(`path outside cwd: ${s}`);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 oe(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 ae(i){if(!i||typeof i!="object"||Array.isArray(i))throw new _("review result must be an object");if(!It.includes(i.verdict))throw new _(`verdict must be one of ${It.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,s)=>{if(!n||typeof n!="object"||Array.isArray(n))throw new _(`findings[${s}] must be an object`);if(!St.includes(n.severity))throw new _(`findings[${s}].severity must be one of ${St.join(", ")}`);if(typeof n.message!="string"||n.message.length===0)throw new _(`findings[${s}].message is required`);let r={severity:n.severity,message:n.message};if(n.file!==void 0){if(typeof n.file!="string")throw new _(`findings[${s}].file must be a string`);r.file=n.file}if(n.line!==void 0){if(!Number.isInteger(n.line)||n.line<1)throw new _(`findings[${s}].line must be a positive integer`);r.line=n.line}if(n.suggestion!==void 0){if(typeof n.suggestion!="string")throw new _(`findings[${s}].suggestion must be a string`);r.suggestion=n.suggestion}return r});return{verdict:i.verdict,findings:t,summary:i.summary}}function _t(i,e){return i.toInstance?e===i.toInstance:vt(e)===(i.toTool??i.to)}function ce(i,e){return i.from===e?!0:!i.from.includes("#")&&vt(e)===i.from}var F=class{constructor(e){this.registry=e,this.waiters=new Map,this.channelWaiters=new Map}create(e,{from:t,to:n,request:s,attachments:r=[],depth:o=0,cwd:a,origin:l,allowedRoots:h=[],mode:u="auto",maxDepth:d=2,online:p=new Set,kind:f="task",review:w,fromCwdKey:I}){if(typeof n!="string"||n.length===0)throw new x("target agent name is required");if(n.includes("#")&&!ne(n))throw new x(`no peer "${n}" on this channel`);let g=yt(n);if(g.instance!==null&&g.instance===t)throw new x("cannot delegate a task to yourself");if((!Number.isInteger(o)||o<0)&&(o=0),o>d)throw new Q(d);if(!re.includes(u))throw new Error(`invalid mode: ${u}`);if(a!==void 0){let v=!1;try{v=Xt(a).isDirectory()}catch{v=!1}if(!v)throw new et(a);let P=M(a),B=[];if(l!==void 0)try{B.push(M(l))}catch{}for(let Y of h)try{B.push(M(Y))}catch{}if(!B.some(Y=>Et(P,Y)))throw new nt(a);a=P}if(!se.includes(f))throw new Error(`invalid kind: ${f}`);let $;if(f==="review")$=ie(w??{},a??l),(typeof s!="string"||s.length===0)&&(s=oe($));else if(w!==void 0)throw new T('review is only valid for kind "review"');let m=this.registry.get(e),k=m.channel.peers,S=k.filter(v=>v.tool===g.tool),c;if(g.instance===null){if(c=S.length>0,!c){let v=k.find(P=>P.tool?.toLowerCase()===g.tool.toLowerCase());if(v)throw new x(`no peer named "${n}" on this channel; did you mean "${v.tool}"?`)}}else if(c=S.some(v=>v.instanceId===g.instance),!c&&S.length>0){let v=S.find(P=>p.has(P.instanceId))??S[0];throw new x(`no peer "${n}" on this channel; did you mean "${v.instanceId}"?`)}let y=new Date().toISOString(),R={taskId:ee(),from:t,to:n,toTool:g.tool,request:s,attachments:r,depth:o,mode:u,kind:f,status:"submitted",result:null,createdAt:y,updatedAt:y};g.instance!==null&&(R.toInstance=g.instance),a!==void 0&&(R.cwd=a),typeof I=="string"&&I.length>0&&(R.fromCwdKey=I),f==="review"&&(R.review=$),m.tasks.push(R),this.registry.save(e,m),this.#e(e);let D={task:R,targetJoined:c};return g.instance!==null&&(D.targetOnline=p.has(g.instance),c&&!D.targetOnline&&(D.warning=`"${n}" is not online; the task will wait until it reconnects.`)),c||(D.warning=`"${n}" has not joined this channel yet; the task will wait until it joins.`),D}list(e,{to:t,from:n,status:s,kind:r}={}){let o=this.registry.get(e).tasks;if(t){let a=yt(t);o=o.filter(l=>a.instance===null?(l.toTool??l.to)===a.tool:l.toInstance===a.instance||!l.toInstance&&(l.toTool??l.to)===a.tool)}return n&&(o=o.filter(a=>a.from===n)),s&&(o=o.filter(a=>a.status===s)),r&&(o=o.filter(a=>(a.kind??"task")===r)),o}get(e,t){let n=this.registry.get(e).tasks.find(s=>s.taskId===t);if(!n)throw new b(t);return n}#t(e,t,n){let s=this.registry.get(e),r=s.tasks.find(a=>a.taskId===t);if(!r)throw new b(t);let o=r.status;return n(r),r.updatedAt=new Date().toISOString(),this.registry.save(e,s),r.status!==o&&this.#r(e,t,r),this.#e(e),r}#r(e,t,n){let s=this.waiters.get(`${e}/${t}`);if(s){this.waiters.delete(`${e}/${t}`);for(let r of s)r(n)}}#e(e){let t=this.channelWaiters.get(e);if(t){this.channelWaiters.delete(e);for(let n of t)n(!0)}}waitForChange(e,t,{signal:n}={}){return n?.aborted?Promise.resolve(null):new Promise(s=>{let r,o=e.map(h=>{let u=this.channelWaiters.get(h)??new Set;return this.channelWaiters.set(h,u),[h,u]}),a=h=>{clearTimeout(r),n?.removeEventListener("abort",l);for(let[u,d]of o)d.delete(a),d.size===0&&this.channelWaiters.get(u)===d&&this.channelWaiters.delete(u);s(h)},l=()=>a(null);for(let[,h]of o)h.add(a);n?.addEventListener("abort",l,{once:!0}),r=setTimeout(()=>a(!1),t)})}waitFor(e,t,n,{signal:s}={}){let r=this.get(e,t);if(V.has(r.status))return Promise.resolve(r);if(s?.aborted)return Promise.resolve(null);let o=`${e}/${t}`;return new Promise(a=>{let l,h=this.waiters.get(o)??new Set;this.waiters.set(o,h);let u=p=>{clearTimeout(l),s?.removeEventListener("abort",d),h.delete(u),h.size===0&&this.waiters.get(o)===h&&this.waiters.delete(o),a(p)},d=()=>u(null);h.add(u),s?.addEventListener("abort",d,{once:!0}),l=setTimeout(()=>{let p=r;try{p=this.get(e,t)}catch{}u(p)},n)})}claim(e,t,n){return this.#t(e,t,s=>{if(!_t(s,n))throw new K(t,s.to,n);if(s.status!=="submitted")throw new O(s.status,"working");s.status="working"})}complete(e,t,{from:n,result:s,status:r="completed",worker:o=!1,review:a}){if(r!=="completed"&&r!=="failed")throw new O("?",r);return this.#t(e,t,l=>{if(!_t(l,n))throw new K(t,l.to,n);if(V.has(l.status))throw new O(l.status,r);let h=(l.kind??"task")==="review";if(!h&&a!==void 0)throw new tt(t);if(h&&r==="completed"){if(a===void 0)throw new Z(t);s=ae(a)}l.status=r,l.result=s,l.completedBy=n,o?l.completedByWorker=!0:delete l.completedByWorker})}cancel(e,t,{agent:n,reason:s}){return this.#t(e,t,r=>{if(!ce(r,n))throw new X(t,r.from,n);if(r.status!=="submitted"&&r.status!=="working")throw new O(r.status,"cancelled");r.status="cancelled",r.result=s??null,r.cancelledBy=n})}markHookDelivered(e,t,n,s=new Date){let r=this.registry.get(e),o=r.tasks.find(a=>a.taskId===t);if(!o)throw new b(t);return o.hookDelivered={...o.hookDelivered??{},[n]:s.toISOString()},this.registry.save(e,r),o}setDispatch(e,t,n,s=new Date){let r=this.registry.get(e),o=r.tasks.find(a=>a.taskId===t);if(!o)throw new b(t);return o.dispatch=n,o.dispatchedAt=s.toISOString(),this.registry.save(e,r),o}setFallback(e,t,{kind:n,hint:s},r=new Date){let o=this.registry.get(e),a=o.tasks.find(l=>l.taskId===t);if(!a)throw new b(t);return a.fallback={at:r.toISOString(),kind:n,...s?{hint:s}:{}},this.registry.save(e,o),this.#e(e),a}markStallNotified(e,t,n,s=new Date){let r=this.registry.get(e),o=r.tasks.find(a=>a.taskId===t);if(!o)throw new b(t);return o.stallNotified={...o.stallNotified??{},[n]:s.toISOString()},this.registry.save(e,r),o}setWorker(e,t,n){return this.#t(e,t,s=>{s.worker={...s.worker??{},...n}})}failIfOpen(e,t,{result:n,by:s}){return this.#t(e,t,r=>{V.has(r.status)||(r.status="failed",r.result=n,r.completedBy=s)})}};import{entryId as le}from"../shared/ids.js";var q=class{constructor(e){this.registry=e}add(e,{from:t,summary:n,artifacts:s=[]}){let r=this.registry.get(e),o={entryId:le(),from:t,summary:n,artifacts:s,at:new Date().toISOString()};return r.context.push(o),this.registry.save(e,r),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 Pe}from"node:child_process";import{mkdirSync as Dt,openSync as Oe,closeSync as De,readFileSync as Le,appendFileSync as rt,readdirSync as Me,rmSync as Ne}from"node:fs";import{mkdir as je,rm as it}from"node:fs/promises";import{join as E}from"node:path";import{workerEnabled as We,TEMPLATE_AGENTS as Ge}from"../shared/config.js";import{join as Tt}from"node:path";import{fileURLToPath as he}from"node:url";import{agyCommand as ue}from"../shared/agy.js";import{DEFAULT_LIMITS as de}from"../shared/config.js";var fe=he(new URL("../../bin/pluriply.js",import.meta.url)),me=["acceptEdits","bypassPermissions"],pe=["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 st(){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 we(i,e){let t=st()?.[i];if(!t)return;let n=s=>s.replace(/\{(taskId|channelCode|home|cwd|prompt|readOnly)\}/g,(r,o)=>String(e[o]));return{command:t.command,args:t.args.map(n)}}function $t(i,{home:e,cwd:t,prompt:n,logDir:s,taskId:r,channelCode:o,taskDir:a=Tt(s,r),permissionMode:l="acceptEdits",timeoutMs:h=de.timeoutMs,readOnly:u=!1}){let d=we(i,{taskId:r,channelCode:o,home:e,cwd:t,prompt:n,readOnly:u});if(d)return d;switch(i){case"codex":return{command:"codex",args:["exec","-C",t,"--skip-git-repo-check","-s",u?"read-only":"workspace-write","-c",'approval_policy="never"',"-o",Tt(s,`${r}.last.md`),n]};case"claude-code":{if(!me.includes(l))throw new Error(`invalid permissionMode "${l}" for claude-code worker`);let p=JSON.stringify({mcpServers:{pluriply:{command:process.execPath,args:[fe,"connector","--agent","claude-code"],env:{PLURIPLY_HOME:e}}}});return{command:"claude",args:["-p",...u?["--allowedTools",...pe,"--permission-mode","default","--add-dir",a]:["--permission-mode",l],"--mcp-config",p,"--strict-mcp-config","--output-format","json",n]}}case"antigravity":return{command:ue(),args:["-p",n,...u?["--mode","plan"]:[],"--dangerously-skip-permissions","--output-format","text","--print-timeout",`${Math.ceil(h/1e3)}s`]};default:return null}}import{pidAlive as Ke}from"../shared/probe.js";import{writeFile as Ee}from"node:fs/promises";import{execFile as ge}from"node:child_process";import{promisify as ke}from"node:util";var ye=ke(ge),Ie=[/^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$/],Se=["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 _e(){let i={...process.env};for(let e of Se)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 ve(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 N(i,{cwd:e,env:t,maxBuffer:n=4*1024*1024,timeout:s=2e4}){try{let{stdout:r}=await ye("git",i,{cwd:e,env:t,encoding:"utf8",maxBuffer:n,timeout:s,windowsHide:!0});return r}catch(r){throw new Error(ve(r.stderr)||r.message)}}async function bt(i){let e=_e(),t=o=>N(["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 s=["-c","core.fsmonitor=false"],r=new Set(["core.fsmonitor"]);for(let o of n)for(let a of o.split("\0")){if(!a)continue;let l=a.split(`
5
+ `,1)[0];if(r.has(l))continue;let h=l.toLowerCase();Ie.some(u=>u.test(h))&&(r.add(l),s.push("-c",`${l}=`))}return{args:s,env:e}}var Te=20*1024*1024;async function At({cwd:i,review:e,outFile:t,git:n}){if(e.gitRange?.startsWith("-"))throw new Error('gitRange must not start with "-"');let s=[...n.args,"diff","--no-color","--no-ext-diff","--no-textconv"],r;e.gitRange?(s.push(e.gitRange,"--"),e.paths?.length&&s.push(...e.paths),r=`git diff ${e.gitRange}`):e.paths?.length?(s.push("HEAD","--",...e.paths),r=`git diff HEAD -- ${e.paths.join(" ")}`):(s.push("HEAD"),r="git diff HEAD");let o;try{o=await N(s,{cwd:i,env:n.env,maxBuffer:Te,timeout:2e4})}catch(a){throw new Error(`${r} failed: ${a.message}`)}return await Ee(t,o),{file:t,bytes:Buffer.byteLength(o),target:r}}import{copyFile as $e,lstat as be,mkdir as xt,rm as Ct}from"node:fs/promises";import{dirname as Ae,isAbsolute as xe,join as Rt}from"node:path";var Ce=512*1024*1024,Re=16,Pt=Object.freeze({recursive:!0,force:!0,maxRetries:5,retryDelay:100});async function Ot({repoDir:i,destDir:e,git:t,maxBytes:n=Ce}){let r=(await N([...t.args,"ls-files","-z","-co","--exclude-standard"],{cwd:i,env:t.env,maxBuffer:67108864})).split("\0").filter(Boolean);await Ct(e,Pt),await xt(e,{recursive:!0});let o=0,a=0,l=0,h=0,u=async()=>{for(;h<r.length;){let f=r[h++];if(xe(f)||f.split("/").includes(".."))continue;let w=Rt(i,f),I;try{I=await be(w)}catch{continue}if(I.isSymbolicLink()){l++;continue}if(!I.isFile())continue;if(a+=I.size,a>n)throw new Error(`snapshot exceeds ${Math.floor(n/1024/1024)}MB`);let g=Rt(e,f);await xt(Ae(g),{recursive:!0}),await $e(w,g),o++}},p=(await Promise.allSettled(Array.from({length:Math.min(Re,r.length)},u))).find(f=>f.status==="rejected");if(p)throw await Ct(e,Pt),p.reason;return{files:o,bytes:a,skippedSymlinks:l}}var H=Object.freeze({recursive:!0,force:!0,maxRetries:5,retryDelay:100}),Fe=i=>`run \`pluriply worker enable ${i}\` to let the hub process this automatically`,qe=i=>`no worker template is configured for "${i}"; enable it via config or set PLURIPLY_WORKER_TEMPLATE_OVERRIDE`,He=new Set(["completed","failed","cancelled"]);function Ue(i){return Ge.includes(i)?!0:!!st()?.[i]}function Be(i){try{return Le(i,"utf8").trimEnd().split(`
6
6
  `).slice(-20).join(`
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
- `)}catch{}}else k?I=`worker timed out after ${n.limits.timeoutMs/1e3}s`:I=`worker exited without submitting a result (exit ${f})
7
+ `)}catch{return""}}function Ye({agent:i,channelCode:e,taskId:t,cwd:n,from:s}){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 ${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. \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 ze({agent:i,channelCode:e,taskId:t,cwd:n,origin:s,from:r,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 ${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. 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 ${s} \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 U=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:s}){if(t.toInstance)return{kind:"pinned",hint:"pinned tasks are never handed to a worker"};let r=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(!We(s,r))return{kind:"none",hint:Fe(r)};try{if(!Ue(r))return{kind:"none",hint:qe(r)};if(this.#t(r)>=s.limits.maxConcurrentPerAgent)return this.#r(r)>=s.limits.maxQueuedPerAgent?{kind:"none",hint:`worker queue full (${s.limits.maxQueuedPerAgent}) for ${r}`}:(this.queue.push({code:e,task:t,config:s}),{kind:"queued"});this.#i(e,t,s)}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 s of n)(e===void 0||s.code===e)&&t++;return t}onCancelled(e,t){for(let s of this.running.values())for(let r of s)r.code===e&&r.taskId===t&&(r.child?r.child.kill("SIGTERM"):r.cancelled=!0);let n=this.queue.findIndex(s=>s.code===e&&s.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.#e(e,t.taskId)||Ke(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[s,r]of this.running)for(let o of r){if(!o.child){o.cancelled=!0,r.delete(o);try{this.tasks.failIfOpen(o.code,o.taskId,{result:"hub stopped while the review was being prepared",by:`${s} worker`})}catch{}it(E(this.home,"workers",o.taskId,"tree"),H).catch(()=>{});continue}o.child.kill("SIGTERM"),e.push(o.child)}for(let s of this.queue)try{this.tasks.failIfOpen(s.code,s.task.taskId,{result:"hub stopped while the task was queued",by:`${s.task.toTool??s.task.to} worker`})}catch{}this.queue.length=0;let t=s=>s.exitCode===null&&s.signalCode===null,n=Date.now()+2e3;for(;e.some(t)&&Date.now()<n;)await new Promise(s=>setTimeout(s,100));for(let s of e)if(t(s))try{s.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}#e(e,t){for(let n of this.running.values())for(let s of n)if(s.code===e&&s.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=Me(e,{withFileTypes:!0})}catch{return}for(let n of t)if(!(!n.isDirectory()||this.#a(n.name)))try{Ne(E(e,n.name,"tree"),H)}catch{}}#i(e,t,n){let s=t.toTool??t.to,r={code:e,taskId:t.taskId,child:null,cancelled:!1};this.running.has(s)||this.running.set(s,new Set),this.running.get(s).add(r),this.#n(e,t,n,r).catch(o=>{this.#s(r,s,t,`worker spawn failed: ${o.message}`)})}#s(e,t,n,s){this.running.get(t)?.delete(e);let r=E(this.home,"workers",`${n.taskId}.log`);try{rt(r,`${s}
10
+ `)}catch{}try{this.tasks.failIfOpen(e.code,n.taskId,{result:s,by:`${t} worker`})}catch{}it(E(this.home,"workers",n.taskId,"tree"),H).catch(()=>{}),this.#o(t)}async#n(e,t,n,s){let r=t.toTool??t.to,o=E(this.home,"workers");Dt(o,{recursive:!0});let a=E(o,`${t.taskId}.log`),l=(t.kind??"task")==="review",h=E(o,t.taskId),u={agent:r,channelCode:e,taskId:t.taskId,from:t.from},d,p,f="";try{if(l){if(!t.cwd)throw new Error("review task has no cwd");await je(h,{recursive:!0});let m=await bt(t.cwd);d=E(h,"tree");let k=await Ot({repoDir:t.cwd,destDir:d,git:m}),S=await At({cwd:t.cwd,review:t.review??{},outFile:E(h,"review.diff"),git:m});f=`snapshot: ${k.files} files, ${k.bytes} bytes, ${k.skippedSymlinks} symlinks skipped
11
+ `,p=ze({...u,cwd:d,origin:t.cwd,diff:S}),await this.#l()}else d=t.cwd??E(this.home,"workspaces",t.taskId),Dt(d,{recursive:!0}),p=Ye({...u,cwd:d})}catch(m){this.#s(s,r,t,`${l?"review preparation":"worker spawn"} failed: ${m.message}`);return}if(s.cancelled||this.stopping){this.#s(s,r,t,this.stopping?"hub stopped while the worker was being prepared":"worker cancelled while it was being prepared");return}let w;try{let m=$t(r,{home:this.home,cwd:d,prompt:p,logDir:o,taskDir:h,taskId:t.taskId,channelCode:e,permissionMode:n.workers[r]?.permissionMode,timeoutMs:n.limits.timeoutMs,readOnly:l});f&&rt(a,f);let k=Oe(a,"a");try{w=Pe(m.command,m.args,{cwd:d,shell:!1,stdio:["ignore",k,k],env:{...process.env,PLURIPLY_HOME:this.home,PLURIPLY_WORKER_TASK:t.taskId,PLURIPLY_WORKER_AGENT:r,PLURIPLY_DEPTH:String(t.depth??0)}})}finally{De(k)}}catch(m){this.#s(s,r,t,`worker spawn failed: ${m.message}`);return}s.child=w;try{this.tasks.setWorker(e,t.taskId,{agent:r,pid:w.pid,startedAt:new Date().toISOString(),log:a})}catch(m){try{w.kill("SIGKILL")}catch{}this.#s(s,r,t,`worker spawn failed: ${m.message}`);return}let I=!1,g=setTimeout(()=>{I=!0,w.kill("SIGTERM"),setTimeout(()=>w.kill("SIGKILL"),5e3).unref()},n.limits.timeoutMs),$=(m,k)=>{clearTimeout(g),this.running.get(r)?.delete(s);let S={endedAt:new Date().toISOString(),exitCode:m};I&&(S.timedOut=!0);try{this.tasks.setWorker(e,t.taskId,S);let c;if(k){c=`worker failed to start: ${k.message}`;try{rt(a,`${c}
12
+ `)}catch{}}else I?c=`worker timed out after ${n.limits.timeoutMs/1e3}s`:c=`worker exited without submitting a result (exit ${m})
13
13
  --- last log lines ---
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};
14
+ ${Be(a)}`;this.tasks.failIfOpen(e,t.taskId,{result:c,by:`${r} worker`})}catch{}l&&it(E(h,"tree"),H).catch(()=>{}),this.#o(r)};w.on("exit",(m,k)=>$(m??(k?-1:0))),w.on("error",m=>$(-1,m))}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 s;try{s=this.tasks.get(n.code,n.task.taskId).status}catch{this.queue.splice(t,1),t--;continue}if(He.has(s)){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(r){try{this.tasks.failIfOpen(n.code,n.task.taskId,{result:`worker spawn failed: ${r.message}`,by:`${n.task.toTool??n.task.to} worker`})}catch{}}return}}};function ot(i){let e=String(i.request??"").replace(/\s+/g," ").trim();return e.length>80?`${e.slice(0,80)}\u2026`:e}function at(i,{tool:e,instanceIds:t,exclude:n={}}){return n.from&&i.from===n.from||n.fromCwdKey&&i.fromCwdKey===n.fromCwdKey?!1:i.toInstance?t.has(i.toInstance):(i.toTool??i.to)===e}function Lt({key:i,tool:e,self:t,connections:n,agents:s,tasks:r,workers:o,now:a=new Date,limit:l=10}){let h={response:{channelCode:null,tool:e,incoming:[],results:[],stalled:[],more:0},channels:[]},u=[...n].filter(c=>c.cwdKey===i);if(u.length>0&&u.every(c=>c.worker))return h;let d=u.filter(c=>!c.worker),p=t?d.filter(c=>c.instanceId===t):[],f=p.length>0?p:d,w=f.length>0?[...new Set(f.flatMap(c=>[...c.channels]))]:[s.resume(i)].filter(Boolean);if(w.length===0)return h;for(let c of w)o.reconcile(c);let I=new Set(f.map(c=>c.instanceId)),g=c=>!c.hookDelivered?.[i],$=w.flatMap(c=>r.list(c).map(y=>({t:y,code:c}))),m=[...$.filter(({t:c})=>c.status==="submitted"&&g(c)&&at(c,{tool:e,instanceIds:I,exclude:t?{from:t}:{fromCwdKey:i}})).map(({t:c,code:y})=>({t:c,code:y,at:c.createdAt,side:"incoming",entry:{taskId:c.taskId,kind:c.kind??"task",from:c.from,summary:ot(c)}})),...$.filter(({t:c})=>c.fromCwdKey===i&&(c.status==="completed"||c.status==="failed")&&g(c)).map(({t:c,code:y})=>({t:c,code:y,at:c.updatedAt,side:"results",entry:{taskId:c.taskId,status:c.status,to:c.completedBy??c.to,summary:ot(c)}})),...$.filter(({t:c})=>c.fromCwdKey===i&&c.status==="submitted"&&c.fallback?.kind==="none"&&!c.stallNotified?.[i]).map(({t:c,code:y})=>({t:c,code:y,at:c.fallback.at,side:"stalled",entry:{taskId:c.taskId,to:c.to,summary:ot(c),hint:c.fallback.hint??""}}))].sort((c,y)=>c.at<y.at?-1:c.at>y.at?1:0),k=m.slice(0,l);for(let c of k)c.side==="stalled"?r.markStallNotified(c.code,c.t.taskId,i,a):r.markHookDelivered(c.code,c.t.taskId,i,a);let S=c=>k.filter(y=>y.side===c).map(y=>y.entry);return{response:{channelCode:w[0],tool:e,incoming:S("incoming"),results:S("results"),stalled:S("stalled"),more:m.length-k.length},channels:w}}import{loadConfig as lt}from"../shared/config.js";import{pluriplyHome as rn}from"../shared/paths.js";import{shortId as on}from"../shared/ids.js";import{isValidAgentName as Nt,isInstanceId as an,makeInstanceId as cn,toolOf as ln,cwdKey as jt}from"../shared/identity.js";import{PACKAGE_VERSION as Wt,PROTOCOL_VERSION as Gt}from"../shared/version.js";import{pingHub as hn,pidAlive as ht,homeId as Kt}from"../shared/probe.js";function C(i){try{let e=JSON.parse(Qe(i,"utf8"));return Number.isInteger(e?.pid)&&Number.isInteger(e?.port)?e:null}catch{return null}}function Ht(i,e){return!i||!e?!1:i.pid===e.pid&&i.port===e.port&&i.startedAt===e.startedAt&&i.token===e.token}function Ft(i,e){let t=C(i);return(e?!Ht(t,e):t!==null)?!1:(qt(i,{force:!0}),!0)}function un(i,e=500){return new Promise(t=>{let n=en({host:"127.0.0.1",port:i}),s=o=>{clearTimeout(r),n.destroy(),t(o)},r=setTimeout(()=>s(!1),e);n.once("connect",()=>s(!1)),n.once("error",o=>s(o.code==="ECONNREFUSED"))})}var dn=1e4,fn=3e4,mn=5e3,pn="unauthorized: hub requires a token \u2014 re-run `npx pluriply@latest setup` and restart your tool",ut=class extends tn{constructor({home:e=rn(),port:t=0,verifyDelayMs:n=100,takeoverGraceMs:s=dn,lockWatchMs:r=fn,log:o=a=>process.stderr.write(a)}={}){super(),this.home=e,this.requestedPort=t,this.verifyDelayMs=n,this.takeoverGraceMs=s,this.lockWatchMs=r,this.lockTimer=null,this.orphaned=!1,this.stopping=!1,this.stopPromise=null,this.log=o,this.token=null;let a=new j(e);this.channels=new G(a),this.tasks=new F(this.channels),this.context=new q(this.channels),this.agents=new W(a),this.workers=new U({home:e,tasks:this.tasks}),this.wss=null,this.redundant=!1,this.redundantPort=null,this.connections=new Map,this.issued=new Set,this.pickupTimers=new Map}get port(){return this.redundant?this.redundantPort:this.wss?.address()?.port}async start(){Ve(this.home,{recursive:!0,mode:448});try{Ze(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 Je({host:"127.0.0.1",port:this.requestedPort,verifyClient:(s,r)=>{"origin"in s.req.headers?r(!1,403,"Forbidden"):r(!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",s=>this.#i(t,s)),t.on("close",()=>{this.connections.get(t)?.abort.abort(),this.connections.delete(t)})});let e=ct(this.home,"hub.json");for(let t=0;t<2;t++){if(this.#r(e)){await new Promise(r=>setTimeout(r,this.verifyDelayMs));let s=C(e);return s&&s.pid!==process.pid?(await new Promise(r=>this.wss.close(r)),this.wss=null,this.redundant=!0,this.redundantPort=s.port,this.token=typeof s.token=="string"?s.token:null,{port:s.port,redundant:!0}):(this.#e(),this.#w(),{port:this.port})}let n=C(e);if(n&&await this.#t(n,e))return await new Promise(s=>this.wss.close(s)),this.wss=null,this.redundant=!0,this.redundantPort=n.port,this.token=typeof n.token=="string"?n.token:null,{port:n.port,redundant:!0};Ft(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(!ht(e.pid))return!1;let n=Date.now()+this.takeoverGraceMs;for(;;){let s=await hn(e.port,500);if(s)return!s.home||s.home===Kt(this.home);if(!Ht(C(t),e)||await un(e.port)||Date.now()>=n||!ht(e.pid))return!1;await new Promise(r=>setTimeout(r,500))}}#r(e){let t=nn(32).toString("hex"),n=JSON.stringify({pid:process.pid,port:this.wss.address().port,version:Wt,protocol:Gt,startedAt:new Date().toISOString(),token:t},null,2);try{return Xe(e,n,{flag:"wx",mode:384}),this.token=t,!0}catch(s){if(s.code==="EEXIST")return!1;throw s}}#e(){if(!this.lockWatchMs||this.stopping)return;let e=Math.min(mn,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.#e()})},t),this.lockTimer.unref?.()}async#a(){if(this.stopping||this.redundant||!this.wss)return;let e=ct(this.home,"hub.json"),t=C(e);if(!t){if(this.#r(e)){this.log(`hub: lock was missing; re-acquired (pid ${process.pid})
17
+ `),this.#e();return}if(t=C(e),!t){this.log(`hub: lock file is unreadable; keeping the hub running
18
+ `),this.#e();return}}if(t.pid===process.pid){this.#e();return}if(!ht(t.pid)){Ft(e,t)&&this.#r(e)&&this.log(`hub: reclaimed stale lock of dead pid ${t.pid} (pid ${process.pid})
19
+ `),this.#e();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(){this.stopping=!0,this.lockTimer&&clearTimeout(this.lockTimer),this.lockTimer=null;for(let e of this.pickupTimers.values())clearTimeout(e);return this.pickupTimers.clear(),this.redundant||!this.wss?Promise.resolve():(this.stopPromise??=this.#c().finally(()=>{this.stopPromise=null}),this.stopPromise)}async#c(){await this.workers.stopAll();let e=ct(this.home,"hub.json");C(e)?.pid===process.pid&&qt(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 s=this.connections.get(e);if(n.type!=="ping"&&!s?.authed){if(s&&!s.warned){s.warned=!0;let r=String(n.type).slice(0,40).replace(/[^\w.-]/g,"?");this.log(`hub: rejected unauthenticated ${r} from ${s.remote}
21
+ `)}this.#s(e,{id:n.id,ok:!1,error:{message:pn}});return}try{let r=await this.#g(n.type,n.payload??{},e);this.#s(e,{id:n.id,ok:!0,payload:r})}catch(r){this.#s(e,{id:n.id,ok:!1,error:{message:r.message}})}}#s(e,t){e.readyState===e.OPEN&&e.send(JSON.stringify(t))}#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 s=Buffer.from(n[1]),r=Buffer.from(this.token);return s.length===r.length&&sn(s,r)}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,{exclude:n}={}){for(let s of this.connections.values())if(s.tool===t&&!s.worker&&s.channels.has(e)&&!(n&&s.instanceId===n))return!0;return!1}#o(e){let t=[],n=[];for(let s of this.connections.values())!s.instanceId||!s.channels.has(e)||(s.worker?n:t).push(s.instanceId);return{interactive:t,workers:n}}#h(e,t){let n=this.onlineInstances(e);return t.map(s=>({...s,online:n.has(s.instanceId)}))}#m(e){for(;;){let t=cn(e,on(4));if(!(this.issued.has(t)||[...this.connections.values()].some(s=>s.instanceId===t)))return this.issued.add(t),t}}#u(e,t,n){return Lt({key:e,tool:t,self:n,connections:this.connections.values(),agents:this.agents,tasks:this.tasks,workers:this.workers})}#d(e,t,n){let s=`${e}/${t}`;clearTimeout(this.pickupTimers.get(s));let r=setTimeout(()=>{this.pickupTimers.delete(s);try{this.#p(e,t)}catch(o){this.log(`hub: pickup check failed for ${t}: ${o.message}
22
+ `)}},Math.min(Math.max(0,n),2147483647));r.unref?.(),this.pickupTimers.set(s,r)}#p(e,t){if(this.stopping)return;let n;try{n=this.tasks.get(e,t)}catch(o){if(o instanceof b||o instanceof L)return;throw o}if(n.status!=="submitted"||n.fallback||n.hookDelivered&&Object.keys(n.hookDelivered).length>0)return;let s=lt(this.home),r=`no live session picked it up within ${s.limits.interactivePickupSeconds}s`;if(n.mode==="auto"&&!n.toInstance){let o=this.workers.dispatch(e,n,{interactive:!1,config:s});this.tasks.setFallback(e,t,{kind:o.kind,hint:o.kind==="none"?`${r}; ${o.hint}`:o.hint});return}this.tasks.setFallback(e,t,{kind:"none",hint:`${r}; wake the target session or send it as a worker task`})}#w(){let e=lt(this.home).limits.interactivePickupSeconds;if(!(e>0))return;let t=Date.now();for(let n of this.channels.store.listChannels()){let s;try{s=this.tasks.list(n)}catch{continue}for(let r of s)r.status!=="submitted"||!r.dispatchedAt||r.fallback||r.dispatch!=="interactive"&&r.dispatch!=="pinned"||this.#d(n,r.taskId,Date.parse(r.dispatchedAt)+e*1e3-t)}}#f(e,t,n){let s=this.onlineInstances(n);s.add(t.instanceId);let{peers:r}=this.channels.join(n,{instanceId:t.instanceId,tool:t.tool,worker:t.worker},{online:s});return this.agents.touch(t.cwdKey,n),t.channels.add(n),this.#h(n,r)}#g(e,t,n){switch(e){case"ping":return{pong:!0,version:Wt,protocol:Gt,pid:process.pid,home:Kt(this.home)};case"hook.poll":{let s=t.tool;return!Nt(s)||typeof t.cwd!="string"||!Mt(t.cwd)?{channelCode:null,tool:s,incoming:[],results:[],stalled:[],more:0}:this.#u(jt(s,t.cwd),s).response}case"channel.create":return{channelCode:this.channels.create().channel.code};case"agent.hello":{if(!Nt(t.tool))throw new Error(`invalid agent name: ${t.tool}`);if(typeof t.cwd!="string"||t.cwd.length===0||!Mt(t.cwd))throw new Error("cwd is required");let s=this.connections.get(n);if(s.instanceId)return{instanceId:s.instanceId};let r=t.instanceId;if(r!==void 0){if(!an(r)||ln(r)!==t.tool)throw new Error(`invalid instanceId: ${r}`)}else r=this.#m(t.tool);return s.instanceId=r,s.tool=t.tool,s.worker=!!t.worker,s.cwdKey=jt(t.tool,t.cwd),{instanceId:r}}case"channel.join":{let s=this.#n(n);this.channels.get(t.channelCode);let r=this.#f(n,s,t.channelCode);return{channelCode:t.channelCode,peers:r}}case"channel.peers":return{peers:this.#h(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 s=this.#n(n);if(s.channels.size>0)return{channelCode:null,alreadyJoined:!0};let r=this.agents.resume(s.cwdKey);if(!r)return{channelCode:null};let o=this.#f(n,s,r);return{channelCode:r,peers:o}}case"task.create":{let s=this.#n(n),r=lt(this.home),{task:o,targetJoined:a,targetOnline:l,warning:h}=this.tasks.create(t.channelCode,{...t,from:s.instanceId,fromCwdKey:s.cwdKey,depth:t.depth??0,mode:t.mode??"auto",maxDepth:r.limits.maxDepth,allowedRoots:r.allowedRoots,online:this.onlineInstances(t.channelCode)});this.agents.touch(s.cwdKey,t.channelCode);let u=this.workers.dispatch(t.channelCode,o,{interactive:this.isInteractive(t.channelCode,o.toTool,{exclude:s.instanceId}),config:r});this.tasks.setDispatch(t.channelCode,o.taskId,u.kind);let d=r.limits.interactivePickupSeconds,p=d>0&&(u.kind==="interactive"||u.kind==="pinned");p&&this.#d(t.channelCode,o.taskId,d*1e3);let f={taskId:o.taskId,targetJoined:a,dispatch:u.kind};if(l!==void 0&&(f.targetOnline=l),h&&(f.warning=h),u.hint&&(f.hint=u.hint),p&&!f.hint){let w=o.mode==="auto"&&!o.toInstance;f.hint=`waiting for a live ${o.toTool??o.to} session; if none picks it up within ${d}s `+(w?"it goes to a worker (or you will be told)":"you will be told")}return f}case"task.list":{this.workers.reconcile(t.channelCode);let s=this.tasks.list(t.channelCode,{to:t.to,from:t.from,status:t.status,kind:t.kind}),r=this.connections.get(n);if(t.markDelivered===!0&&r?.instanceId&&!r.worker&&r.cwdKey){let o=new Date;for(let a of s)a.status==="submitted"&&!a.hookDelivered?.[r.cwdKey]&&at(a,{tool:r.tool,instanceIds:new Set([r.instanceId]),exclude:{from:r.instanceId}})&&this.tasks.markHookDelivered(t.channelCode,a.taskId,r.cwdKey,o)}return{tasks:s}}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 s=Math.min(Math.max(Number(t.timeoutMs)||0,1),15e3),r=this.connections.get(n)?.abort.signal;return this.tasks.waitFor(t.channelCode,t.taskId,s,{signal:r}).then(o=>{if(o===null)throw new Error("connection closed while waiting");return{task:o}})}case"agent.wait":{let s=this.#n(n);if(s.worker)throw new Error("workers do not listen");let r=Math.min(Math.max(Number(t.timeoutMs)||0,1),15e3),o=Date.now()+r,a=s.abort.signal;return(async()=>{for(;;){let{response:l,channels:h}=this.#u(s.cwdKey,s.tool,s.instanceId),u=l.incoming.length+l.results.length+l.stalled.length,d=o-Date.now();if(u>0||d<=0)return l;if(await this.tasks.waitForChange(h,d,{signal:a})===null)throw new Error("connection closed while waiting")}})()}case"task.claim":{let s=this.#n(n),r=this.tasks.claim(t.channelCode,t.taskId,s.instanceId);return this.agents.touch(s.cwdKey,t.channelCode),{task:r}}case"task.complete":{let s=this.#n(n),r=this.tasks.complete(t.channelCode,t.taskId,{from:s.instanceId,result:t.result,status:t.status,worker:s.worker,review:t.review});if(this.agents.touch(s.cwdKey,t.channelCode),(r.kind??"task")==="review"&&r.status==="completed")try{this.context.add(t.channelCode,{from:s.instanceId,summary:`[review] ${r.result.verdict} by ${s.instanceId}: ${r.result.summary}`,artifacts:[]})}catch{}return{task:r}}case"task.cancel":{let s=this.#n(n),r=this.tasks.cancel(t.channelCode,t.taskId,{agent:s.instanceId,reason:t.reason});return this.agents.touch(s.cwdKey,t.channelCode),this.workers.onCancelled(t.channelCode,t.taskId),{task:r}}case"worker.status":return t.channelCode!==void 0&&this.channels.get(t.channelCode),{running:this.workers.runningCount(t.channelCode)};case"context.add":{let s=this.#n(n),r=this.context.add(t.channelCode,{...t,from:s.instanceId});return this.agents.touch(s.cwdKey,t.channelCode),{entryId:r.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 wn}from"node:child_process";import{existsSync as gn,rmSync as dt}from"node:fs";import{join as kn}from"node:path";import{fileURLToPath as yn}from"node:url";import{pingHub as Ut,homeId as In,pidAlive as Sn}from"../shared/probe.js";import{readLock as Bt}from"../shared/lock.js";var _n=yn(new URL("../../bin/pluriply.js",import.meta.url));async function ft(i){let e=Bt(i);if(!e)return null;let t=await Ut(e.port);return!t||t.home&&t.home!==In(i)?null:{...t,port:e.port,lockPid:e.pid,token:e.token}}var vn=2e4;async function En({home:i,timeoutMs:e=vn}){let t=wn(process.execPath,[_n,"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 s=Date.now()+e;for(;Date.now()<s;){let r=await ft(i);if(r)return r;if(n){let o=await ft(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 Tn({home:i,timeoutMs:e=5e3}){let t=Bt(i),n=kn(i,"hub.json");if(!t)return"not-running";let s=await Ut(t.port,1e3),r=s&&Number.isInteger(s.pid)&&Number.isInteger(t.pid)?s.pid!==t.pid:!1;if(!s||r)return dt(n,{force:!0}),"not-running";try{process.kill(t.pid,"SIGTERM")}catch{return dt(n,{force:!0}),"stopped"}let o=Date.now()+e;for(;Date.now()<o;){if(!gn(n))return"stopped";if(!Sn(t.pid))return dt(n,{force:!0}),"stopped";await new Promise(a=>setTimeout(a,100))}return"timeout"}import{readLock as Us}from"../shared/lock.js";import{loadConfig as Ys,saveConfig as zs,setWorkerEnabled as Js,TEMPLATE_AGENTS as Vs}from"../shared/config.js";export{ut as Hub,Vs as TEMPLATE_AGENTS,ft as liveHub,Ys as loadConfig,Us as readLock,zs as saveConfig,Js as setWorkerEnabled,En as spawnHub,Tn as stopHub};
@@ -4,50 +4,12 @@
4
4
  // 공개 미러에도 실리므로 허브·커넥터는 쓰는 순간에만 동적으로 불러온다(경계 테스트).
5
5
  import { pluriplyHome } from "../shared/paths.js";
6
6
  import { resolveAgentName } from "../shared/identity.js";
7
+ import { formatActivity } from "../shared/activity-format.js";
7
8
 
8
9
  export const HOOK_AGENTS = ["claude-code", "codex", "antigravity"];
9
- const MAX_REASON = 2000;
10
- const MORE_LINE = (n) => `(+${n} more: run list_tasks)`;
11
10
 
12
- /**
13
- * 허브 hook.poll 응답을 모델이 읽을 문구로 만든다. 순수 함수.
14
- * @param {{tool: string, cwd: string, channelCode: string, incoming: object[], results: object[], more: number}} r
15
- * @returns {string}
16
- */
17
- export function formatStopReason(r) {
18
- const head = `pluriply: new activity on channel ${r.channelCode} for ${r.tool} (cwd ${r.cwd}). Handle it before finishing.`;
19
- const inLines = (r.incoming ?? []).map(
20
- (t) => `- ${t.taskId} ${t.kind ?? "task"} from ${t.from}: "${t.summary}"`,
21
- );
22
- const resLines = (r.results ?? []).map(
23
- (t) => `- ${t.taskId} ${t.status} by ${t.to}: "${t.summary}"`,
24
- );
25
- let more = r.more ?? 0;
26
- const build = () => {
27
- const parts = [head];
28
- if (inLines.length)
29
- parts.push(
30
- "Incoming tasks (do the work, then submit_result — or submit_review for reviews; skip one another instance already claimed):",
31
- ...inLines,
32
- );
33
- if (resLines.length)
34
- parts.push(
35
- "Results of tasks you delegated (read them with get_task_result):",
36
- ...resLines,
37
- );
38
- if (more > 0) parts.push(MORE_LINE(more));
39
- return parts.join("\n");
40
- };
41
- let text = build();
42
- // 2,000자를 넘으면 뒤 항목부터 덜어내고 more 를 늘린다
43
- while (text.length > MAX_REASON && inLines.length + resLines.length > 0) {
44
- if (resLines.length) resLines.pop();
45
- else inLines.pop();
46
- more++;
47
- text = build();
48
- }
49
- return text;
50
- }
11
+ /** 허브 hook.poll 응답을 모델이 읽을 문구로 만든다(공유 함수의 옛 이름, 호환용). */
12
+ export { formatActivity as formatStopReason };
51
13
 
52
14
  /** @param {string} input @returns {object} 손상·빈 입력은 {} */
53
15
  function parseInput(input) {
@@ -135,8 +97,14 @@ export async function runStopHook({
135
97
  work.catch(() => {}); // 위 race 가 이미 끝난 뒤의 거부가 미처리로 남지 않게
136
98
  const r = await Promise.race([work, deadline]);
137
99
  if (!r || !r.channelCode) return {};
138
- if ((r.incoming?.length ?? 0) + (r.results?.length ?? 0) === 0) return {};
139
- const reason = formatStopReason({ ...r, cwd: at });
100
+ if (
101
+ (r.incoming?.length ?? 0) +
102
+ (r.results?.length ?? 0) +
103
+ (r.stalled?.length ?? 0) ===
104
+ 0
105
+ )
106
+ return {};
107
+ const reason = formatActivity({ ...r, cwd: at });
140
108
  // antigravity 는 {decision:"continue"} 라야 멈추지 않고 reason 을 주입한다(실기기 확인).
141
109
  // 다른 도구는 그대로 block.
142
110
  return agent === "antigravity"
@@ -0,0 +1,55 @@
1
+ // 허브의 활동 응답(hook.poll·agent.wait)을 모델이 읽을 문구로 만든다(Plan 4c §5, Plan 5a §7).
2
+ // 순수 함수. 공개 코드라 허브를 import 하지 않는다.
3
+ const MAX_TEXT = 2000;
4
+ const MORE_LINE = (n) => `(+${n} more: run list_tasks)`;
5
+
6
+ /**
7
+ * @param {{tool: string, cwd: string, channelCode: string, incoming?: object[], results?: object[], stalled?: object[], more?: number}} r
8
+ * @returns {string}
9
+ */
10
+ export function formatActivity(r) {
11
+ const head = `pluriply: new activity on channel ${r.channelCode} for ${r.tool} (cwd ${r.cwd}). Handle it before finishing.`;
12
+ const inLines = (r.incoming ?? []).map(
13
+ (t) => `- ${t.taskId} ${t.kind ?? "task"} from ${t.from}: "${t.summary}"`,
14
+ );
15
+ const resLines = (r.results ?? []).map(
16
+ (t) => `- ${t.taskId} ${t.status} by ${t.to}: "${t.summary}"`,
17
+ );
18
+ const stallLines = (r.stalled ?? []).map(
19
+ (t) => `- ${t.taskId} to ${t.to}: "${t.summary}" — ${t.hint}`,
20
+ );
21
+ let more = r.more ?? 0;
22
+ const build = () => {
23
+ const parts = [head];
24
+ if (inLines.length)
25
+ parts.push(
26
+ "Incoming tasks (do the work, then submit_result — or submit_review for reviews; skip one another instance already claimed):",
27
+ ...inLines,
28
+ );
29
+ if (resLines.length)
30
+ parts.push(
31
+ "Results of tasks you delegated (read them with get_task_result):",
32
+ ...resLines,
33
+ );
34
+ if (stallLines.length)
35
+ parts.push(
36
+ "Waiting on others (no live session picked these up; see the hint):",
37
+ ...stallLines,
38
+ );
39
+ if (more > 0) parts.push(MORE_LINE(more));
40
+ return parts.join("\n");
41
+ };
42
+ let text = build();
43
+ // 2,000자를 넘으면 대기 알림 → 결과 → 받은 태스크 순으로 뒤에서부터 덜어내고 more 를 늘린다
44
+ while (
45
+ text.length > MAX_TEXT &&
46
+ inLines.length + resLines.length + stallLines.length > 0
47
+ ) {
48
+ if (stallLines.length) stallLines.pop();
49
+ else if (resLines.length) resLines.pop();
50
+ else inLines.pop();
51
+ more++;
52
+ text = build();
53
+ }
54
+ return text;
55
+ }
@@ -16,12 +16,14 @@ export const DEFAULT_LIMITS = Object.freeze({
16
16
  timeoutMs: 20 * 60 * 1000,
17
17
  maxConcurrentPerAgent: 1,
18
18
  maxQueuedPerAgent: 10,
19
+ // 대화형 세션 몫으로 판정된 태스크를 아무도 받아 보지 않으면 워커로 넘기기까지의 초(Plan 5a). 0 이면 끔.
20
+ interactivePickupSeconds: 120,
19
21
  });
20
22
 
21
23
  /**
22
24
  * `<home>/config.json`. 없거나 손상되면 기본값.
23
25
  * @param {string} home
24
- * @returns {{workers: object, limits: {maxDepth: number, timeoutMs: number, maxConcurrentPerAgent: number, maxQueuedPerAgent: number}, allowedRoots: string[]}}
26
+ * @returns {{workers: object, limits: {maxDepth: number, timeoutMs: number, maxConcurrentPerAgent: number, maxQueuedPerAgent: number, interactivePickupSeconds: number}, allowedRoots: string[]}}
25
27
  */
26
28
  export function loadConfig(home) {
27
29
  const file = join(home, "config.json");
@@ -43,7 +45,11 @@ export function loadConfig(home) {
43
45
  const limits = { ...DEFAULT_LIMITS };
44
46
  for (const key of Object.keys(DEFAULT_LIMITS)) {
45
47
  const v = doc.limits?.[key];
46
- if (Number.isInteger(v) && v > 0) limits[key] = v;
48
+ if (key === "interactivePickupSeconds") {
49
+ // 0(끔)과 소수(테스트용 짧은 기한)를 받는다
50
+ if (typeof v === "number" && Number.isFinite(v) && v >= 0)
51
+ limits[key] = v;
52
+ } else if (Number.isInteger(v) && v > 0) limits[key] = v;
47
53
  }
48
54
  const allowedRoots = Array.isArray(doc.allowedRoots)
49
55
  ? doc.allowedRoots.filter((r) => typeof r === "string" && isAbsolute(r))
@@ -15,5 +15,6 @@ export const PACKAGE_VERSION = pkg.version;
15
15
  * 5: Plan 3a (task.wait 보류 응답).
16
16
  * 6: Plan 3b (task.kind/review, task.complete의 review, task.list의 kind).
17
17
  * 7: Plan 4f (연결 토큰 — ping 외 모든 요청은 Authorization: Bearer <token> 연결에서만 처리).
18
+ * 8: Plan 5a (agent.wait 보류 요청, 태스크 dispatch·fallback 기록, 활동 응답의 stalled).
18
19
  */
19
- export const PROTOCOL_VERSION = 7;
20
+ export const PROTOCOL_VERSION = 8;